Compare commits

...

25 Commits

Author SHA1 Message Date
semantic-release-bot b1e01d377a 🔖 chore(release): v1.106.2 [skip ci]
### [Version 1.106.2](https://github.com/lobehub/lobe-chat/compare/v1.106.1...v1.106.2)
<sup>Released on **2025-07-29**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix desktop auth redirect url error.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's fixed

* **misc**: Fix desktop auth redirect url error, closes [#8597](https://github.com/lobehub/lobe-chat/issues/8597) ([0ed7368](https://github.com/lobehub/lobe-chat/commit/0ed7368))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 16:32:35 +00:00
Arvin Xu 0ed73685dc 🐛 fix: fix desktop auth redirect url error (#8597)
* try to fix cloudflare issue

* fix 0.0.0.0

* fix route

* fix desktop callback

* update pkg

* try to fix again

* try to fix route again
2025-07-30 00:17:23 +08:00
lobehubbot f92b86b194 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 14:11:44 +00:00
semantic-release-bot 1cf58899d8 🔖 chore(release): v1.106.1 [skip ci]
### [Version&nbsp;1.106.1](https://github.com/lobehub/lobe-chat/compare/v1.106.0...v1.106.1)
<sup>Released on **2025-07-29**</sup>

#### 💄 Styles

- **misc**: Support Minimax T2I models.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Styles

* **misc**: Support Minimax T2I models, closes [#8583](https://github.com/lobehub/lobe-chat/issues/8583) ([f8a01aa](https://github.com/lobehub/lobe-chat/commit/f8a01aa))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 14:10:47 +00:00
YuTengjing c1dfa7ab49 style: none login user access ai image page error (#8605) 2025-07-29 21:55:13 +08:00
Zhijie He f8a01aacb0 💄 style: support Minimax T2I models (#8583) 2025-07-29 21:54:56 +08:00
lobehubbot c29559cb2e 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 13:09:02 +00:00
semantic-release-bot 16dd71b5e9 🔖 chore(release): v1.106.0 [skip ci]
## [Version&nbsp;1.106.0](https://github.com/lobehub/lobe-chat/compare/v1.105.6...v1.106.0)
<sup>Released on **2025-07-29**</sup>

####  Features

- **misc**: Add support for Okta Authentication.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's improved

* **misc**: Add support for Okta Authentication, closes [#8547](https://github.com/lobehub/lobe-chat/issues/8547) ([67abdfe](https://github.com/lobehub/lobe-chat/commit/67abdfe))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 13:08:05 +00:00
Arvin Xu ad8f5e451b 📝 docs: add chinese docs for okta provider (#8607)
Reverted a micro-change which was changed during some testing (back to original)

Removed Okta Test

Removed deprecated env variables

Add Okta support to auth config and tests
2025-07-29 20:52:02 +08:00
Jamie Stivala 67abdfe153 feat: Add support for Okta Authentication (#8547)
* Add Okta support to auth config and tests

* Added documentation

* Removed deprecated env variables

* Added Okta as SSO Provider

* Removed Okta Test

* Reverted a micro-change which was changed during some testing (back to original)

* Added Okta to SSO providers list
2025-07-29 20:48:52 +08:00
lobehubbot 672580737f 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 12:21:49 +00:00
semantic-release-bot 778152f966 🔖 chore(release): v1.105.6 [skip ci]
### [Version&nbsp;1.105.6](https://github.com/lobehub/lobe-chat/compare/v1.105.5...v1.105.6)
<sup>Released on **2025-07-29**</sup>

#### 💄 Styles

- **misc**: Open new topic by tap Just Chat again.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Styles

* **misc**: Open new topic by tap Just Chat again, closes [#8426](https://github.com/lobehub/lobe-chat/issues/8426) ([018ca75](https://github.com/lobehub/lobe-chat/commit/018ca75))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 12:20:56 +00:00
René Wang 018ca7598c 💄 style: Open new topic by tap Just Chat again (#8426) 2025-07-29 20:05:29 +08:00
lobehubbot 6bfba72dce 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 11:28:43 +00:00
semantic-release-bot b7dbcfd310 🔖 chore(release): v1.105.5 [skip ci]
### [Version&nbsp;1.105.5](https://github.com/lobehub/lobe-chat/compare/v1.105.4...v1.105.5)
<sup>Released on **2025-07-29**</sup>

#### 🐛 Bug Fixes

- **misc**: Reorder AppTheme and Locale to fix modal i18n.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's fixed

* **misc**: Reorder AppTheme and Locale to fix modal i18n, closes [#8600](https://github.com/lobehub/lobe-chat/issues/8600) ([3264cf2](https://github.com/lobehub/lobe-chat/commit/3264cf2))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 11:27:39 +00:00
Zhaker 3264cf2106 🐛 fix: reorder AppTheme and Locale to fix modal i18n (#8600) 2025-07-29 19:12:09 +08:00
lobehubbot 9a372f622d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 10:36:16 +00:00
semantic-release-bot de05d4165f 🔖 chore(release): v1.105.4 [skip ci]
### [Version&nbsp;1.105.4](https://github.com/lobehub/lobe-chat/compare/v1.105.3...v1.105.4)
<sup>Released on **2025-07-29**</sup>

#### 🐛 Bug Fixes

- **misc**: Revert jose to ^5 to fix auth issue on desktop.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's fixed

* **misc**: Revert jose to ^5 to fix auth issue on desktop, closes [#8603](https://github.com/lobehub/lobe-chat/issues/8603) ([57118b0](https://github.com/lobehub/lobe-chat/commit/57118b0))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 10:35:23 +00:00
Arvin Xu 57118b031e 🐛 fix: revert jose to ^5 to fix auth issue on desktop (#8603) 2025-07-29 18:20:16 +08:00
lobehubbot c4b2bb59c3 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 09:57:47 +00:00
semantic-release-bot 2211cd7632 🔖 chore(release): v1.105.3 [skip ci]
### [Version&nbsp;1.105.3](https://github.com/lobehub/lobe-chat/compare/v1.105.2...v1.105.3)
<sup>Released on **2025-07-29**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix subscription plan tag display.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's fixed

* **misc**: Fix subscription plan tag display, closes [#8599](https://github.com/lobehub/lobe-chat/issues/8599) ([2a3754a](https://github.com/lobehub/lobe-chat/commit/2a3754a))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 09:56:53 +00:00
Arvin Xu 2a3754a436 🐛 fix: fix subscription plan tag display (#8599) 2025-07-29 17:41:42 +08:00
lobehubbot e448d52d5c 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 07:12:06 +00:00
semantic-release-bot 04edfca6e0 🔖 chore(release): v1.105.2 [skip ci]
### [Version&nbsp;1.105.2](https://github.com/lobehub/lobe-chat/compare/v1.105.1...v1.105.2)
<sup>Released on **2025-07-29**</sup>

#### ♻ Code Refactoring

- **misc**: Clean mcp sitemap, refactor jose-JWT to xor obfuscation.

#### 💄 Styles

- **misc**: Add more OpenAI SDK Text2Image providers, update i18n.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Code refactoring

* **misc**: Clean mcp sitemap, closes [#8596](https://github.com/lobehub/lobe-chat/issues/8596) ([b9e3e66](https://github.com/lobehub/lobe-chat/commit/b9e3e66))
* **misc**: Refactor jose-JWT to xor obfuscation, closes [#8595](https://github.com/lobehub/lobe-chat/issues/8595) ([be98d56](https://github.com/lobehub/lobe-chat/commit/be98d56))

#### Styles

* **misc**: Add more OpenAI SDK Text2Image providers, closes [#8573](https://github.com/lobehub/lobe-chat/issues/8573) ([403aebd](https://github.com/lobehub/lobe-chat/commit/403aebd))
* **misc**: Update i18n, closes [#8593](https://github.com/lobehub/lobe-chat/issues/8593) ([356cf0c](https://github.com/lobehub/lobe-chat/commit/356cf0c))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-07-29 07:11:05 +00:00
Arvin Xu b9e3e6633e ♻️ refactor: clean mcp sitemap (#8596) 2025-07-29 14:55:46 +08:00
37 changed files with 1370 additions and 206 deletions
+210
View File
@@ -2,6 +2,216 @@
# Changelog
### [Version 1.106.2](https://github.com/lobehub/lobe-chat/compare/v1.106.1...v1.106.2)
<sup>Released on **2025-07-29**</sup>
#### 🐛 Bug Fixes
- **misc**: Fix desktop auth redirect url error.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Fix desktop auth redirect url error, closes [#8597](https://github.com/lobehub/lobe-chat/issues/8597) ([0ed7368](https://github.com/lobehub/lobe-chat/commit/0ed7368))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.106.1](https://github.com/lobehub/lobe-chat/compare/v1.106.0...v1.106.1)
<sup>Released on **2025-07-29**</sup>
#### 💄 Styles
- **misc**: Support Minimax T2I models.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Support Minimax T2I models, closes [#8583](https://github.com/lobehub/lobe-chat/issues/8583) ([f8a01aa](https://github.com/lobehub/lobe-chat/commit/f8a01aa))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 1.106.0](https://github.com/lobehub/lobe-chat/compare/v1.105.6...v1.106.0)
<sup>Released on **2025-07-29**</sup>
#### ✨ Features
- **misc**: Add support for Okta Authentication.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **misc**: Add support for Okta Authentication, closes [#8547](https://github.com/lobehub/lobe-chat/issues/8547) ([67abdfe](https://github.com/lobehub/lobe-chat/commit/67abdfe))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.105.6](https://github.com/lobehub/lobe-chat/compare/v1.105.5...v1.105.6)
<sup>Released on **2025-07-29**</sup>
#### 💄 Styles
- **misc**: Open new topic by tap Just Chat again.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Open new topic by tap Just Chat again, closes [#8426](https://github.com/lobehub/lobe-chat/issues/8426) ([018ca75](https://github.com/lobehub/lobe-chat/commit/018ca75))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.105.5](https://github.com/lobehub/lobe-chat/compare/v1.105.4...v1.105.5)
<sup>Released on **2025-07-29**</sup>
#### 🐛 Bug Fixes
- **misc**: Reorder AppTheme and Locale to fix modal i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Reorder AppTheme and Locale to fix modal i18n, closes [#8600](https://github.com/lobehub/lobe-chat/issues/8600) ([3264cf2](https://github.com/lobehub/lobe-chat/commit/3264cf2))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.105.4](https://github.com/lobehub/lobe-chat/compare/v1.105.3...v1.105.4)
<sup>Released on **2025-07-29**</sup>
#### 🐛 Bug Fixes
- **misc**: Revert jose to ^5 to fix auth issue on desktop.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Revert jose to ^5 to fix auth issue on desktop, closes [#8603](https://github.com/lobehub/lobe-chat/issues/8603) ([57118b0](https://github.com/lobehub/lobe-chat/commit/57118b0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.105.3](https://github.com/lobehub/lobe-chat/compare/v1.105.2...v1.105.3)
<sup>Released on **2025-07-29**</sup>
#### 🐛 Bug Fixes
- **misc**: Fix subscription plan tag display.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Fix subscription plan tag display, closes [#8599](https://github.com/lobehub/lobe-chat/issues/8599) ([2a3754a](https://github.com/lobehub/lobe-chat/commit/2a3754a))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.105.2](https://github.com/lobehub/lobe-chat/compare/v1.105.1...v1.105.2)
<sup>Released on **2025-07-29**</sup>
#### ♻ Code Refactoring
- **misc**: Clean mcp sitemap, refactor jose-JWT to xor obfuscation.
#### 💄 Styles
- **misc**: Add more OpenAI SDK Text2Image providers, update i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Code refactoring
- **misc**: Clean mcp sitemap, closes [#8596](https://github.com/lobehub/lobe-chat/issues/8596) ([b9e3e66](https://github.com/lobehub/lobe-chat/commit/b9e3e66))
- **misc**: Refactor jose-JWT to xor obfuscation, closes [#8595](https://github.com/lobehub/lobe-chat/issues/8595) ([be98d56](https://github.com/lobehub/lobe-chat/commit/be98d56))
#### Styles
- **misc**: Add more OpenAI SDK Text2Image providers, closes [#8573](https://github.com/lobehub/lobe-chat/issues/8573) ([403aebd](https://github.com/lobehub/lobe-chat/commit/403aebd))
- **misc**: Update i18n, closes [#8593](https://github.com/lobehub/lobe-chat/issues/8593) ([356cf0c](https://github.com/lobehub/lobe-chat/commit/356cf0c))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.105.1](https://github.com/lobehub/lobe-chat/compare/v1.105.0...v1.105.1)
<sup>Released on **2025-07-29**</sup>
+49
View File
@@ -1,4 +1,53 @@
[
{
"children": {
"improvements": ["Support Minimax T2I models."]
},
"date": "2025-07-29",
"version": "1.106.1"
},
{
"children": {
"features": ["Add support for Okta Authentication."]
},
"date": "2025-07-29",
"version": "1.106.0"
},
{
"children": {
"improvements": ["Open new topic by tap Just Chat again."]
},
"date": "2025-07-29",
"version": "1.105.6"
},
{
"children": {
"fixes": ["Reorder AppTheme and Locale to fix modal i18n."]
},
"date": "2025-07-29",
"version": "1.105.5"
},
{
"children": {
"fixes": ["Revert jose to ^5 to fix auth issue on desktop."]
},
"date": "2025-07-29",
"version": "1.105.4"
},
{
"children": {
"fixes": ["Fix subscription plan tag display."]
},
"date": "2025-07-29",
"version": "1.105.3"
},
{
"children": {
"improvements": ["Add more OpenAI SDK Text2Image providers, update i18n."]
},
"date": "2025-07-29",
"version": "1.105.2"
},
{
"children": {
"improvements": ["Support more Text2Image from Qwen."]
+3
View File
@@ -55,6 +55,8 @@ Currently supported identity verification services include:
<Card href={'/docs/self-hosting/advanced/auth/next-auth/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/google'} title={'Google'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/okta'} title={'Okta'} />
</Cards>
Click on the links to view the corresponding platform's configuration documentation.
@@ -78,6 +80,7 @@ The order corresponds to the display order of the SSO providers.
| ZITADEL | `zitadel` |
| Keycloak | `keycloak` |
| Google | `google` |
| Okta | `okta` |
## Other SSO Providers
@@ -51,6 +51,8 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/logto'} title={'Logto'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/keycloak'} title={'Keycloak'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/okta'} title={'Okta'} />
</Cards>
点击即可查看对应平台的配置文档。
@@ -73,6 +75,7 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
| Microsoft Entra ID | `microsoft-entra-id` |
| ZITADEL | `zitadel` |
| Keycloak | `keycloak` |
| Okta | `okta` |
## 其他 SSO 提供商
@@ -0,0 +1,65 @@
---
title: Configure Okta Identity Verification Service for LobeChat
description: >-
Learn how to configure Okta Identity Verification Service for LobeChat for your organization, including creating applications, adding users, and configuring environment variables.
tags:
- Okta
- Identity Verification
- Single Sign-On
- Environment Variables
- User Management
- SSO Integrations
- Social Login
---
# Configure Okta Identity Verification Service
<Steps>
### Create Okta Application
Register and log in to [Okta][okta-client-page], open the "Applications" subtab in the left navigation bar, and click "Applications" to switch to the application management interface. click "Create App Integration" in the upper left corner to create an application.
Select "OIDC - OpenID Connect" in Sign-In Method and then select "Web Application" in Application Type.
Fill in the following settings:
| Setting Name | Description | Sample Information |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------- |
| App Integration Name | The Application Name your users will see | LobeChat Instance |
| Sign-in redirect URIs | Okta sends the authentication response and ID token for the user's sign-in request to these URIs | (http(s)://your-domain/api/auth/callback/okta |
| Sign-out redirect URIs | After your application contacts Okta to close the user session, Okta redirects the user to one of these URIs | (http(s)://your-domain |
<Callout type={'important'}>
You can fill in or modify all the fields after deployment, but make sure the filled URL is
consistent with the deployed URL.
</Callout>
### Add Users
Click on the "Assignments" in the top navigation bar to enter the user management interface, where you can create or assign users in your organization to log in to LobeChat.
### Configure Environment Variables
When deploying LobeChat, you need to configure the following environment variables:
| Environment Variable | Type | Description |
| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LoboChat. Use `okta` for Okta. |
| `AUTH_OKTA_ID` | Required | Client ID of the Okta application |
| `AUTH_OKTA_SECRET` | Required | Client Secret of the Okta application |
| `AUTH_OKTA_ISSUER` | Required | Domain of the Okta application, `https://example.oktapreview.com` |
| `NEXTAUTH_URL` | Optional | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
<Callout type={'tip'}>
You can refer to the related variable details at [📘Environment Variables](/docs/self-hosting/environment-variable/auth#okta).
</Callout>
</Steps>
<Callout>
After successful deployment, users will be able to authenticate and use LobeChat using the users
configured in Okta.
</Callout>
[okta-client-page]: https://login.okta.com
@@ -0,0 +1,63 @@
---
title: 在 LobeChat 中配置 Okta 身份验证服务 - 详细步骤和环境变量设置
description: >-
学习如何在 LobeChat 中为您的组织配置 Okta 身份验证服务,包括创建应用程序、添加用户和配置环境变量等。
tags:
- Okta
- 身份验证
- 单点登录
- 环境变量
- 用户管理
- SSO 集成
- 社交登录
---
# 配置 Okta 身份验证服务
<Steps>
### 创建 Okta 应用程序
注册并登录 [Okta][okta-client-page],打开左侧导航栏中的「Applications」子选项卡,点击「Applications」切换到应用程序管理界面。点击左上角的「Create App Integration」创建应用程序。
在登录方法中选择「OIDC - OpenID Connect」,然后在应用程序类型中选择「Web Application」。
填写以下设置:
| 设置名称 | 描述 | 示例信息 |
| ---------------------- | ------------------------------------------- | --------------------------------------------- |
| App Integration Name | 您的用户将看到的应用程序名称 | LobeChat Instance |
| Sign-in redirect URIs | Okta 将用户登录请求的身份验证响应和 ID 令牌发送到这些 URI | (http(s)://your-domain/api/auth/callback/okta |
| Sign-out redirect URIs | 您的应用程序联系 Okta 关闭用户会话后,Okta 将用户重定向到这些 URI 之一 | (http(s)://your-domain |
<Callout type={'important'}>
您可以在部署后填写或修改所有字段,但请确保填写的 URL 与部署的 URL 一致。
</Callout>
### 添加用户
点击顶部导航栏中的「Assignments」进入用户管理界面,您可以在此创建或分配组织中的用户来登录 LobeChat。
### 配置环境变量
在部署 LobeChat 时,您需要配置以下环境变量:
| 环境变量 | 类型 | 描述 |
| ------------------------- | -- | ------------------------------------------------------------------------------------ |
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成密钥:`openssl rand -base64 32` |
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Okta 请填写 `okta`。 |
| `AUTH_OKTA_ID` | 必选 | Okta 应用程序的客户端 ID |
| `AUTH_OKTA_SECRET` | 必选 | Okta 应用程序的客户端密钥 |
| `AUTH_OKTA_ISSUER` | 必选 | Okta 应用程序的域名,`https://example.oktapreview.com` |
| `NEXTAUTH_URL` | 可选 | 该 URL 用于指定 Auth.js 在执行 OAuth 认证时的回调地址。仅当默认地址不正确时才需要设置。`https://example.com/api/auth` |
<Callout type={'tip'}>
您可以在 [📘环境变量](/zh/docs/self-hosting/environment-variables/auth#okta) 查阅相关变量详情。
</Callout>
</Steps>
<Callout>
部署成功后,用户将能够使用在 Okta 中配置的用户进行身份验证并使用 LobeChat。
</Callout>
[okta-client-page]: https://login.okta.com
@@ -249,6 +249,29 @@ LobeChat provides a complete authentication service capability when deployed. Th
- Default: `-`
- Example: `https://your-instance-abc123.zitadel.cloud`
### Okta
#### `AUTH_OKTA_ID`
- Type: Required
- Description: Client ID of the Okta application. This can be found under your application settings in the Okta console.
- Default: `-`
- Example: `ac12c950f3ce48c8a45a`
#### `AUTH_OKTA_SECRET`
- Type: Required
- Description: Client Secret of the Okta application. This can be found under your application settings in the Okta console.
- Default: `-`
- Example: `ex1HqvSOOkC5INqo42grOSqNvHoD4p84em1yy5QU7v88IZlaWGywFjYkrkpkSopt`
#### `AUTH_OKTA_ISSUER`
- Type: Required
- Description: Issuer of the Okta application. This is the URL of the Okta instance -- If branding is set up, it can be your custom domain.
- Default: `-`
- Example: `https://your-instance.okta.com`
### Generic OIDC
#### `AUTH_GENERIC_OIDC_ID`
@@ -245,6 +245,29 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
- 默认值:`-`
- 示例:`https://your-instance-abc123.zitadel.cloud`
### Okta
#### `AUTH_OKTA_ID`
- 类型:必选
- 描述:Okta 应用程序的 Client ID。您可以在 Okta 控制台的应用程序设置中找到。
- 默认值:`-`
- 示例:`ac12c950f3ce48c8a45a`
#### `AUTH_OKTA_SECRET`
- 类型:必选
- 描述:Okta 应用程序的 Client Secret。您可以在 Okta 控制台的应用程序设置中找到。
- 默认值:`-`
- 示例:`ex1HqvSOOkC5INqo42grOSqNvHoD4p84em1yy5QU7v88IZlaWGywFjYkrkpkSopt`
#### `AUTH_OKTA_ISSUER`
- 类型:必选
- 描述:Okta 应用程序的 OpenID Connect 颁发者(issuer)。这是 Okta 实例的 URL—— 如果设置了品牌化,也可以是您的自定义域名。
- 默认值:`-`
- 示例:`https://your-instance.okta.com`
### Generic OIDC
#### `AUTH_GENERIC_OIDC_ID`
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/chat",
"version": "1.105.1",
"version": "1.106.2",
"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",
@@ -196,7 +196,7 @@
"i18next-resources-to-backend": "^1.2.1",
"idb-keyval": "^6.2.2",
"immer": "^10.1.1",
"jose": "^6.0.12",
"jose": "^5.10.0",
"js-sha256": "^0.11.1",
"jsonl-parse-stringify": "^1.0.3",
"keyv": "^4.5.4",
@@ -257,7 +257,7 @@
"semver": "^7.7.2",
"sharp": "^0.34.3",
"shiki": "^3.8.1",
"stripe": "^16.12.0",
"stripe": "^17.7.0",
"superjson": "^2.2.2",
"svix": "^1.69.0",
"swr": "^2.3.4",
@@ -3,9 +3,50 @@ import { NextRequest, NextResponse, after } from 'next/server';
import { OAuthHandoffModel } from '@/database/models/oauthHandoff';
import { serverDB } from '@/database/server';
import { correctOIDCUrl } from '@/utils/server/correctOIDCUrl';
const log = debug('lobe-oidc:callback:desktop');
const errorPathname = '/oauth/callback/error';
/**
* 安全地构建重定向URL
*/
const buildRedirectUrl = (req: NextRequest, pathname: string): URL => {
const forwardedHost = req.headers.get('x-forwarded-host');
const requestHost = req.headers.get('host');
const forwardedProto =
req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
// 确定实际的主机名,提供后备值
const actualHost = forwardedHost || requestHost;
const actualProto = forwardedProto || 'https';
log(
'Building redirect URL - host: %s, proto: %s, pathname: %s',
actualHost,
actualProto,
pathname,
);
// 如果主机名仍然无效,使用req.nextUrl作为后备
if (!actualHost) {
log('Warning: Invalid host detected, using req.nextUrl as fallback');
const fallbackUrl = req.nextUrl.clone();
fallbackUrl.pathname = pathname;
return fallbackUrl;
}
try {
return new URL(`${actualProto}://${actualHost}${pathname}`);
} catch (error) {
log('Error constructing URL, using req.nextUrl as fallback: %O', error);
const fallbackUrl = req.nextUrl.clone();
fallbackUrl.pathname = pathname;
return fallbackUrl;
}
};
export const GET = async (req: NextRequest) => {
try {
const searchParams = req.nextUrl.searchParams;
@@ -14,9 +55,11 @@ export const GET = async (req: NextRequest) => {
if (!code || !state || typeof code !== 'string' || typeof state !== 'string') {
log('Missing code or state in form data');
const errorUrl = req.nextUrl.clone();
errorUrl.pathname = '/oauth/callback/error';
const errorUrl = buildRedirectUrl(req, errorPathname);
errorUrl.searchParams.set('reason', 'invalid_request');
log('Redirecting to error URL: %s', errorUrl.toString());
return NextResponse.redirect(errorUrl);
}
@@ -31,9 +74,16 @@ export const GET = async (req: NextRequest) => {
await authHandoffModel.create({ client, id, payload });
log('Handoff record created successfully for id: %s', id);
// Redirect to a generic success page. The desktop app will poll for the result.
const successUrl = req.nextUrl.clone();
successUrl.pathname = '/oauth/callback/success';
const successUrl = buildRedirectUrl(req, '/oauth/callback/success');
// 添加调试日志
log('Request host header: %s', req.headers.get('host'));
log('Request x-forwarded-host: %s', req.headers.get('x-forwarded-host'));
log('Request x-forwarded-proto: %s', req.headers.get('x-forwarded-proto'));
log('Constructed success URL: %s', successUrl.toString());
const correctedUrl = correctOIDCUrl(req, successUrl);
log('Final redirect URL: %s', correctedUrl.toString());
// cleanup expired
after(async () => {
@@ -42,17 +92,18 @@ export const GET = async (req: NextRequest) => {
log('Cleaned up %d expired handoff records', cleanedCount);
});
return NextResponse.redirect(successUrl);
return NextResponse.redirect(correctedUrl);
} catch (error) {
log('Error in OIDC callback: %O', error);
const errorUrl = req.nextUrl.clone();
errorUrl.pathname = '/oauth/callback/error';
const errorUrl = buildRedirectUrl(req, errorPathname);
errorUrl.searchParams.set('reason', 'internal_error');
if (error instanceof Error) {
errorUrl.searchParams.set('errorMessage', error.message);
}
log('Redirecting to error URL: %s', errorUrl.toString());
return NextResponse.redirect(errorUrl);
}
};
+9 -12
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { OIDCService } from '@/server/services/oidc';
import { getUserAuth } from '@/utils/server/auth';
import { correctOIDCUrl } from '@/utils/server/correctOIDCUrl';
const log = debug('lobe-oidc:consent');
@@ -113,19 +114,15 @@ export async function POST(request: NextRequest) {
const internalRedirectUrlString = await oidcService.getInteractionResult(uid, result);
log('OIDC Provider internal redirect URL string: %s', internalRedirectUrlString);
// // Construct the handoff URL
// const handoffUrl = new URL('/oauth/handoff', request.nextUrl.origin);
// // Set the original redirect URL as the 'target' query parameter (URL encoded)
// handoffUrl.searchParams.set('target', internalRedirectUrlString);
//
// log('Redirecting to handoff page: %s', handoffUrl.toString());
// // Redirect to the handoff page
// return NextResponse.redirect(handoffUrl.toString(), {
// headers: request.headers, // Keep original headers if necessary
// status: 303,
// });
let finalRedirectUrl;
try {
finalRedirectUrl = correctOIDCUrl(request, new URL(internalRedirectUrlString));
} catch {
finalRedirectUrl = new URL(internalRedirectUrlString);
log('Warning: Could not parse redirect URL, using as-is: %s', internalRedirectUrlString);
}
return NextResponse.redirect(internalRedirectUrlString, {
return NextResponse.redirect(finalRedirectUrl, {
headers: request.headers,
status: 303,
});
@@ -6,6 +6,8 @@ import { DEFAULT_INBOX_AVATAR } from '@/const/meta';
import { INBOX_SESSION_ID } from '@/const/session';
import { SESSION_CHAT_URL } from '@/const/url';
import { useSwitchSession } from '@/hooks/useSwitchSession';
import { getChatStoreState, useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
@@ -17,13 +19,26 @@ const Inbox = memo(() => {
const activeId = useSessionStore((s) => s.activeId);
const switchSession = useSwitchSession();
const openNewTopicOrSaveTopic = useChatStore((s) => s.openNewTopicOrSaveTopic);
return (
<Link
aria-label={t('inbox.title')}
href={SESSION_CHAT_URL(INBOX_SESSION_ID, mobile)}
onClick={(e) => {
onClick={async (e) => {
e.preventDefault();
switchSession(INBOX_SESSION_ID);
if (activeId === INBOX_SESSION_ID && !mobile) {
// If user tap the inbox again, open a new topic.
// Only for desktop.
const inboxMessages = chatSelectors.inboxActiveTopicMessages(getChatStoreState());
if (inboxMessages.length > 0) {
await openNewTopicOrSaveTopic();
}
} else {
switchSession(INBOX_SESSION_ID);
}
}}
>
<ListItem
@@ -5,15 +5,18 @@ import { useSize } from 'ahooks';
import { memo, useRef } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useFetchGenerationTopics } from '@/hooks/useFetchGenerationTopics';
import { useImageStore } from '@/store/image';
import { generationTopicSelectors } from '@/store/image/selectors';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
import NewTopicButton from './NewTopicButton';
import TopicItem from './TopicItem';
const TopicsList = memo(() => {
useFetchGenerationTopics();
const isLogin = useUserStore(authSelectors.isLogin);
const useFetchGenerationTopics = useImageStore((s) => s.useFetchGenerationTopics);
useFetchGenerationTopics(!!isLogin);
const ref = useRef(null);
const { width = 80 } = useSize(ref) || {};
const [parent] = useAutoAnimate();
@@ -7,9 +7,12 @@ import type { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { loginRequired } from '@/components/Error/loginRequiredNotification';
import { useImageStore } from '@/store/image';
import { createImageSelectors } from '@/store/image/selectors';
import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
import PromptTitle from './Title';
@@ -46,8 +49,14 @@ const PromptInput = ({ showTitle = false }: PromptInputProps) => {
const { value, setValue } = useGenerationConfigParam('prompt');
const isCreating = useImageStore(createImageSelectors.isCreating);
const createImage = useImageStore((s) => s.createImage);
const isLogin = useUserStore(authSelectors.isLogin);
const handleGenerate = async () => {
if (!isLogin) {
loginRequired.redirect({ timeout: 2000 });
return;
}
await createImage();
};
+1 -10
View File
@@ -17,10 +17,9 @@ export async function generateSitemaps() {
const staticSitemaps = sitemapModule.sitemapIndexs;
// 获取需要分页的类型的页数
const [pluginPages, assistantPages, mcpPages, modelPages] = await Promise.all([
const [pluginPages, assistantPages, modelPages] = await Promise.all([
sitemapModule.getPluginPageCount(),
sitemapModule.getAssistantPageCount(),
sitemapModule.getMcpPageCount(),
sitemapModule.getModelPageCount(),
]);
@@ -30,7 +29,6 @@ export async function generateSitemaps() {
...Array.from({ length: assistantPages }, (_, i) => ({
id: `assistants-${i + 1}` as SitemapType,
})),
...Array.from({ length: mcpPages }, (_, i) => ({ id: `mcp-${i + 1}` as SitemapType })),
...Array.from({ length: modelPages }, (_, i) => ({ id: `models-${i + 1}` as SitemapType })),
];
@@ -60,9 +58,6 @@ export default async function sitemap({ id }: { id: string }): Promise<MetadataR
case SitemapType.Assistants: {
return sitemapModule.getAssistants(page);
}
case SitemapType.Mcp: {
return sitemapModule.getMcp(page);
}
case SitemapType.Plugins: {
return sitemapModule.getPlugins(page);
}
@@ -82,10 +77,6 @@ export default async function sitemap({ id }: { id: string }): Promise<MetadataR
const pageNum = parseInt(id.split('-')[1], 10);
return sitemapModule.getAssistants(pageNum);
}
if (id.startsWith('mcp-')) {
const pageNum = parseInt(id.split('-')[1], 10);
return sitemapModule.getMcp(pageNum);
}
if (id.startsWith('models-')) {
const pageNum = parseInt(id.split('-')[1], 10);
return sitemapModule.getModels(pageNum);
+43 -2
View File
@@ -1,4 +1,4 @@
import { AIChatModelCard } from '@/types/aiModel';
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
const minimaxChatModels: AIChatModelCard[] = [
{
@@ -51,6 +51,47 @@ const minimaxChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...minimaxChatModels];
const minimaxImageModels: AIImageModelCard[] = [
{
description:
'全新图像生成模型,画面表现细腻,支持文生图、图生图',
displayName: 'Image 01',
enabled: true,
id: 'image-01',
parameters: {
aspectRatio: {
default: '1:1',
enum: ['1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9'],
},
prompt: {
default: '',
},
seed: { default: null },
},
releasedAt: '2025-02-28',
type: 'image',
},
{
description:
'图像生成模型,画面表现细腻,支持文生图并进行画风设置',
displayName: 'Image 01 Live',
enabled: true,
id: 'image-01-live',
parameters: {
aspectRatio: {
default: '1:1',
enum: ['1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9'],
},
prompt: {
default: '',
},
seed: { default: null },
},
releasedAt: '2025-02-28',
type: 'image',
},
];
export const allModels = [...minimaxChatModels, ...minimaxImageModels];
export default allModels;
+3 -2
View File
@@ -30,9 +30,10 @@ export interface UserInfoProps extends FlexboxProps {
const UserInfo = memo<UserInfoProps>(({ avatarProps, onClick, ...rest }) => {
const { styles, theme } = useStyles();
const isSignedIn = useUserStore(authSelectors.isLogin);
const [nickname, username] = useUserStore((s) => [
const [nickname, username, subscriptionPlan] = useUserStore((s) => [
userProfileSelectors.nickName(s),
userProfileSelectors.username(s),
s.subscriptionPlan,
]);
return (
@@ -52,7 +53,7 @@ const UserInfo = memo<UserInfoProps>(({ avatarProps, onClick, ...rest }) => {
<div className={styles.username}>{username}</div>
</Flexbox>
</Flexbox>
{isSignedIn && <PlanTag />}
{isSignedIn && <PlanTag type={subscriptionPlan} />}
</Flexbox>
);
});
-13
View File
@@ -1,13 +0,0 @@
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useImageStore } from '@/store/image';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
export const useFetchGenerationTopics = () => {
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
const isLogin = useUserStore(authSelectors.isLogin);
const useFetchGenerationTopics = useImageStore((s) => s.useFetchGenerationTopics);
useFetchGenerationTopics(isDBInited, isLogin);
};
+11 -11
View File
@@ -40,15 +40,15 @@ const GlobalLayout = async ({
const serverConfig = await getServerGlobalConfig();
return (
<StyleRegistry>
<AppTheme
customFontFamily={appEnv.CUSTOM_FONT_FAMILY}
customFontURL={appEnv.CUSTOM_FONT_URL}
defaultAppearance={appearance}
defaultNeutralColor={neutralColor as any}
defaultPrimaryColor={primaryColor as any}
globalCDN={appEnv.CDN_USE_GLOBAL}
>
<Locale antdLocale={antdLocale} defaultLang={userLocale}>
<Locale antdLocale={antdLocale} defaultLang={userLocale}>
<AppTheme
customFontFamily={appEnv.CUSTOM_FONT_FAMILY}
customFontURL={appEnv.CUSTOM_FONT_URL}
defaultAppearance={appearance}
defaultNeutralColor={neutralColor as any}
defaultPrimaryColor={primaryColor as any}
globalCDN={appEnv.CDN_USE_GLOBAL}
>
<ServerConfigStoreProvider
featureFlags={serverFeatureFlags}
isMobile={isMobile}
@@ -63,8 +63,8 @@ const GlobalLayout = async ({
{process.env.NODE_ENV === 'development' && <DevPanel />}
</Suspense>
</ServerConfigStoreProvider>
</Locale>
</AppTheme>
</AppTheme>
</Locale>
<AntdV5MonkeyPatch />
</StyleRegistry>
);
@@ -0,0 +1,553 @@
// @vitest-environment edge-runtime
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CreateImagePayload } from '../types/image';
import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
import { createMiniMaxImage } from './createImage';
// Mock the console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
const mockOptions: CreateImageOptions = {
apiKey: 'test-api-key',
baseURL: 'https://api.minimaxi.com/v1',
provider: 'minimax',
};
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('createMiniMaxImage', () => {
describe('Success scenarios', () => {
it('should successfully generate image with basic prompt', async () => {
const mockImageUrl = 'https://minimax-cdn.com/images/generated/test-image.jpg';
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [mockImageUrl],
},
id: 'img-123456',
metadata: {
failed_count: '0',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'A beautiful sunset over the mountains',
},
};
const result = await createMiniMaxImage(payload, mockOptions);
expect(fetch).toHaveBeenCalledWith(
'https://api.minimaxi.com/v1/image_generation',
{
method: 'POST',
headers: {
'Authorization': 'Bearer test-api-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
aspect_ratio: undefined,
model: 'image-01',
n: 1,
prompt: 'A beautiful sunset over the mountains',
}),
},
);
expect(result).toEqual({
imageUrl: mockImageUrl,
});
});
it('should handle custom aspect ratio', async () => {
const mockImageUrl = 'https://minimax-cdn.com/images/generated/custom-ratio.jpg';
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [mockImageUrl],
},
id: 'img-custom-ratio',
metadata: {
failed_count: '0',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Abstract digital art',
aspectRatio: '16:9',
},
};
const result = await createMiniMaxImage(payload, mockOptions);
expect(fetch).toHaveBeenCalledWith(
'https://api.minimaxi.com/v1/image_generation',
expect.objectContaining({
body: JSON.stringify({
aspect_ratio: '16:9',
model: 'image-01',
n: 1,
prompt: 'Abstract digital art',
}),
}),
);
expect(result).toEqual({
imageUrl: mockImageUrl,
});
});
it('should handle seed value correctly', async () => {
const mockImageUrl = 'https://minimax-cdn.com/images/generated/seeded-image.jpg';
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [mockImageUrl],
},
id: 'img-seeded',
metadata: {
failed_count: '0',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Reproducible image with seed',
seed: 42,
},
};
const result = await createMiniMaxImage(payload, mockOptions);
expect(fetch).toHaveBeenCalledWith(
'https://api.minimaxi.com/v1/image_generation',
expect.objectContaining({
body: JSON.stringify({
aspect_ratio: undefined,
model: 'image-01',
n: 1,
prompt: 'Reproducible image with seed',
seed: 42,
}),
}),
);
expect(result).toEqual({
imageUrl: mockImageUrl,
});
});
it('should handle seed value of 0 correctly', async () => {
const mockImageUrl = 'https://minimax-cdn.com/images/generated/zero-seed.jpg';
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [mockImageUrl],
},
id: 'img-zero-seed',
metadata: {
failed_count: '0',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Image with seed 0',
seed: 0,
},
};
await createMiniMaxImage(payload, mockOptions);
// Verify that seed: 0 is included in the request
expect(fetch).toHaveBeenCalledWith(
'https://api.minimaxi.com/v1/image_generation',
expect.objectContaining({
body: JSON.stringify({
aspect_ratio: undefined,
model: 'image-01',
n: 1,
prompt: 'Image with seed 0',
seed: 0,
}),
}),
);
});
it('should handle multiple generated images and return the first one', async () => {
const mockImageUrls = [
'https://minimax-cdn.com/images/generated/image-1.jpg',
'https://minimax-cdn.com/images/generated/image-2.jpg',
];
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: mockImageUrls,
},
id: 'img-multiple',
metadata: {
failed_count: '0',
success_count: '2',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Multiple images test',
},
};
const result = await createMiniMaxImage(payload, mockOptions);
expect(result).toEqual({
imageUrl: mockImageUrls[0], // Should return the first image
});
});
it('should handle partial failures gracefully', async () => {
const mockImageUrl = 'https://minimax-cdn.com/images/generated/partial-success.jpg';
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [mockImageUrl],
},
id: 'img-partial',
metadata: {
failed_count: '2',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Test partial failure',
},
};
const result = await createMiniMaxImage(payload, mockOptions);
expect(result).toEqual({
imageUrl: mockImageUrl,
});
});
});
describe('Error scenarios', () => {
it('should handle HTTP error responses', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({
base_resp: {
status_code: 1001,
status_msg: 'Invalid prompt format',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Invalid prompt',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle non-JSON error responses', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => {
throw new Error('Failed to parse JSON');
},
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Test prompt',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle API error status codes', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 1002,
status_msg: 'Content policy violation',
},
data: {
image_urls: [],
},
id: 'img-error',
metadata: {
failed_count: '1',
success_count: '0',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Inappropriate content',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle empty image URLs array', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [],
},
id: 'img-empty',
metadata: {
failed_count: '1',
success_count: '0',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Empty result test',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle missing data field', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
id: 'img-no-data',
metadata: {
failed_count: '0',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Missing data test',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle null/empty image URL', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
base_resp: {
status_code: 0,
status_msg: 'success',
},
data: {
image_urls: [''], // Empty string URL
},
id: 'img-empty-url',
metadata: {
failed_count: '0',
success_count: '1',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Empty URL test',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle network errors', async () => {
global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network connection failed'));
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Network error test',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle unauthorized access', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({
base_resp: {
status_code: 1003,
status_msg: 'Invalid API key',
},
}),
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'Unauthorized test',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
it('should handle malformed JSON response', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => {
throw new Error('Unexpected token in JSON');
},
});
const payload: CreateImagePayload = {
model: 'image-01',
params: {
prompt: 'JSON error test',
},
};
await expect(createMiniMaxImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'minimax',
}),
);
});
});
});
@@ -0,0 +1,105 @@
import createDebug from 'debug';
import { CreateImagePayload, CreateImageResponse } from '../types/image';
import { AgentRuntimeError } from '../utils/createError';
import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
const log = createDebug('lobe-image:minimax');
interface MiniMaxImageResponse {
base_resp: {
status_code: number;
status_msg: string;
};
data: {
image_urls: string[];
};
id: string;
metadata: {
failed_count: string;
success_count: string;
};
}
/**
* Create image using MiniMax API
*/
export async function createMiniMaxImage(
payload: CreateImagePayload,
options: CreateImageOptions,
): Promise<CreateImageResponse> {
const { apiKey, baseURL, provider } = options;
const { model, params } = payload;
try {
const endpoint = `${baseURL}/image_generation`;
const response = await fetch(endpoint, {
body: JSON.stringify({
aspect_ratio: params.aspectRatio,
model,
n: 1,
prompt: params.prompt,
//prompt_optimizer: true, // 开启 prompt 自动优化
...(typeof params.seed === 'number' ? { seed: params.seed } : {}),
}),
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
// Failed to parse JSON error response
}
throw new Error(
`MiniMax API error (${response.status}): ${errorData?.base_resp || response.statusText}`
);
}
const data: MiniMaxImageResponse = await response.json();
log('Image generation response: %O', data);
// Check API response status
if (data.base_resp.status_code !== 0) {
throw new Error(`MiniMax API error: ${data.base_resp.status_msg}`);
}
// Check if we have valid image data
if (!data.data?.image_urls || data.data.image_urls.length === 0) {
throw new Error('No images generated in response');
}
// Log generation statistics
const successCount = parseInt(data.metadata.success_count);
const failedCount = parseInt(data.metadata.failed_count);
log('Image generation completed: %d successful, %d failed', successCount, failedCount);
// Return the first generated image URL
const imageUrl = data.data.image_urls[0];
if (!imageUrl) {
throw new Error('No valid image URL in response');
}
log('Image generated successfully: %s', imageUrl);
return { imageUrl };
} catch (error) {
log('Error in createMiniMaxImage: %O', error);
throw AgentRuntimeError.createImage({
error: error as any,
errorType: 'ProviderBizError',
provider,
});
}
}
+2
View File
@@ -2,6 +2,7 @@ import minimaxChatModels from '@/config/aiModels/minimax';
import { ModelProvider } from '../types';
import { createOpenAICompatibleRuntime } from '../utils/openaiCompatibleFactory';
import { createMiniMaxImage } from './createImage';
export const getMinimaxMaxOutputs = (modelId: string): number | undefined => {
const model = minimaxChatModels.find((model) => model.id === modelId);
@@ -34,6 +35,7 @@ export const LobeMinimaxAI = createOpenAICompatibleRuntime({
} as any;
},
},
createImage: createMiniMaxImage,
debug: {
chatCompletion: () => process.env.DEBUG_MINIMAX_CHAT_COMPLETION === '1',
},
+4 -2
View File
@@ -4,15 +4,16 @@ import Authentik from './authentik';
import AzureAD from './azure-ad';
import Casdoor from './casdoor';
import CloudflareZeroTrust from './cloudflare-zero-trust';
import Cognito from './cognito';
import GenericOIDC from './generic-oidc';
import Github from './github';
import Google from './google';
import Keycloak from './keycloak';
import Logto from './logto';
import MicrosoftEntraID from './microsoft-entra-id';
import Okta from './okta';
import WeChat from './wechat';
import Zitadel from './zitadel';
import Cognito from "./cognito";
export const ssoProviders = [
Auth0,
@@ -29,5 +30,6 @@ export const ssoProviders = [
WeChat,
Keycloak,
Google,
Cognito
Cognito,
Okta,
];
+26
View File
@@ -0,0 +1,26 @@
import Okta from 'next-auth/providers/okta';
import { CommonProviderConfig } from './sso.config';
const provider = {
id: 'okta',
provider: Okta({
...CommonProviderConfig,
authorization: { params: { scope: 'openid email profile' } },
clientId: process.env.AUTH_OKTA_ID,
clientSecret: process.env.AUTH_OKTA_SECRET,
issuer: process.env.AUTH_OKTA_ISSUER,
// Remove End
profile(profile) {
return {
email: profile.email,
id: profile.sub,
image: profile.picture,
name: profile.name ?? profile.preferred_username,
providerAccountId: profile.sub,
};
},
}),
};
export default provider;
-14
View File
@@ -173,20 +173,6 @@ export const marketRouter = router({
}
}),
getMcpIdentifiers: marketProcedure.query(async ({ ctx }) => {
log('getMcpIdentifiers called');
try {
return await ctx.discoverService.getMcpIdentifiers();
} catch (error) {
log('Error fetching mcp identifiers: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch mcp identifiers',
});
}
}),
getMcpList: marketProcedure
.input(
z
-13
View File
@@ -462,19 +462,6 @@ export class DiscoverService {
return result;
};
getMcpIdentifiers = async (): Promise<IdentifiersResponse> => {
log('getMcpIdentifiers: fetching identifiers');
const result = await this.market.plugins.getPublishedIdentifiers({
cache: 'force-cache',
next: {
revalidate: CacheRevalidate.List,
tags: [CacheTag.Discover, CacheTag.MCP],
},
});
log('getMcpIdentifiers: returning %d identifiers', result.length);
return result;
};
getMcpList = async (params: McpQueryParams = {}): Promise<McpListResponse> => {
log('getMcpList: params=%O', params);
const { locale } = params;
-52
View File
@@ -13,7 +13,6 @@ describe('Sitemap', () => {
// Mock the page count methods to return specific values for testing
vi.spyOn(sitemap, 'getPluginPageCount').mockResolvedValue(2);
vi.spyOn(sitemap, 'getAssistantPageCount').mockResolvedValue(3);
vi.spyOn(sitemap, 'getMcpPageCount').mockResolvedValue(1);
vi.spyOn(sitemap, 'getModelPageCount').mockResolvedValue(2);
const index = await sitemap.getIndex();
@@ -31,7 +30,6 @@ describe('Sitemap', () => {
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/assistants-1.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/assistants-2.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/assistants-3.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/mcp-1.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/models-1.xml')}</loc>`);
expect(index).toContain(`<loc>${getCanonicalUrl('/sitemap/models-2.xml')}</loc>`);
@@ -218,46 +216,6 @@ describe('Sitemap', () => {
});
});
describe('getMcp', () => {
it('should return a valid mcp sitemap without pagination', async () => {
vi.spyOn(sitemap['discoverService'], 'getMcpIdentifiers').mockResolvedValue([
// @ts-ignore
{ identifier: 'test-mcp', lastModified: '2023-01-01' },
]);
const mcpSitemap = await sitemap.getMcp();
expect(mcpSitemap.length).toBe(15);
expect(mcpSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/discover/mcp/test-mcp'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
expect(mcpSitemap).toContainEqual(
expect.objectContaining({
url: getCanonicalUrl('/discover/mcp/test-mcp?hl=zh-CN'),
lastModified: '2023-01-01T00:00:00.000Z',
}),
);
});
it('should return a valid mcp sitemap with pagination', async () => {
const mockMcps = Array.from({ length: 80 }, (_, i) => ({
identifier: `test-mcp-${i}`,
lastModified: '2023-01-01',
}));
vi.spyOn(sitemap['discoverService'], 'getMcpIdentifiers').mockResolvedValue(
// @ts-ignore
mockMcps,
);
// Test first page (should have 80 items, all on first page)
const firstPageSitemap = await sitemap.getMcp(1);
expect(firstPageSitemap.length).toBe(80 * 15); // 80 items * 15 locales
});
});
describe('getProviders', () => {
it('should return a valid providers sitemap', async () => {
vi.spyOn(sitemap['discoverService'], 'getProviderIdentifiers').mockResolvedValue([
@@ -303,16 +261,6 @@ describe('Sitemap', () => {
expect(pageCount).toBe(3); // 250 items / 100 per page = ceil(2.5) = 3 pages
});
it('should return correct mcp page count', async () => {
vi.spyOn(sitemap['discoverService'], 'getMcpIdentifiers').mockResolvedValue(
// @ts-ignore
Array.from({ length: 50 }, (_, i) => ({ identifier: `mcp-${i}` })),
);
const pageCount = await sitemap.getMcpPageCount();
expect(pageCount).toBe(1); // 50 items / 100 per page = 1 page
});
it('should return correct model page count', async () => {
vi.spyOn(sitemap['discoverService'], 'getModelIdentifiers').mockResolvedValue(
// @ts-ignore
+1 -38
View File
@@ -52,12 +52,6 @@ export class Sitemap {
return Math.ceil(list.length / ITEMS_PER_PAGE);
}
// 获取MCP总页数
async getMcpPageCount(): Promise<number> {
const list = await this.discoverService.getMcpIdentifiers();
return Math.ceil(list.length / ITEMS_PER_PAGE);
}
// 获取模型总页数
async getModelPageCount(): Promise<number> {
const list = await this.discoverService.getModelIdentifiers();
@@ -171,10 +165,9 @@ export class Sitemap {
);
// 获取需要分页的类型的页数
const [pluginPages, assistantPages, mcpPages, modelPages] = await Promise.all([
const [pluginPages, assistantPages, modelPages] = await Promise.all([
this.getPluginPageCount(),
this.getAssistantPageCount(),
this.getMcpPageCount(),
this.getModelPageCount(),
]);
@@ -193,11 +186,6 @@ export class Sitemap {
),
),
),
...Array.from({ length: mcpPages }, (_, i) =>
this._generateSitemapLink(
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? `mcp-${i + 1}` : `mcp-${i + 1}.xml`),
),
),
...Array.from({ length: modelPages }, (_, i) =>
this._generateSitemapLink(
getCanonicalUrl(SITEMAP_BASE_URL, isDev ? `models-${i + 1}` : `models-${i + 1}.xml`),
@@ -239,31 +227,6 @@ export class Sitemap {
return flatten(sitmap);
}
async getMcp(page?: number): Promise<MetadataRoute.Sitemap> {
const list = await this.discoverService.getMcpIdentifiers();
if (page !== undefined) {
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const pageMcps = list.slice(startIndex, endIndex);
const sitmap = pageMcps.map((item) =>
this._genSitemap(urlJoin('/discover/mcp', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
// 如果没有指定页数,返回所有(向后兼容)
const sitmap = list.map((item) =>
this._genSitemap(urlJoin('/discover/mcp', item.identifier), {
lastModified: item?.lastModified || LAST_MODIFIED,
}),
);
return flatten(sitmap);
}
async getPlugins(page?: number): Promise<MetadataRoute.Sitemap> {
const list = await this.discoverService.getPluginIdentifiers();
-4
View File
@@ -83,10 +83,6 @@ class DiscoverService {
});
};
getMcpIdentifiers = async (): Promise<IdentifiersResponse> => {
return lambdaClient.market.getMcpIdentifiers.query();
};
getMcpList = async (params: McpQueryParams = {}): Promise<McpListResponse> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.getMcpList.query({
@@ -201,6 +201,11 @@ const isSendButtonDisabledByMessage = (s: ChatStoreState) =>
// 4. when the message is in RAG flow
isInRAGFlow(s);
const inboxActiveTopicMessages = (state: ChatStoreState) => {
const activeTopicId = state.activeTopicId;
return state.messagesMap[messageMapKey(INBOX_SESSION_ID, activeTopicId)] || [];
};
export const chatSelectors = {
activeBaseChats,
activeBaseChatsWithoutTool,
@@ -213,6 +218,7 @@ export const chatSelectors = {
getMessageById,
getMessageByToolCallId,
getTraceIdByMessageId,
inboxActiveTopicMessages,
isAIGenerating,
isCreatingMessage,
isCurrentChatLoaded,
-8
View File
@@ -8,7 +8,6 @@ import { DiscoverStore } from '@/store/discover';
import { globalHelpers } from '@/store/global/helpers';
import {
DiscoverMcpDetail,
IdentifiersResponse,
McpListResponse,
McpQueryParams,
} from '@/types/discover';
@@ -20,7 +19,6 @@ export interface MCPAction {
}) => SWRResponse<DiscoverMcpDetail>;
useFetchMcpList: (params: McpQueryParams) => SWRResponse<McpListResponse>;
useMcpCategories: (params: CategoryListQuery) => SWRResponse<CategoryItem[]>;
useMcpIdentifiers: () => SWRResponse<IdentifiersResponse>;
}
export const createMCPSlice: StateCreator<
@@ -61,10 +59,4 @@ export const createMCPSlice: StateCreator<
},
);
},
useMcpIdentifiers: () => {
return useClientDataSWR('mcp-identifiers', async () => discoverService.getMcpIdentifiers(), {
revalidateOnFocus: false,
});
},
});
@@ -405,7 +405,7 @@ describe('GenerationTopicAction', () => {
const { result } = renderHook(() => {
const store = useImageStore();
// Actually call the SWR hook to trigger the service call
const swrResult = store.useFetchGenerationTopics(true, true);
const swrResult = store.useFetchGenerationTopics(true);
// Simulate the SWR onSuccess callback behavior
React.useEffect(() => {
@@ -426,7 +426,7 @@ describe('GenerationTopicAction', () => {
});
it('should not fetch when disabled', async () => {
const { result } = renderHook(() => useImageStore().useFetchGenerationTopics(false, true));
const { result } = renderHook(() => useImageStore().useFetchGenerationTopics(false));
expect(result.current.data).toBeUndefined();
expect(generationTopicService.getAllGenerationTopics).not.toHaveBeenCalled();
@@ -455,7 +455,7 @@ describe('GenerationTopicAction', () => {
await result.current.refreshGenerationTopics();
});
expect(mutate).toHaveBeenCalledWith(['fetchGenerationTopics', true]);
expect(mutate).toHaveBeenCalledWith(['fetchGenerationTopics']);
});
});
@@ -25,10 +25,7 @@ const n = setNamespace('generationTopic');
export interface GenerationTopicAction {
createGenerationTopic: (prompts: string[]) => Promise<string>;
removeGenerationTopic: (id: string) => Promise<void>;
useFetchGenerationTopics: (
enabled: boolean,
isLogin: boolean | undefined,
) => SWRResponse<ImageGenerationTopic[]>;
useFetchGenerationTopics: (enabled: boolean) => SWRResponse<ImageGenerationTopic[]>;
summaryGenerationTopicTitle: (topicId: string, prompts: string[]) => Promise<string>;
refreshGenerationTopics: () => Promise<void>;
switchGenerationTopic: (topicId: string) => void;
@@ -216,9 +213,9 @@ export const createGenerationTopicSlice: StateCreator<
);
},
useFetchGenerationTopics: (enabled, isLogin) =>
useFetchGenerationTopics: (enabled) =>
useClientDataSWR<ImageGenerationTopic[]>(
enabled ? [FETCH_GENERATION_TOPICS_KEY, isLogin] : null,
enabled ? [FETCH_GENERATION_TOPICS_KEY] : null,
() => generationTopicService.getAllGenerationTopics(),
{
suspense: true,
@@ -231,7 +228,7 @@ export const createGenerationTopicSlice: StateCreator<
),
refreshGenerationTopics: async () => {
await mutate([FETCH_GENERATION_TOPICS_KEY, true]);
await mutate([FETCH_GENERATION_TOPICS_KEY]);
},
removeGenerationTopic: async (id: string) => {
+1
View File
@@ -113,6 +113,7 @@ export const createCommonSlice: StateCreator<
preference,
serverLanguageModel: serverConfig.languageModel,
settings: data.settings || {},
subscriptionPlan: data.subscriptionPlan,
user,
},
false,
@@ -1,9 +1,12 @@
import { Plans } from '@/types/subscription';
export interface CommonState {
isOnboard: boolean;
isShowPWAGuide: boolean;
isUserCanEnableTrace: boolean;
isUserHasConversation: boolean;
isUserStateInit: boolean;
subscriptionPlan?: Plans;
}
export const initialCommonState: CommonState = {
+2
View File
@@ -1,6 +1,7 @@
import type { PartialDeep } from 'type-fest';
import { z } from 'zod';
import { Plans } from '@/types/subscription';
import { TopicDisplayMode } from '@/types/topic';
import { UserSettings } from '@/types/user/settings';
@@ -56,6 +57,7 @@ export interface UserInitializationState {
lastName?: string;
preference: UserPreference;
settings: PartialDeep<UserSettings>;
subscriptionPlan?: Plans;
userId?: string;
username?: string;
}
+61
View File
@@ -0,0 +1,61 @@
import debug from 'debug';
import { NextRequest } from 'next/server';
const log = debug('lobe-oidc:correctOIDCUrl');
/**
* 修复 OIDC 重定向 URL 在代理环境下的问题
* @param req - Next.js 请求对象
* @param url - 要修复的 URL 对象
* @returns 修复后的 URL 对象
*/
export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
const requestHost = req.headers.get('host');
const forwardedHost = req.headers.get('x-forwarded-host');
const forwardedProto =
req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
log('Input URL: %s', url.toString());
log(
'Request headers - host: %s, x-forwarded-host: %s, x-forwarded-proto: %s',
requestHost,
forwardedHost,
forwardedProto,
);
// 确定实际的主机名和协议,提供后备值
const actualHost = forwardedHost || requestHost;
const actualProto = forwardedProto || (url.protocol === 'https:' ? 'https' : 'http');
// 如果无法确定有效的主机名,直接返回原URL
if (!actualHost || actualHost === 'null') {
log('Warning: Cannot determine valid host, returning original URL');
return url;
}
// 如果 URL 指向本地地址,或者主机名与实际请求主机不匹配,则修正 URL
const needsCorrection =
url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '0.0.0.0' ||
url.hostname !== actualHost;
if (needsCorrection) {
log('URL needs correction. Original hostname: %s, correcting to: %s', url.hostname, actualHost);
try {
const correctedUrl = new URL(url.toString());
correctedUrl.protocol = actualProto + ':';
correctedUrl.host = actualHost;
log('Corrected URL: %s', correctedUrl.toString());
return correctedUrl;
} catch (error) {
log('Error creating corrected URL, returning original: %O', error);
return url;
}
}
log('URL does not need correction, returning original: %s', url.toString());
return url;
};