Compare commits

...

40 Commits

Author SHA1 Message Date
semantic-release-bot 0f63781876 🔖 chore(release): v2.1.17 [skip ci]
### [Version 2.1.17](https://github.com/lobehub/lobe-chat/compare/v2.1.16...v2.1.17)
<sup>Released on **2026-02-04**</sup>

#### ♻ Code Refactoring

- **model-runtime**: Extract Anthropic factory and convert Moonshot to RouterRuntime.

<br/>

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

#### Code refactoring

* **model-runtime**: Extract Anthropic factory and convert Moonshot to RouterRuntime, closes [#12109](https://github.com/lobehub/lobe-chat/issues/12109) ([71064fd](https://github.com/lobehub/lobe-chat/commit/71064fd))

</details>

<div align="right">

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

</div>
2026-02-04 14:09:20 +00:00
YuTengjing 71064fdede ♻️ refactor(model-runtime): extract Anthropic factory and convert Moonshot to RouterRuntime (#12109)
* ♻️ refactor(model-runtime): extract Anthropic provider into reusable factory

- Create `anthropicCompatibleFactory` for shared Anthropic-compatible logic
- Migrate Anthropic provider to use the new factory
- Migrate Moonshot provider from OpenAI-compatible to Anthropic-compatible API
- Move shared utilities (resolveCacheTTL, resolveMaxTokens, etc.) to factory

* ♻️ refactor(model-runtime): convert Moonshot to RouterRuntime with auto API format detection

- Add baseURLPattern support to RouterRuntime for URL-based routing
- Moonshot now auto-selects OpenAI or Anthropic format based on baseURL
  - /anthropic suffix -> Anthropic format (with kimi-k2.5 thinking)
  - /v1 or default -> OpenAI format
- Remove moonshot from baseRuntimeMap to avoid circular dependency
- Update model-bank config: default to OpenAI format with api.moonshot.ai/v1
- Export CreateRouterRuntimeOptions type from RouterRuntime
- Fix type annotation in Anthropic test

*  feat(model-bank): add Kimi K2.5 to LobeHub provider

- Add Kimi K2.5 model with multimodal capabilities
- Supports vision, reasoning, function calling, structured output, and search
- Context window: 262K tokens, max output: 32K tokens

* 🐛 fix(model-runtime): address PR review feedback

- Restore forceImageBase64 for Moonshot OpenAI runtime to fix vision requests
- Simplify baseURLPattern to only support RegExp (remove string support)
- Add baseURLPattern matching tests for RouterRuntime
2026-02-04 21:51:15 +08:00
lobehubbot 20928ac466 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-04 10:26:13 +00:00
semantic-release-bot 347d0ce70f 🔖 chore(release): v2.1.16 [skip ci]
### [Version&nbsp;2.1.16](https://github.com/lobehub/lobe-chat/compare/v2.1.15...v2.1.16)
<sup>Released on **2026-02-04**</sup>

#### 🐛 Bug Fixes

- **misc**: Add the preview publish to market button preview check.

<br/>

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

#### What's fixed

* **misc**: Add the preview publish to market button preview check, closes [#12105](https://github.com/lobehub/lobe-chat/issues/12105) ([28887c7](https://github.com/lobehub/lobe-chat/commit/28887c7))

</details>

<div align="right">

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

</div>
2026-02-04 10:24:51 +00:00
Shinji-Li 28887c77e8 🐛 fix: add the preview publish to market button preview check (#12105)
feat: add the preview publish to market button preview check
2026-02-04 18:07:21 +08:00
lobehubbot 1cc9034c7c 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-04 09:25:23 +00:00
semantic-release-bot 37609e42d6 🔖 chore(release): v2.1.15 [skip ci]
### [Version&nbsp;2.1.15](https://github.com/lobehub/lobe-chat/compare/v2.1.14...v2.1.15)
<sup>Released on **2026-02-04**</sup>

#### 🐛 Bug Fixes

- **misc**: Fixed the agents list the show updateAt time error.

<br/>

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

#### What's fixed

* **misc**: Fixed the agents list the show updateAt time error, closes [#12103](https://github.com/lobehub/lobe-chat/issues/12103) ([3063cee](https://github.com/lobehub/lobe-chat/commit/3063cee))

</details>

<div align="right">

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

</div>
2026-02-04 09:23:52 +00:00
Shinji-Li 3063ceef8c 🐛 fix: fixed the agents list the show updateAt time error (#12103)
* feat: refactor community user pages

* feat: add the agents-group like in social types

* feat: add the user favoitor filter

* fix: slove the agents list show the updateAt time
2026-02-04 17:05:56 +08:00
Shinji-Li f61ab26081 🔨 chore: refacctor the community user pages agents/group fitler (#12102)
* feat: refactor community user pages

* feat: add the agents-group like in social types

* feat: add the user favoitor filter
2026-02-04 16:14:03 +08:00
LobeHub Bot 79712bd38c 🌐 chore: translate non-English symbols to English in packages/utils and src/services (#12087)
* 🌐 chore: translate non-English symbols to English in packages/utils and src/services

- Replace Unicode arrow symbols (→, ⇒) with ASCII equivalents (-, =>) in comments
- Replace Chinese colon (:) with English colon (:) in console.log
- Files affected: packages/utils/src/pricing.ts, packages/utils/src/chunkers/trimBatchProbe/trimBatchProbe.ts, src/services/models.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove debug log for enableFetchOnClient

Removed console log for enableFetchOnClient.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Arvin Xu <arvinx@foxmail.com>
2026-02-04 14:02:10 +08:00
lobehubbot 66caf30e7e 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-04 05:39:03 +00:00
semantic-release-bot 0c855e44fc 🔖 chore(release): v2.1.14 [skip ci]
### [Version&nbsp;2.1.14](https://github.com/lobehub/lobe-chat/compare/v2.1.13...v2.1.14)
<sup>Released on **2026-02-04**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix cannot uncompressed messages.

<br/>

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

#### What's fixed

* **misc**: Fix cannot uncompressed messages, closes [#12086](https://github.com/lobehub/lobe-chat/issues/12086) ([ccfaec2](https://github.com/lobehub/lobe-chat/commit/ccfaec2))

</details>

<div align="right">

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

</div>
2026-02-04 05:37:35 +00:00
LobeHub Bot e18b7a92c7 🌐 chore: translate non-English comments to English in desktop menus (#12056)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 13:21:24 +08:00
Arvin Xu 3f1fd102c5 👷 build: fix db index (#12090)
* build index

* update
2026-02-04 13:16:56 +08:00
Arvin Xu ccfaec2fdb 🐛 fix: fix cannot uncompressed messages (#12086)
* support uncompressed
* fix open new
2026-02-04 12:25:28 +08:00
lobehubbot 8aba59bffd 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-03 06:54:24 +00:00
semantic-release-bot 13e0652c59 🔖 chore(release): v2.1.13 [skip ci]
### [Version&nbsp;2.1.13](https://github.com/lobehub/lobe-chat/compare/v2.1.12...v2.1.13)
<sup>Released on **2026-02-03**</sup>

#### 🐛 Bug Fixes

- **docker**: Add librt.so.1 to fix PDF parsing.

<br/>

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

#### What's fixed

* **docker**: Add librt.so.1 to fix PDF parsing, closes [#12039](https://github.com/lobehub/lobe-chat/issues/12039) ([4a6be92](https://github.com/lobehub/lobe-chat/commit/4a6be92))

</details>

<div align="right">

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

</div>
2026-02-03 06:52:58 +00:00
Ruxiao Yin 4a6be92604 🐛 fix(docker): add librt.so.1 to fix PDF parsing (#12039) 2026-02-03 14:34:36 +08:00
lobehubbot c576a13a43 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-03 03:04:51 +00:00
semantic-release-bot 63a0464a83 🔖 chore(release): v2.1.12 [skip ci]
### [Version&nbsp;2.1.12](https://github.com/lobehub/lobe-chat/compare/v2.1.11...v2.1.12)
<sup>Released on **2026-02-03**</sup>

#### 🐛 Bug Fixes

- **changelog**: Normalize versionRange to valid semver.

<br/>

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

#### What's fixed

* **changelog**: Normalize versionRange to valid semver, closes [#12049](https://github.com/lobehub/lobe-chat/issues/12049) ([74b9bd0](https://github.com/lobehub/lobe-chat/commit/74b9bd0))

</details>

<div align="right">

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

</div>
2026-02-03 03:03:19 +00:00
LobeHub Bot b1c6bdb192 🌐 chore: translate non-English comments to English in server/utils (#12042)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:45:26 +08:00
Kingsword 74b9bd0bed 🐛 fix(changelog): normalize versionRange to valid semver (#12049) 2026-02-03 10:45:03 +08:00
Arvin Xu 6977c570e6 🔨 chore: improve electron build workflow (#12054)
* improve workflow

* update
2026-02-03 10:44:26 +08:00
BrandonStudio 4efe60e9f7 🔨 chore: Remove unexpected file (#12045) 2026-02-02 15:29:39 +08:00
lobehubbot 336d10663c 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-02 06:36:27 +00:00
semantic-release-bot 5f21aaf048 🔖 chore(release): v2.1.11 [skip ci]
### [Version&nbsp;2.1.11](https://github.com/lobehub/lobe-chat/compare/v2.1.10...v2.1.11)
<sup>Released on **2026-02-02**</sup>

#### 🐛 Bug Fixes

- **misc**: Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set.

<br/>

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

#### What's fixed

* **misc**: Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set, closes [#12023](https://github.com/lobehub/lobe-chat/issues/12023) ([e2fd28e](https://github.com/lobehub/lobe-chat/commit/e2fd28e))

</details>

<div align="right">

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

</div>
2026-02-02 06:35:01 +00:00
YuTengjing e2fd28eece 🐛 fix: hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set (#12023) 2026-02-02 14:17:10 +08:00
lobehubbot a6a1fecae0 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-02 05:54:53 +00:00
semantic-release-bot fdee6b9aac 🔖 chore(release): v2.1.10 [skip ci]
### [Version&nbsp;2.1.10](https://github.com/lobehub/lobe-chat/compare/v2.1.9...v2.1.10)
<sup>Released on **2026-02-02**</sup>

#### 🐛 Bug Fixes

- **auth**: Revert authority URL and tenant ID for Microsoft authentication..

<br/>

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

#### What's fixed

* **auth**: Revert authority URL and tenant ID for Microsoft authentication., closes [#11930](https://github.com/lobehub/lobe-chat/issues/11930) ([98f93ef](https://github.com/lobehub/lobe-chat/commit/98f93ef))

</details>

<div align="right">

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

</div>
2026-02-02 05:53:33 +00:00
BrandonStudio 98f93ef2f0 🐛 fix(auth): revert authority URL and tenant ID for Microsoft authentication. (#11930)
🔧 feat(auth): revert authority URL and tenant ID for Microsoft authentication
2026-02-02 13:35:54 +08:00
lobehubbot df7e2800a7 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-02 03:48:44 +00:00
semantic-release-bot 4aac694364 🔖 chore(release): v2.1.9 [skip ci]
### [Version&nbsp;2.1.9](https://github.com/lobehub/lobe-chat/compare/v2.1.8...v2.1.9)
<sup>Released on **2026-02-02**</sup>

#### 🐛 Bug Fixes

- **misc**: Use oauth2.link for generic OIDC provider account linking.

<br/>

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

#### What's fixed

* **misc**: Use oauth2.link for generic OIDC provider account linking, closes [#12024](https://github.com/lobehub/lobe-chat/issues/12024) ([c7a06a4](https://github.com/lobehub/lobe-chat/commit/c7a06a4))

</details>

<div align="right">

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

</div>
2026-02-02 03:47:20 +00:00
YuTengjing c7a06a4b62 🐛 fix: use oauth2.link for generic OIDC provider account linking (#12024) 2026-02-02 11:29:35 +08:00
lobehubbot 2f21c15172 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-01 10:12:44 +00:00
semantic-release-bot de0ce799c7 🔖 chore(release): v2.1.8 [skip ci]
### [Version&nbsp;2.1.8](https://github.com/lobehub/lobe-chat/compare/v2.1.7...v2.1.8)
<sup>Released on **2026-02-01**</sup>

#### 💄 Styles

- **misc**: Improve tasks display.

<br/>

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

#### Styles

* **misc**: Improve tasks display, closes [#12032](https://github.com/lobehub/lobe-chat/issues/12032) ([3423ad1](https://github.com/lobehub/lobe-chat/commit/3423ad1))

</details>

<div align="right">

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

</div>
2026-02-01 10:11:21 +00:00
Arvin Xu 3423ad1b15 💄 style: improve tasks display (#12032)
improve tasks
2026-02-01 17:53:21 +08:00
LobeHub Bot 5db07efe6b 🌐 chore: translate non-English comments to English in src/hooks (#12028)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 17:40:45 +08:00
lobehubbot f5d67a7385 📝 docs(bot): Auto sync agents & plugin to readme 2026-02-01 06:30:03 +00:00
semantic-release-bot 99d4c02b9d 🔖 chore(release): v2.1.7 [skip ci]
### [Version&nbsp;2.1.7](https://github.com/lobehub/lobe-chat/compare/v2.1.6...v2.1.7)
<sup>Released on **2026-02-01**</sup>

#### 🐛 Bug Fixes

- **misc**: Add missing description parameter docs in Notebook system prompt.

<br/>

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

#### What's fixed

* **misc**: Add missing description parameter docs in Notebook system prompt, closes [#12015](https://github.com/lobehub/lobe-chat/issues/12015) [#11391](https://github.com/lobehub/lobe-chat/issues/11391) ([182030f](https://github.com/lobehub/lobe-chat/commit/182030f))

</details>

<div align="right">

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

</div>
2026-02-01 06:28:33 +00:00
Arvin Xu 182030f404 🐛 fix: add missing description parameter docs in Notebook system prompt (#12015)
The createDocument API requires a 'description' parameter, but the system prompt
didn't mention it. This caused models (especially Gemini) to omit the description
field when calling createDocument, resulting in validation errors.

Added <api_parameters> section to clearly document all required and optional
parameters for each Notebook API.

closes #11391
2026-02-01 14:09:52 +08:00
121 changed files with 14376 additions and 1662 deletions
+5 -4
View File
@@ -5,10 +5,11 @@ You are a support assistant for LobeChat authentication migration issues. Your j
**IMPORTANT**: The official documentation website is `https://lobehub.com`. When providing documentation links, always use `https://lobehub.com/docs/...` format. Never use `lobechat.com` - that domain is incorrect.
Examples of correct documentation URLs:
- `https://lobehub.com/docs/self-hosting/advanced/auth/nextauth-to-betterauth`
- `https://lobehub.com/docs/self-hosting/advanced/auth/clerk-to-betterauth`
- `https://lobehub.com/docs/self-hosting/advanced/auth`
- `https://lobehub.com/docs/self-hosting/advanced/auth/providers/casdoor`
- `https://lobehub.com/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth`
- `https://lobehub.com/docs/self-hosting/migration/v2/auth/clerk-to-betterauth`
- `https://lobehub.com/docs/self-hosting/auth`
- `https://lobehub.com/docs/self-hosting/auth/providers/casdoor`
## Target Issues
@@ -24,6 +24,17 @@ runs:
shell: bash
run: pnpm install --node-linker=hoisted
# 移除国内 electron 镜像配置,GitHub Actions 使用官方源更快
- name: Remove China electron mirror from .npmrc
shell: bash
run: |
NPMRC_FILE="./apps/desktop/.npmrc"
if [ -f "$NPMRC_FILE" ]; then
sed -i.bak '/^electron_mirror=/d; /^electron_builder_binaries_mirror=/d' "$NPMRC_FILE"
rm -f "${NPMRC_FILE}.bak"
echo "✅ Removed electron mirror config from .npmrc"
fi
- name: Install deps on Desktop
shell: bash
run: npm run install-isolated --prefix=./apps/desktop
@@ -70,12 +70,12 @@ jobs:
```
2. Read the latest migration documentation based on the issue:
- If issue #11757 (NextAuth): `cat docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx`
- If issue #11707 (Clerk): `cat docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx`
- If issue #11757 (NextAuth): `cat docs/self-hosting/migration/v2/auth/nextauth-to-betterauth.mdx`
- If issue #11707 (Clerk): `cat docs/self-hosting/migration/v2/auth/clerk-to-betterauth.mdx`
3. Read additional reference files:
- Main auth documentation: `cat docs/self-hosting/advanced/auth.mdx`
- Migration internals: `cat docs/self-hosting/advanced/auth/migration-internals.mdx`
- Main auth documentation: `cat docs/self-hosting/auth.mdx`
- Migration internals: `cat docs/self-hosting/migration/v2/auth/migration-internals.mdx`
- Deprecated env vars checker: `cat scripts/_shared/checkDeprecatedAuth.js`
4. Analyze the user's comment and determine:
+11
View File
@@ -109,6 +109,17 @@ jobs:
- name: Install dependencies
run: pnpm install --node-linker=hoisted
# 移除国内 electron 镜像配置,GitHub Actions 使用官方源更快
- name: Remove China electron mirror from .npmrc
shell: bash
run: |
NPMRC_FILE="./apps/desktop/.npmrc"
if [ -f "$NPMRC_FILE" ]; then
sed -i.bak '/^electron_mirror=/d; /^electron_builder_binaries_mirror=/d' "$NPMRC_FILE"
rm -f "${NPMRC_FILE}.bak"
echo "✅ Removed electron mirror config from .npmrc"
fi
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
+275
View File
@@ -2,6 +2,281 @@
# Changelog
### [Version 2.1.17](https://github.com/lobehub/lobe-chat/compare/v2.1.16...v2.1.17)
<sup>Released on **2026-02-04**</sup>
#### ♻ Code Refactoring
- **model-runtime**: Extract Anthropic factory and convert Moonshot to RouterRuntime.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Code refactoring
- **model-runtime**: Extract Anthropic factory and convert Moonshot to RouterRuntime, closes [#12109](https://github.com/lobehub/lobe-chat/issues/12109) ([71064fd](https://github.com/lobehub/lobe-chat/commit/71064fd))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.16](https://github.com/lobehub/lobe-chat/compare/v2.1.15...v2.1.16)
<sup>Released on **2026-02-04**</sup>
#### 🐛 Bug Fixes
- **misc**: Add the preview publish to market button preview check.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Add the preview publish to market button preview check, closes [#12105](https://github.com/lobehub/lobe-chat/issues/12105) ([28887c7](https://github.com/lobehub/lobe-chat/commit/28887c7))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.15](https://github.com/lobehub/lobe-chat/compare/v2.1.14...v2.1.15)
<sup>Released on **2026-02-04**</sup>
#### 🐛 Bug Fixes
- **misc**: Fixed the agents list the show updateAt time error.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Fixed the agents list the show updateAt time error, closes [#12103](https://github.com/lobehub/lobe-chat/issues/12103) ([3063cee](https://github.com/lobehub/lobe-chat/commit/3063cee))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.14](https://github.com/lobehub/lobe-chat/compare/v2.1.13...v2.1.14)
<sup>Released on **2026-02-04**</sup>
#### 🐛 Bug Fixes
- **misc**: Fix cannot uncompressed messages.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Fix cannot uncompressed messages, closes [#12086](https://github.com/lobehub/lobe-chat/issues/12086) ([ccfaec2](https://github.com/lobehub/lobe-chat/commit/ccfaec2))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.13](https://github.com/lobehub/lobe-chat/compare/v2.1.12...v2.1.13)
<sup>Released on **2026-02-03**</sup>
#### 🐛 Bug Fixes
- **docker**: Add librt.so.1 to fix PDF parsing.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **docker**: Add librt.so.1 to fix PDF parsing, closes [#12039](https://github.com/lobehub/lobe-chat/issues/12039) ([4a6be92](https://github.com/lobehub/lobe-chat/commit/4a6be92))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.12](https://github.com/lobehub/lobe-chat/compare/v2.1.11...v2.1.12)
<sup>Released on **2026-02-03**</sup>
#### 🐛 Bug Fixes
- **changelog**: Normalize versionRange to valid semver.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **changelog**: Normalize versionRange to valid semver, closes [#12049](https://github.com/lobehub/lobe-chat/issues/12049) ([74b9bd0](https://github.com/lobehub/lobe-chat/commit/74b9bd0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.11](https://github.com/lobehub/lobe-chat/compare/v2.1.10...v2.1.11)
<sup>Released on **2026-02-02**</sup>
#### 🐛 Bug Fixes
- **misc**: Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set, closes [#12023](https://github.com/lobehub/lobe-chat/issues/12023) ([e2fd28e](https://github.com/lobehub/lobe-chat/commit/e2fd28e))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.10](https://github.com/lobehub/lobe-chat/compare/v2.1.9...v2.1.10)
<sup>Released on **2026-02-02**</sup>
#### 🐛 Bug Fixes
- **auth**: Revert authority URL and tenant ID for Microsoft authentication..
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **auth**: Revert authority URL and tenant ID for Microsoft authentication., closes [#11930](https://github.com/lobehub/lobe-chat/issues/11930) ([98f93ef](https://github.com/lobehub/lobe-chat/commit/98f93ef))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.9](https://github.com/lobehub/lobe-chat/compare/v2.1.8...v2.1.9)
<sup>Released on **2026-02-02**</sup>
#### 🐛 Bug Fixes
- **misc**: Use oauth2.link for generic OIDC provider account linking.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Use oauth2.link for generic OIDC provider account linking, closes [#12024](https://github.com/lobehub/lobe-chat/issues/12024) ([c7a06a4](https://github.com/lobehub/lobe-chat/commit/c7a06a4))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.8](https://github.com/lobehub/lobe-chat/compare/v2.1.7...v2.1.8)
<sup>Released on **2026-02-01**</sup>
#### 💄 Styles
- **misc**: Improve tasks display.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Improve tasks display, closes [#12032](https://github.com/lobehub/lobe-chat/issues/12032) ([3423ad1](https://github.com/lobehub/lobe-chat/commit/3423ad1))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.7](https://github.com/lobehub/lobe-chat/compare/v2.1.6...v2.1.7)
<sup>Released on **2026-02-01**</sup>
#### 🐛 Bug Fixes
- **misc**: Add missing description parameter docs in Notebook system prompt.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Add missing description parameter docs in Notebook system prompt, closes [#12015](https://github.com/lobehub/lobe-chat/issues/12015) [#11391](https://github.com/lobehub/lobe-chat/issues/11391) ([182030f](https://github.com/lobehub/lobe-chat/commit/182030f))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.6](https://github.com/lobehub/lobe-chat/compare/v2.1.5...v2.1.6)
<sup>Released on **2026-02-01**</sup>
+4 -1
View File
@@ -21,6 +21,7 @@ RUN set -e && \
cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf && \
cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6 && \
cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1 && \
cp /usr/lib/$(arch)-linux-gnu/librt.so.1 /distroless/lib/librt.so.1 && \
cp /usr/local/bin/node /distroless/bin/node && \
cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
@@ -186,7 +187,9 @@ ENV AUTH_SECRET="" \
AUTH_GITHUB_SECRET="" \
# Microsoft
AUTH_MICROSOFT_ID="" \
AUTH_MICROSOFT_SECRET=""
AUTH_MICROSOFT_SECRET="" \
AUTH_MICROSOFT_AUTHORITY_URL="" \
AUTH_MICROSOFT_TENANT_ID=""
# Redis
ENV REDIS_URL="" \
+1 -1
View File
@@ -45,7 +45,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
this.buildAndSetAppMenu(options);
}
// --- 私有方法:定义菜单模板和逻辑 ---
// --- Private methods: define menu templates and logic ---
private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
const showDev = isDev || options?.showDevItems;
+8 -8
View File
@@ -48,23 +48,23 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
}
refresh(options?: MenuOptions): void {
// 重建Application menu
// Rebuild Application menu
this.buildAndSetAppMenu(options);
// 如果托盘菜单存在,也重建它(如果需要动态更新)
// If tray menu exists, rebuild it as well (if dynamic update is needed)
// this.trayMenu = this.buildTrayMenu();
// 需要考虑如何更新现有托盘图标的菜单
// Need to consider how to update the menu for existing tray icons
}
// --- 私有方法:定义菜单模板和逻辑 ---
// --- Private methods: define menu templates and logic ---
private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
const appName = app.getName();
const showDev = isDev || options?.showDevItems;
// 创建命名空间翻译函数
// Create namespaced translation function
const t = this.app.i18n.ns('menu');
// 添加调试日志
// console.log('[MacOSMenu] 菜单渲染, i18n实例:', !!this.app.i18n);
// Add debug logging
// console.log('[MacOSMenu] Menu rendering, i18n instance:', !!this.app.i18n);
const template: MenuItemConstructorOptions[] = [
{
@@ -324,7 +324,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
},
{
click: () => {
// @ts-expect-error cache 目录好像暂时不在类型定义里
// @ts-expect-error cache directory seems to be temporarily missing from type definitions
const cachePath = app.getPath('cache');
const updaterCachePath = path.join(cachePath, `${app.getName()}-updater`);
+1 -1
View File
@@ -43,7 +43,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
refresh(options?: MenuOptions): void {
this.buildAndSetAppMenu(options);
// 如果有必要更新托盘菜单,可以在这里添加逻辑
// If it's necessary to update tray menu, logic can be added here
}
private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
+64
View File
@@ -1,4 +1,68 @@
[
{
"children": {
"fixes": ["Add the preview publish to market button preview check."]
},
"date": "2026-02-04",
"version": "2.1.16"
},
{
"children": {
"fixes": ["Fixed the agents list the show updateAt time error."]
},
"date": "2026-02-04",
"version": "2.1.15"
},
{
"children": {
"fixes": ["Fix cannot uncompressed messages."]
},
"date": "2026-02-04",
"version": "2.1.14"
},
{
"children": {},
"date": "2026-02-03",
"version": "2.1.13"
},
{
"children": {},
"date": "2026-02-03",
"version": "2.1.12"
},
{
"children": {
"fixes": ["Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set."]
},
"date": "2026-02-02",
"version": "2.1.11"
},
{
"children": {},
"date": "2026-02-02",
"version": "2.1.10"
},
{
"children": {
"fixes": ["Use oauth2.link for generic OIDC provider account linking."]
},
"date": "2026-02-02",
"version": "2.1.9"
},
{
"children": {
"improvements": ["Improve tasks display."]
},
"date": "2026-02-01",
"version": "2.1.8"
},
{
"children": {
"fixes": ["Add missing description parameter docs in Notebook system prompt."]
},
"date": "2026-02-01",
"version": "2.1.7"
},
{
"children": {
"improvements": ["Improve local-system tool implement."]
-50
View File
@@ -1,50 +0,0 @@
## chore(i18n): Adjust Latin language locales for terminology consistency
This comprehensive update ensures all Latin language locales (de-DE, fr-FR, es-ES, it-IT, pt-BR, nl-NL, pl-PL) follow the microcopy style guide's terminology requirements.
### Changes Made
**Total: 557 changes across 7 Latin locales**
#### Primary Terminology Updates
1. **"Plugin" → "Skill"**
- Fixed terminology inconsistency across all Latin languages
- UI elements now consistently use "Skill" instead of localized equivalents
- Includes both singular and plural forms: `plugin/Skill`, `plugins/Skills`
2. **"LobeChat" → "LobeHub"**
- Updated brand name references to current branding
3. **"Agent" Terminology Consistency**
- French: Fixed inconsistent "Assistant" → "Agent" usage in UI elements
- Ensured consistent terminology across all languages
#### Per-Locale Breakdown
- **de-DE (German)**: 267 changes
- **fr-FR (French)**: 94 changes (including 7 Agent→Assistant fixes)
- **es-ES (Spanish)**: 39 changes
- **it-IT (Italian)**: 59 changes (including 18 plugin→skill fixes)
- **pt-BR (Portuguese)**: 58 changes
- **nl-NL (Dutch)**: 62 changes
- **pl-PL (Polish)**: 28 changes
#### Files Modified
- All 37 locale JSON files for each language (259 total files)
- Includes: auth.json, chat.json, common.json, discover.json, plugin.json, setting.json, etc.
#### Key Improvements
1. **Fixed Terminology**: Following microcopy guide's fixed terminology rules
2. **Brand Consistency**: Changed all brand references to "LobeHub"
3. **Natural Localization**: Maintained natural language patterns while ensuring consistency
4. **User Experience**: Improved consistency across all Latin language interfaces
### Scripts Created
Two utility scripts for future locale maintenance:
- `scripts/adjust-latin-locales.py` - For common.json specific adjustments
- `scripts/adjust-latin-locales-full.py` - For comprehensive adjustments across all files
### Review Notes
- All changes maintain backward compatibility
- No breaking changes to functionality
- JSON files validated and remain syntactically correct
- Changes reviewed against English base for consistency
+1 -1
View File
@@ -12,7 +12,7 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobechat
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL provider configuration
# Please refer tohttps://lobehub.com/zh/docs/self-hosting/advanced/auth/providers/zitadel
# Please refer to: https://lobehub.com/docs/self-hosting/auth/providers/zitadel
AUTH_ZITADEL_ID=285945938244075523
AUTH_ZITADEL_SECRET=hkbtzHLaCEIeHeFThym14UcydpmQiEB5JtAX08HSqSoJxhAlVVkyovTuNUZ5TNrT
AUTH_ZITADEL_ISSUER=http://localhost:8080
@@ -11,7 +11,7 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobechat
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL 鉴权服务提供商部分
# 请参考:https://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel
# 请参考:https://lobehub.com/zh/docs/self-hosting/auth/providers/zitadel
AUTH_ZITADEL_ID=285945938244075523
AUTH_ZITADEL_SECRET=hkbtzHLaCEIeHeFThym14UcydpmQiEB5JtAX08HSqSoJxhAlVVkyovTuNUZ5TNrT
AUTH_ZITADEL_ISSUER=http://localhost:8080
+1 -1
View File
@@ -12,7 +12,7 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobe
# Authentication related environment variables
# Supports Auth0, Azure AD, GitHub, Authentik, Zitadel, Logto, etc.
# For supported providers, see: https://lobehub.com/docs/self-hosting/advanced/auth
# For supported providers, see: https://lobehub.com/docs/self-hosting/auth
# If you have ACCESS_CODE, please remove it. We use Better Auth as the sole authentication source
# Required: Auth secret key. Generate with: openssl rand -base64 32
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
@@ -11,7 +11,7 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobe
# 鉴权服务必需的环境变量
# 可以使用 Auth0、Azure AD、GitHub、Authentik、Zitadel、Logto 等,如有其他接入诉求欢迎提 PR
# 目前支持的鉴权服务提供商请参考:https://lobehub.com/zh/docs/self-hosting/advanced/auth
# 目前支持的鉴权服务提供商请参考:https://lobehub.com/zh/docs/self-hosting/auth
# 如果你有 ACCESS_CODE,请务必清空,我们以 Better Auth 作为唯一鉴权来源
# 必填,用于鉴权的密钥,可以使用 openssl rand -base64 32 生成
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
@@ -17,7 +17,7 @@ AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL provider configuration
# Please refer tohttps://lobehub.com/zh/docs/self-hosting/advanced/auth/providers/zitadel
# Please refer to: https://lobehub.com/docs/self-hosting/auth/providers/zitadel
AUTH_ZITADEL_ID=285934220675723622
AUTH_ZITADEL_SECRET=pe7Nh3lopXkZkfqh5YEDYI2xsbIz08eZKqInOUZxssd3refRia518Apbv3DZ
AUTH_ZITADEL_ISSUER=https://zitadel.example.com
@@ -16,7 +16,7 @@ AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL 鉴权服务提供商部分
# 请参考:https://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel
# 请参考:https://lobehub.com/zh/docs/self-hosting/auth/providers/zitadel
AUTH_ZITADEL_ID=285934220675723622
AUTH_ZITADEL_SECRET=pe7Nh3lopXkZkfqh5YEDYI2xsbIz08eZKqInOUZxssd3refRia518Apbv3DZ
AUTH_ZITADEL_ISSUER=https://zitadel.example.com
+1 -1
View File
@@ -6,7 +6,7 @@
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
"id": "2025-12-20-mcp",
"date": "2025-12-20",
"versionRange": ["1.142.8", "1.143"]
"versionRange": ["1.142.8", "1.143.0"]
},
{
"image": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
@@ -114,7 +114,7 @@ AUTH_OKTA_ISSUER: process.env.AUTH_OKTA_ISSUER,
### Step 4: Update Documentation (Optional)
Add provider documentation in `docs/self-hosting/advanced/auth.mdx` and `docs/self-hosting/advanced/auth.zh-CN.mdx`.
Add provider documentation in `docs/self-hosting/auth.mdx` and `docs/self-hosting/auth.zh-CN.mdx`.
## Adding a Built-in Provider
@@ -115,7 +115,7 @@ AUTH_OKTA_ISSUER: process.env.AUTH_OKTA_ISSUER,
### 步骤 4: 更新文档(可选)
在 `docs/self-hosting/advanced/auth.mdx` 和 `docs/self-hosting/advanced/auth.zh-CN.mdx` 中添加提供商文档。
在 `docs/self-hosting/auth.mdx` 和 `docs/self-hosting/auth.zh-CN.mdx` 中添加提供商文档。
## 添加内置提供商
+175 -13
View File
@@ -10,7 +10,8 @@ tags:
- PNPM
- Bun
- Git
- VSCode
- Docker
- PostgreSQL
---
# Environment Setup Guide
@@ -35,6 +36,7 @@ First, you need to install the following software:
- PNPM: We use PNPM as the preferred package manager. You can download and install it from the [PNPM official website](https://pnpm.io/installation).
- Bun: We use Bun as the npm scripts runner. You can download and install it from the [Bun official website](https://bun.com/docs/installation).
- Git: We use Git for version control. You can download and install it from the Git official website.
- Docker: Required for running PostgreSQL, MinIO, and other services. You can download and install it from the [Docker official website](https://www.docker.com/get-started).
- IDE: You can choose your preferred integrated development environment (IDE). We recommend using WebStorm/VSCode.
### VSCode Users
@@ -45,20 +47,72 @@ We recommend installing the extensions listed in [.vscode/extensions.json](https
After installing the above software, you can start setting up the LobeHub project.
1. **Get the code**: First, you need to clone the LobeHub codebase from GitHub. Run the following command in the terminal:
#### 1. Get the Code
First, you need to clone the LobeHub codebase from GitHub. Run the following command in the terminal:
```bash
git clone https://github.com/lobehub/lobehub.git
cd lobehub
```
2. **Install dependencies**: Then, navigate to the project directory and use PNPM to install the project's dependencies:
#### 2. Install Dependencies
Use PNPM to install the project's dependencies:
```bash
cd lobehub
pnpm i
```
3. **Start the development server**: After installing the dependencies, you can start the development server:
#### 3. Configure Environment
Copy the example environment file to create your Docker Compose configuration:
```bash
cp docker-compose/local/.env.example docker-compose/local/.env
```
Edit `docker-compose/local/.env` as needed for your development setup. This file contains all necessary environment variables for the Docker services and configures:
- **Database**: PostgreSQL with connection string
- **Authentication**: Better Auth with Casdoor SSO
- **Storage**: MinIO S3-compatible storage
- **Search**: SearXNG search engine
#### 4. Start Docker Services
Start all required services using Docker Compose:
```bash
docker-compose -f docker-compose.development.yml up -d
```
This will start the following services:
- PostgreSQL database (port 5432)
- MinIO storage (port 9000)
- Casdoor authentication (port 8000)
- SearXNG search (port 8080)
You can check all Docker services are running by running:
```bash
docker-compose -f docker-compose.development.yml ps
```
#### 5. Run Database Migrations
Execute the database migration script to create all necessary tables:
```bash
pnpm db:migrate
```
You should see: `✅ database migration pass.`
#### 6. Start Development Server
Launch the LobeHub development server:
```bash
bun run dev
@@ -68,17 +122,125 @@ Now, you can open `http://localhost:3010` in your browser, and you should see th
![](https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/274655364-414bc31e-8511-47a3-af17-209b530effc7.png)
## Working with Server-Side Features
## Image Generation Development
The basic setup above uses LobeHub's client-side database mode. If you need to work with server-side features such as:
When working with image generation features (text-to-image, image-to-image), the Docker Compose setup already includes all necessary storage services for handling generated images and user uploads.
- Database persistence
- File uploads and storage
- Image generation
- Multi-user authentication
- Advanced server-side integrations
### Image Generation Configuration
Please refer to the [Work with Server-Side Database](/docs/development/basic/work-with-server-side-database) guide for complete setup instructions.
The existing Docker Compose configuration already includes MinIO storage service and all necessary environment variables in `docker-compose/local/.env.example`. No additional setup is required.
### Image Generation Architecture
The image generation feature requires:
- **PostgreSQL**: Stores metadata about generated images
- **MinIO/S3**: Stores the actual image files
### Storage Configuration
The `docker-compose/local/.env.example` file includes all necessary S3 environment variables:
```bash
# S3 Storage Configuration (MinIO for local development)
S3_ACCESS_KEY_ID=${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
S3_ENDPOINT=http://localhost:${MINIO_PORT}
S3_BUCKET=${MINIO_LOBE_BUCKET}
S3_ENABLE_PATH_STYLE=1 # Required for MinIO
S3_SET_ACL=0 # MinIO compatibility
```
### File Storage Structure
Generated images and user uploads are organized in the MinIO bucket:
```
lobe/ # S3 Bucket (MINIO_LOBE_BUCKET)
├── generated/ # Generated images
│ └── {userId}/
│ └── {sessionId}/
│ └── {imageId}.png
└── uploads/ # User uploads for image-to-image
└── {userId}/
└── {fileId}.{ext}
```
### Development Workflow for Images
When developing image generation features, generated images will be:
1. Created by the AI model
2. Uploaded to S3/MinIO via presigned URLs
3. Metadata stored in PostgreSQL
4. Served via the public S3 URL
Example code for testing image upload:
```typescript
// Example: Upload generated image
const uploadUrl = await trpc.upload.createPresignedUrl.mutate({
filename: 'generated-image.png',
contentType: 'image/png',
});
// Upload to S3
await fetch(uploadUrl, {
method: 'PUT',
body: imageBlob,
headers: { 'Content-Type': 'image/png' },
});
```
### Service URLs
When running with Docker Compose development setup:
- **PostgreSQL**: `postgres://postgres@localhost:5432/LobeHub`
- **MinIO API**: `http://localhost:9000`
- **MinIO Console**: `http://localhost:9001` (admin/CHANGE\_THIS\_PASSWORD\_IN\_PRODUCTION)
- **Application**: `http://localhost:3010`
## Troubleshooting
### Reset Services
If you encounter issues, you can reset the entire stack:
```bash
# Stop and remove all containers
docker-compose -f docker-compose.development.yml down
# Remove volumes (this will delete all data)
docker-compose -f docker-compose.development.yml down -v
# Start fresh
docker-compose -f docker-compose.development.yml up -d
pnpm db:migrate
```
### Port Conflicts
If ports are already in use:
```bash
# Check what's using the ports
lsof -i :5432 # PostgreSQL
lsof -i :9000 # MinIO API
lsof -i :9001 # MinIO Console
```
### Database Migrations
The setup script runs migrations automatically. If you need to run them manually:
```bash
pnpm db:migrate
```
Note: In development mode with `pnpm dev:desktop`, migrations also run automatically on startup.
---
During the development process, if you encounter any issues with environment setup or have any questions about LobeHub development, feel free to ask us at any time. We look forward to seeing your contributions!
@@ -7,6 +7,8 @@ tags:
- Node.js
- PNPM
- Git
- Docker
- PostgreSQL
---
# 环境设置指南
@@ -29,8 +31,9 @@ tags:
- Node.jsLobeHub 是基于 Node.js 构建的,因此你需要安装 Node.js。我们建议安装最新的稳定版。
- PNPM:我们使用 PNPM 作为管理器。你可以从 [pnpm 的官方网站](https://pnpm.io/installation) 上下载并安装。
- Bun:我们使用 Bun 作为 npm scripts runner, 你可以从 [Bun 的官方网站](https://bun.com/docs/installation) 上下载并安装。
- Bun:我们使用 Bun 作为 npm scripts runner你可以从 [Bun 的官方网站](https://bun.com/docs/installation) 上下载并安装。
- Git:我们使用 Git 进行版本控制。你可以从 Git 的官方网站上下载并安装。
- Docker:用于运行 PostgreSQL、MinIO 等服务。你可以从 [Docker 官方网站](https://www.docker.com/get-started) 下载并安装。
- IDE:你可以选择你喜欢的集成开发环境(IDE),我们推荐使用 WebStorm/VSCode。
### VSCode 用户
@@ -41,20 +44,72 @@ tags:
完成上述软件的安装后,你可以开始设置 LobeHub 项目了。
1. **获取代码**:首先,你需要从 GitHub 上克隆 LobeHub 的代码库。在终端中运行以下命令:
#### 1. 获取代码
首先,你需要从 GitHub 上克隆 LobeHub 的代码库。在终端中运行以下命令:
```bash
git clone https://github.com/lobehub/lobehub.git
cd lobehub
```
2. **安装依赖**:然后,进入项目目录,并使用 `pnpm` 安装项目的依赖包:
#### 2. 安装依赖
使用 PNPM 安装项目的依赖包:
```bash
cd lobehub
pnpm i
```
3. **启动开发服务器**:安装完依赖后,你可以启动开发服务器:
#### 3. 配置环境
复制示例环境文件来创建你的 Docker Compose 配置:
```bash
cp docker-compose/local/.env.example docker-compose/local/.env
```
根据需要编辑 `docker-compose/local/.env` 文件以适应你的开发设置。此文件包含 Docker 服务所需的所有环境变量,配置了:
- **数据库**:带连接字符串的 PostgreSQL
- **身份验证**:带 Casdoor SSO 的 Better Auth
- **存储**MinIO S3 兼容存储
- **搜索**SearXNG 搜索引擎
#### 4. 启动 Docker 服务
使用 Docker Compose 启动所有必需的服务:
```bash
docker-compose -f docker-compose.development.yml up -d
```
这将启动以下服务:
- PostgreSQL 数据库(端口 5432
- MinIO 存储(端口 9000
- Casdoor 身份验证(端口 8000
- SearXNG 搜索(端口 8080
可以通过运行以下命令检查所有 Docker 服务运行状态:
```bash
docker-compose -f docker-compose.development.yml ps
```
#### 5. 运行数据库迁移
执行数据库迁移脚本以创建所有必要的表:
```bash
pnpm db:migrate
```
预期输出:`✅ database migration pass.`
#### 6. 启动开发服务器
启动 LobeHub 开发服务器:
```bash
bun run dev
@@ -64,17 +119,125 @@ bun run dev
![Chat Page](https://hub-apac-1.lobeobjects.space/docs/fc7b157a3bc016bc97719065f80c555c.png)
## 使用服务端功能
## 图像生成开发
上述基础设置使用 LobeHub 的客户端数据库模式。如果你需要开发服务端功能,如:
在开发图像生成功能(文生图、图生图)时,Docker Compose 配置已经包含了处理生成图像和用户上传所需的所有存储服务。
- 数据库持久化
- 文件上传和存储
- 图像生成
- 多用户身份验证
- 高级服务端集成
### 图像生成配置
请参考[使用服务端数据库](/docs/development/basic/work-with-server-side-database)指南获得完整的设置说明
现有的 Docker Compose 配置已经包含了 MinIO 存储服务以及 `docker-compose/local/.env.example` 中的所有必要环境变量。无需额外配置
### 图像生成架构
图像生成功能需要:
- **PostgreSQL**:存储生成图像的元数据
- **MinIO/S3**:存储实际的图像文件
### 存储配置
`docker-compose/local/.env.example` 文件包含所有必要的 S3 环境变量:
```bash
# S3 存储配置(本地开发使用 MinIO)
S3_ACCESS_KEY_ID=${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
S3_ENDPOINT=http://localhost:${MINIO_PORT}
S3_BUCKET=${MINIO_LOBE_BUCKET}
S3_ENABLE_PATH_STYLE=1 # MinIO 必需
S3_SET_ACL=0 # MinIO 兼容性
```
### 文件存储结构
生成的图像和用户上传在 MinIO 存储桶中按以下方式组织:
```
lobe/ # S3 存储桶 (MINIO_LOBE_BUCKET)
├── generated/ # 生成的图像
│ └── {userId}/
│ └── {sessionId}/
│ └── {imageId}.png
└── uploads/ # 用户上传的图像处理文件
└── {userId}/
└── {fileId}.{ext}
```
### 图像开发工作流
在开发图像生成功能时,生成的图像将:
1. 由 AI 模型创建
2. 通过预签名 URL 上传到 S3/MinIO
3. 元数据存储在 PostgreSQL 中
4. 通过公共 S3 URL 提供服务
测试图像上传的示例代码:
```typescript
// 示例:上传生成的图像
const uploadUrl = await trpc.upload.createPresignedUrl.mutate({
filename: 'generated-image.png',
contentType: 'image/png',
});
// 上传到 S3
await fetch(uploadUrl, {
method: 'PUT',
body: imageBlob,
headers: { 'Content-Type': 'image/png' },
});
```
### 服务地址
运行 Docker Compose 开发环境时:
- **PostgreSQL**`postgres://postgres@localhost:5432/LobeHub`
- **MinIO API**`http://localhost:9000`
- **MinIO 控制台**`http://localhost:9001` (admin/CHANGE\_THIS\_PASSWORD\_IN\_PRODUCTION)
- **应用程序**`http://localhost:3010`
## 故障排除
### 重置服务
如遇到问题,可以重置整个服务堆栈:
```bash
# 停止并删除所有容器
docker-compose -f docker-compose.development.yml down
# 删除卷(这将删除所有数据)
docker-compose -f docker-compose.development.yml down -v
# 重新启动
docker-compose -f docker-compose.development.yml up -d
pnpm db:migrate
```
### 端口冲突
如果端口已被占用:
```bash
# 检查端口使用情况
lsof -i :5432 # PostgreSQL
lsof -i :9000 # MinIO API
lsof -i :9001 # MinIO 控制台
```
### 数据库迁移
配置脚本会自动运行迁移。如需手动运行:
```bash
pnpm db:migrate
```
注意:在使用 `pnpm dev:desktop` 的开发模式下,迁移也会在启动时自动运行。
---
在开发过程中,如果你在环境设置上遇到任何问题,或者有任何关于 LobeHub 开发的问题,欢迎随时向我们提问。我们期待看到你的贡献!
@@ -1,195 +0,0 @@
---
title: Work with Server-Side Database
description: Learn how to set up a server-side database for LobeHub with Docker.
tags:
- LobeHub
- Server-Side Database
- Docker
- PostgreSQL
- MinIO
---
# Work with Server-Side Database
LobeHub provides a battery-included experience with its client-side database.
While some features you really care about is only available at a server-side development.
In order to work with the aspect of server-side database,
you can setup all the prerequisites by following the [Deploying Server-Side Database](https://lobehub.com/docs/self-hosting/server-database) story.
But here is the easier approach that can reduce your pain.
## Quick Setup
### Environment Configuration
First, copy the example environment file to create your Docker Compose configuration:
```bash
cp docker-compose/local/.env.example docker-compose/local/.env
```
Edit `docker-compose/local/.env` as needed for your development setup. This file contains all necessary environment variables for the Docker services and configures:
- **Database**: PostgreSQL with connection string
- **Authentication**: NextAuth with Casdoor SSO
- **Storage**: MinIO S3-compatible storage
- **Search**: SearXNG search engine
### Start Docker Services
Start all required services using Docker Compose:
```bash
docker-compose -f docker-compose.development.yml up -d
```
This will start the following services:
- PostgreSQL database (port 5432)
- MinIO storage (port 9000)
- Casdoor authentication (port 8000)
- SearXNG search (port 8080)
### Run Database Migrations
Execute the database migration script to create all necessary tables:
```bash
pnpm db:migrate
```
You should see: `✅ database migration pass.`
### Start Development Server
Launch the LobeHub development server:
```bash
pnpm dev
```
The server will start on `http://localhost:3010`
And you can check all Docker services are running by running:
```bash
docker-compose -f docker-compose.development.yml ps
```
## Image Generation Development
When working with image generation features (text-to-image, image-to-image), the Docker Compose setup already includes all necessary storage services for handling generated images and user uploads.
### Image Generation Configuration
The existing Docker Compose configuration already includes MinIO storage service and all necessary environment variables in `docker-compose/local/.env.example`. No additional setup is required.
### Image Generation Architecture
The image generation feature requires:
- **PostgreSQL**: Stores metadata about generated images
- **MinIO/S3**: Stores the actual image files
### Storage Configuration
The `docker-compose/local/.env.example` file includes all necessary S3 environment variables:
```bash
# S3 Storage Configuration (MinIO for local development)
S3_ACCESS_KEY_ID=${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
S3_ENDPOINT=http://localhost:${MINIO_PORT}
S3_BUCKET=${MINIO_LOBE_BUCKET}
S3_ENABLE_PATH_STYLE=1 # Required for MinIO
S3_SET_ACL=0 # MinIO compatibility
```
### File Storage Structure
Generated images and user uploads are organized in the MinIO bucket:
```
lobe/ # S3 Bucket (MINIO_LOBE_BUCKET)
├── generated/ # Generated images
│ └── {userId}/
│ └── {sessionId}/
│ └── {imageId}.png
└── uploads/ # User uploads for image-to-image
└── {userId}/
└── {fileId}.{ext}
```
### Development Workflow for Images
When developing image generation features, generated images will be:
1. Created by the AI model
2. Uploaded to S3/MinIO via presigned URLs
3. Metadata stored in PostgreSQL
4. Served via the public S3 URL
Example code for testing image upload:
```typescript
// Example: Upload generated image
const uploadUrl = await trpc.upload.createPresignedUrl.mutate({
filename: 'generated-image.png',
contentType: 'image/png',
});
// Upload to S3
await fetch(uploadUrl, {
method: 'PUT',
body: imageBlob,
headers: { 'Content-Type': 'image/png' },
});
```
### Service URLs
When running with Docker Compose development setup:
- **PostgreSQL**: `postgres://postgres@localhost:5432/LobeHub`
- **MinIO API**: `http://localhost:9000`
- **MinIO Console**: `http://localhost:9001` (admin/CHANGE\_THIS\_PASSWORD\_IN\_PRODUCTION)
- **Application**: `http://localhost:3010`
### Reset Services
If you encounter issues, you can reset the entire stack:
```bash
# Stop and remove all containers
docker-compose -f docker-compose.development.yml down
# Remove volumes (this will delete all data)
docker-compose -f docker-compose.development.yml down -v
# Start fresh
docker-compose -f docker-compose.development.yml up -d
pnpm db:migrate
```
### Troubleshooting
#### Port Conflicts
If ports are already in use:
```bash
# Check what's using the ports
lsof -i :5432 # PostgreSQL
lsof -i :9000 # MinIO API
lsof -i :9001 # MinIO Console
```
#### Database Migrations
The setup script runs migrations automatically. If you need to run them manually:
```bash
pnpm db:migrate
```
Note: In development mode with `pnpm dev:desktop`, migrations also run automatically on startup.
@@ -1,195 +0,0 @@
---
title: 使用服务端数据库
description: 快速设置 LobeHub 服务端数据库,支持 Docker 和图像生成。
tags:
- 服务端数据库
- LobeHub
- Docker
- 图像生成
- PostgreSQL
---
# 使用服务端数据库
LobeHub 提供了内置的客户端数据库体验。
但某些重要功能仅在服务端开发中可用。
为了使用服务端数据库功能,
需要参考 [部署服务端数据库](https://lobehub.com/docs/self-hosting/server-database) 的说明来配置所有前置条件。
本文档提供了一个更简化的配置方法,能够在本地开发时快速启动简化的服务端环境。
## 快速设置
### 环境配置
首先,复制示例环境文件来创建你的 Docker Compose 配置:
```bash
cp docker-compose/local/.env.example docker-compose/local/.env
```
根据需要编辑 `docker-compose/local/.env` 文件以适应你的开发设置。此文件包含 Docker 服务所需的所有环境变量,配置了:
- **数据库**: 带连接字符串的 PostgreSQL
- **身份验证**: 带 Casdoor SSO 的 NextAuth
- **存储**: MinIO S3 兼容存储
- **搜索**: SearXNG 搜索引擎
### 启动 Docker 服务
使用 Docker Compose 启动所有必需的服务:
```bash
docker-compose -f docker-compose.development.yml up -d
```
这将启动以下服务:
- PostgreSQL 数据库(端口 5432
- MinIO 存储(端口 9000
- Casdoor 身份验证(端口 8000
- SearXNG 搜索(端口 8080
### 运行数据库迁移
执行数据库迁移脚本以创建所有必要的表:
```bash
pnpm db:migrate
```
预期输出:`✅ database migration pass.`
### 启动开发服务器
启动 LobeHub 开发服务器:
```bash
pnpm dev
```
服务器将在 `http://localhost:3010` 上启动
可以通过运行以下命令检查所有 Docker 服务运行状态:
```bash
docker-compose -f docker-compose.development.yml ps
```
## 图像生成开发
在开发图像生成功能(文生图、图生图)时,Docker Compose 配置已经包含了处理生成图像和用户上传所需的所有存储服务。
### 图像生成配置
现有的 Docker Compose 配置已经包含了 MinIO 存储服务以及 `docker-compose/local/.env.example` 中的所有必要环境变量。无需额外配置。
### 图像生成架构
图像生成功能需要:
- **PostgreSQL**:存储生成图像的元数据
- **MinIO/S3**:存储实际的图像文件
### 存储配置
`docker-compose/local/.env.example` 文件包含所有必要的 S3 环境变量:
```bash
# S3 存储配置(本地开发使用 MinIO)
S3_ACCESS_KEY_ID=${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
S3_ENDPOINT=http://localhost:${MINIO_PORT}
S3_BUCKET=${MINIO_LOBE_BUCKET}
S3_ENABLE_PATH_STYLE=1 # MinIO 必需
S3_SET_ACL=0 # MinIO 兼容性
```
### 文件存储结构
生成的图像和用户上传在 MinIO 存储桶中按以下方式组织:
```
lobe/ # S3 存储桶 (MINIO_LOBE_BUCKET)
├── generated/ # 生成的图像
│ └── {userId}/
│ └── {sessionId}/
│ └── {imageId}.png
└── uploads/ # 用户上传的图像处理文件
└── {userId}/
└── {fileId}.{ext}
```
### 图像开发工作流
在开发图像生成功能时,生成的图像将:
1. 由 AI 模型创建
2. 通过预签名 URL 上传到 S3/MinIO
3. 元数据存储在 PostgreSQL 中
4. 通过公共 S3 URL 提供服务
测试图像上传的示例代码:
```typescript
// 示例:上传生成的图像
const uploadUrl = await trpc.upload.createPresignedUrl.mutate({
filename: 'generated-image.png',
contentType: 'image/png',
});
// 上传到 S3
await fetch(uploadUrl, {
method: 'PUT',
body: imageBlob,
headers: { 'Content-Type': 'image/png' },
});
```
### 服务地址
运行 Docker Compose 开发环境时:
- **PostgreSQL**`postgres://postgres@localhost:5432/LobeHub`
- **MinIO API**`http://localhost:9000`
- **MinIO 控制台**`http://localhost:9001` (admin/CHANGE\_THIS\_PASSWORD\_IN\_PRODUCTION)
- **应用程序**`http://localhost:3010`
### 重置服务
如遇到问题,可以重置整个服务堆栈:
```bash
# 停止并删除所有容器
docker-compose -f docker-compose.development.yml down
# 删除卷(这将删除所有数据)
docker-compose -f docker-compose.development.yml down -v
# 重新启动
docker-compose -f docker-compose.development.yml up -d
pnpm db:migrate
```
### 故障排除
#### 端口冲突
如果端口已被占用:
```bash
# 检查端口使用情况
lsof -i :5432 # PostgreSQL
lsof -i :9000 # MinIO API
lsof -i :9001 # MinIO 控制台
```
#### 数据库迁移
配置脚本会自动运行迁移。如需手动运行:
```bash
pnpm db:migrate
```
注意:在使用 `pnpm dev:desktop` 的开发模式下,迁移也会在启动时自动运行。
+1
View File
@@ -643,6 +643,7 @@ table messages {
thread_id [name: 'messages_thread_id_idx']
agent_id [name: 'messages_agent_id_idx']
group_id [name: 'messages_group_id_idx']
message_group_id [name: 'messages_message_group_id_idx']
}
}
+19 -19
View File
@@ -42,7 +42,7 @@ To enable Better Auth in LobeHub, set the following environment variables:
| --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Google | `google` | `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET` |
| GitHub | `github` | `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET` |
| Microsoft | `microsoft` | `AUTH_MICROSOFT_ID`, `AUTH_MICROSOFT_SECRET` |
| Microsoft | `microsoft` | `AUTH_MICROSOFT_ID`, `AUTH_MICROSOFT_SECRET`, `AUTH_MICROSOFT_AUTHORITY_URL`, `AUTH_MICROSOFT_TENANT_ID` |
| Apple | `apple` | `AUTH_APPLE_CLIENT_ID`, `AUTH_APPLE_CLIENT_SECRET` |
| AWS Cognito | `cognito` | `AUTH_COGNITO_ID`, `AUTH_COGNITO_SECRET`, `AUTH_COGNITO_DOMAIN`, `AUTH_COGNITO_REGION`, `AUTH_COGNITO_USERPOOL_ID` |
| Auth0 | `auth0` | `AUTH_AUTH0_ID`, `AUTH_AUTH0_SECRET`, `AUTH_AUTH0_ISSUER` |
@@ -61,41 +61,41 @@ To enable Better Auth in LobeHub, set the following environment variables:
Click on a provider below for detailed configuration guides:
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/providers/password'} title={'Email/Password'} />
<Card href={'/docs/self-hosting/auth/providers/password'} title={'Email/Password'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/github'} title={'GitHub'} />
<Card href={'/docs/self-hosting/auth/providers/github'} title={'GitHub'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/google'} title={'Google'} />
<Card href={'/docs/self-hosting/auth/providers/google'} title={'Google'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/microsoft'} title={'Microsoft'} />
<Card href={'/docs/self-hosting/auth/providers/microsoft'} title={'Microsoft'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/apple'} title={'Apple'} />
<Card href={'/docs/self-hosting/auth/providers/apple'} title={'Apple'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/cognito'} title={'AWS Cognito'} />
<Card href={'/docs/self-hosting/auth/providers/cognito'} title={'AWS Cognito'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/auth0'} title={'Auth0'} />
<Card href={'/docs/self-hosting/auth/providers/auth0'} title={'Auth0'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/authelia'} title={'Authelia'} />
<Card href={'/docs/self-hosting/auth/providers/authelia'} title={'Authelia'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/authentik'} title={'Authentik'} />
<Card href={'/docs/self-hosting/auth/providers/authentik'} title={'Authentik'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/casdoor'} title={'Casdoor'} />
<Card href={'/docs/self-hosting/auth/providers/casdoor'} title={'Casdoor'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/docs/self-hosting/auth/providers/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/auth/providers/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/logto'} title={'Logto'} />
<Card href={'/docs/self-hosting/auth/providers/logto'} title={'Logto'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/okta'} title={'Okta'} />
<Card href={'/docs/self-hosting/auth/providers/okta'} title={'Okta'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/zitadel'} title={'ZITADEL'} />
<Card href={'/docs/self-hosting/auth/providers/zitadel'} title={'ZITADEL'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/docs/self-hosting/auth/providers/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/feishu'} title={'Feishu'} />
<Card href={'/docs/self-hosting/auth/providers/feishu'} title={'Feishu'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/wechat'} title={'WeChat'} />
<Card href={'/docs/self-hosting/auth/providers/wechat'} title={'WeChat'} />
</Cards>
## Callback URL Format
+19 -19
View File
@@ -42,7 +42,7 @@ LobeHub 支持使用 Better Auth 配置外部身份验证服务,供企业 /
| --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Google | `google` | `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET` |
| GitHub | `github` | `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET` |
| Microsoft | `microsoft` | `AUTH_MICROSOFT_ID`, `AUTH_MICROSOFT_SECRET` |
| Microsoft | `microsoft` | `AUTH_MICROSOFT_ID`, `AUTH_MICROSOFT_SECRET`, `AUTH_MICROSOFT_AUTHORITY_URL`, `AUTH_MICROSOFT_TENANT_ID` |
| Apple | `apple` | `AUTH_APPLE_CLIENT_ID`, `AUTH_APPLE_CLIENT_SECRET` |
| AWS Cognito | `cognito` | `AUTH_COGNITO_ID`, `AUTH_COGNITO_SECRET`, `AUTH_COGNITO_DOMAIN`, `AUTH_COGNITO_REGION`, `AUTH_COGNITO_USERPOOL_ID` |
| Auth0 | `auth0` | `AUTH_AUTH0_ID`, `AUTH_AUTH0_SECRET`, `AUTH_AUTH0_ISSUER` |
@@ -61,41 +61,41 @@ LobeHub 支持使用 Better Auth 配置外部身份验证服务,供企业 /
点击下方提供商查看详细配置指南:
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/password'} title={'邮箱密码'} />
<Card href={'/zh/docs/self-hosting/auth/providers/password'} title={'邮箱密码'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/github'} title={'GitHub'} />
<Card href={'/zh/docs/self-hosting/auth/providers/github'} title={'GitHub'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/google'} title={'Google'} />
<Card href={'/zh/docs/self-hosting/auth/providers/google'} title={'Google'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/microsoft'} title={'Microsoft'} />
<Card href={'/zh/docs/self-hosting/auth/providers/microsoft'} title={'Microsoft'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/apple'} title={'Apple'} />
<Card href={'/zh/docs/self-hosting/auth/providers/apple'} title={'Apple'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/cognito'} title={'AWS Cognito'} />
<Card href={'/zh/docs/self-hosting/auth/providers/cognito'} title={'AWS Cognito'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/auth0'} title={'Auth0'} />
<Card href={'/zh/docs/self-hosting/auth/providers/auth0'} title={'Auth0'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/authelia'} title={'Authelia'} />
<Card href={'/zh/docs/self-hosting/auth/providers/authelia'} title={'Authelia'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/authentik'} title={'Authentik'} />
<Card href={'/zh/docs/self-hosting/auth/providers/authentik'} title={'Authentik'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/casdoor'} title={'Casdoor'} />
<Card href={'/zh/docs/self-hosting/auth/providers/casdoor'} title={'Casdoor'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/zh/docs/self-hosting/auth/providers/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/keycloak'} title={'Keycloak'} />
<Card href={'/zh/docs/self-hosting/auth/providers/keycloak'} title={'Keycloak'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/logto'} title={'Logto'} />
<Card href={'/zh/docs/self-hosting/auth/providers/logto'} title={'Logto'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/okta'} title={'Okta'} />
<Card href={'/zh/docs/self-hosting/auth/providers/okta'} title={'Okta'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/zitadel'} title={'ZITADEL'} />
<Card href={'/zh/docs/self-hosting/auth/providers/zitadel'} title={'ZITADEL'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/zh/docs/self-hosting/auth/providers/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/feishu'} title={'飞书'} />
<Card href={'/zh/docs/self-hosting/auth/providers/feishu'} title={'飞书'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/wechat'} title={'微信'} />
<Card href={'/zh/docs/self-hosting/auth/providers/wechat'} title={'微信'} />
</Cards>
## 回调 URL 格式
+15 -15
View File
@@ -13,7 +13,7 @@ tags:
# Legacy Authentication
<Callout type={'warning'}>
**Legacy Notice**: NextAuth and Clerk are legacy authentication methods. For new deployments, we strongly recommend using [Better Auth](/docs/self-hosting/advanced/auth) for its simplicity and flexibility.
**Legacy Notice**: NextAuth and Clerk are legacy authentication methods. For new deployments, we strongly recommend using [Better Auth](/docs/self-hosting/auth) for its simplicity and flexibility.
</Callout>
This page documents the legacy authentication methods (NextAuth and Clerk) for users who are still using these services.
@@ -27,17 +27,17 @@ LobeHub has deeply integrated with Clerk to provide users with a secure and conv
By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` in LobeHub's environment, you can enable and use Clerk.
<Callout type={'info'}>
For detailed Clerk configuration, see [Clerk Configuration Guide](/docs/self-hosting/advanced/auth/clerk).
For detailed Clerk configuration, see [Clerk Configuration Guide](/docs/self-hosting/auth/clerk).
</Callout>
<Callout type={'tip'}>
To migrate from Clerk to Better Auth, see the [Clerk Migration Guide](/docs/self-hosting/advanced/auth/clerk-to-betterauth).
To migrate from Clerk to Better Auth, see the [Clerk Migration Guide](/docs/self-hosting/migration/v2/auth/clerk-to-betterauth).
</Callout>
## Next Auth
<Callout type={'tip'}>
To migrate from NextAuth to Better Auth, see the [NextAuth Migration Guide](/docs/self-hosting/advanced/auth/nextauth-to-betterauth).
To migrate from NextAuth to Better Auth, see the [NextAuth Migration Guide](/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth).
</Callout>
Before using NextAuth, please set the following variables in LobeHub's environment variables:
@@ -53,27 +53,27 @@ Before using NextAuth, please set the following variables in LobeHub's environme
Currently supported identity verification services include:
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/next-auth/auth0'} title={'Auth0'} />
<Card href={'/docs/self-hosting/auth/next-auth/auth0'} title={'Auth0'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/microsoft-entra-id'} title={'Microsoft Entra ID'} />
<Card href={'/docs/self-hosting/auth/next-auth/microsoft-entra-id'} title={'Microsoft Entra ID'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/authentik'} title={'Authentik'} />
<Card href={'/docs/self-hosting/auth/next-auth/authentik'} title={'Authentik'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/github'} title={'Github'} />
<Card href={'/docs/self-hosting/auth/next-auth/github'} title={'Github'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/zitadel'} title={'ZITADEL'} />
<Card href={'/docs/self-hosting/auth/next-auth/zitadel'} title={'ZITADEL'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/docs/self-hosting/auth/next-auth/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/authelia'} title={'Authelia'} />
<Card href={'/docs/self-hosting/auth/next-auth/authelia'} title={'Authelia'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/logto'} title={'Logto'} />
<Card href={'/docs/self-hosting/auth/next-auth/logto'} title={'Logto'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/auth/next-auth/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/google'} title={'Google'} />
<Card href={'/docs/self-hosting/auth/next-auth/google'} title={'Google'} />
<Card href={'/docs/self-hosting/advanced/auth/next-auth/okta'} title={'Okta'} />
<Card href={'/docs/self-hosting/auth/next-auth/okta'} title={'Okta'} />
</Cards>
Click on the links to view the corresponding platform's configuration documentation.
+14 -14
View File
@@ -11,7 +11,7 @@ tags:
# 旧版身份验证
<Callout type={'warning'}>
**旧版提示**NextAuth 和 Clerk 是旧版身份验证方案。对于新部署,我们强烈建议使用 [Better Auth](/zh/docs/self-hosting/advanced/auth),它更简洁、更灵活。
**旧版提示**NextAuth 和 Clerk 是旧版身份验证方案。对于新部署,我们强烈建议使用 [Better Auth](/zh/docs/self-hosting/auth),它更简洁、更灵活。
</Callout>
本页面为仍在使用这些服务的用户提供旧版身份验证方案(NextAuth 和 Clerk)的文档。
@@ -25,17 +25,17 @@ LobeHub 与 Clerk 做了深度集成,能够为用户提供安全、便捷的
在 LobeHub 的环境变量中设置 `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` 和 `CLERK_SECRET_KEY`,即可开启和使用 Clerk。
<Callout type={'info'}>
详细的 Clerk 配置请参阅 [Clerk 配置指南](/zh/docs/self-hosting/advanced/auth/clerk)。
详细的 Clerk 配置请参阅 [Clerk 配置指南](/zh/docs/self-hosting/auth/clerk)。
</Callout>
<Callout type={'tip'}>
如需从 Clerk 迁移到 Better Auth,请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/advanced/auth/clerk-to-betterauth)。
如需从 Clerk 迁移到 Better Auth,请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/migration/v2/auth/clerk-to-betterauth)。
</Callout>
## Next Auth
<Callout type={'tip'}>
如需从 NextAuth 迁移到 Better Auth,请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/advanced/auth/nextauth-to-betterauth)。
如需从 NextAuth 迁移到 Better Auth,请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth)。
</Callout>
在使用 NextAuth 之前,请先在 LobeHub 的环境变量中设置以下变量:
@@ -51,25 +51,25 @@ LobeHub 与 Clerk 做了深度集成,能够为用户提供安全、便捷的
目前支持的身份验证服务有:
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/auth0'} title={'Auth0'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/auth0'} title={'Auth0'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/microsoft-entra-id'} title={'Microsoft Entra ID'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/microsoft-entra-id'} title={'Microsoft Entra ID'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/authentik'} title={'Authentik'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/authentik'} title={'Authentik'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/github'} title={'Github'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/github'} title={'Github'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/zitadel'} title={'ZITADEL'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/zitadel'} title={'ZITADEL'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/authelia'} title={'Authelia'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/authelia'} title={'Authelia'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/logto'} title={'Logto'} />
<Card href={'/zh/docs/self-hosting/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/auth/next-auth/keycloak'} title={'Keycloak'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/next-auth/okta'} title={'Okta'} />
<Card href={'/zh/docs/self-hosting/auth/next-auth/okta'} title={'Okta'} />
</Cards>
点击即可查看对应平台的配置文档。
+8 -10
View File
@@ -70,12 +70,14 @@ tags:
### Configure Environment Variables
| Environment Variable | Type | Description |
| ----------------------- | -------- | --------------------------------------------------------------- |
| `AUTH_SECRET` | Required | Session encryption key, generate with `openssl rand -base64 32` |
| `AUTH_SSO_PROVIDERS` | Required | Set to `microsoft` |
| `AUTH_MICROSOFT_ID` | Required | Application (client) ID |
| `AUTH_MICROSOFT_SECRET` | Required | Client secret value |
| Environment Variable | Type | Description |
| ------------------------------ | -------- | --------------------------------------------------------------- |
| `AUTH_SECRET` | Required | Session encryption key, generate with `openssl rand -base64 32` |
| `AUTH_SSO_PROVIDERS` | Required | Set to `microsoft` |
| `AUTH_MICROSOFT_ID` | Required | Application (client) ID |
| `AUTH_MICROSOFT_SECRET` | Required | Client secret value |
| `AUTH_MICROSOFT_AUTHORITY_URL` | Optional | Authority URL for Microsoft Entra ID |
| `AUTH_MICROSOFT_TENANT_ID` | Optional | Directory (tenant) ID for single-tenant apps |
<Callout type={'info'}>
**Alternative Environment Variables**: For backward compatibility, these
@@ -99,10 +101,6 @@ tags:
## Common Issues
### Tenant Configuration
By default, LobeHub uses `common` tenant which allows both organizational and personal Microsoft accounts. If you need single-tenant configuration, you may need to customize the tenant settings.
### Client Secret Expiration
Microsoft client secrets have a maximum validity of 24 months. Remember to rotate secrets before they expire.
@@ -68,12 +68,14 @@ tags:
### 配置环境变量
| 环境变量 | 类型 | 描述 |
| ----------------------- | -- | -------------------------------------- |
| `AUTH_SECRET` | 必选 | 会话加密密钥,使用 `openssl rand -base64 32` 生成 |
| `AUTH_SSO_PROVIDERS` | 必选 | 填写 `microsoft` |
| `AUTH_MICROSOFT_ID` | 必选 | Application (client) ID |
| `AUTH_MICROSOFT_SECRET` | 必选 | 客户端密钥值 |
| 环境变量 | 类型 | 描述 |
| ------------------------------ | -- | -------------------------------------- |
| `AUTH_SECRET` | 必选 | 会话加密密钥,使用 `openssl rand -base64 32` 生成 |
| `AUTH_SSO_PROVIDERS` | 必选 | 填写 `microsoft` |
| `AUTH_MICROSOFT_ID` | 必选 | Application (client) ID |
| `AUTH_MICROSOFT_SECRET` | 必选 | 客户端密钥值 |
| `AUTH_MICROSOFT_AUTHORITY_URL` | 可选 | Microsoft Entra ID 的 Authority URL |
| `AUTH_MICROSOFT_TENANT_ID` | 可选 | 单租户应用的 Directory (tenant) ID |
<Callout type={'info'}>
**兼容的环境变量**:为了向后兼容,以下别名也支持:
@@ -95,10 +97,6 @@ tags:
## 常见问题
### 租户配置
默认情况下,LobeHub 使用 `common` 租户,允许组织帐户和个人 Microsoft 帐户登录。如果需要单租户配置,可能需要自定义租户设置。
### 客户端密钥过期
Microsoft 客户端密钥最长有效期为 24 个月。请记得在过期前轮换密钥。
@@ -162,6 +162,20 @@ These settings are required for email verification and password reset features.
- Default: `-`
- Example: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
#### `AUTH_MICROSOFT_AUTHORITY_URL`
- Type: Optional
- Description: Authority URL for the Microsoft Entra ID. This is used to specify the endpoint for authentication requests.
- Default: `https://login.microsoftonline.com`
- Example: `https://login.partner.microsoftonline.cn`
#### `AUTH_MICROSOFT_TENANT_ID`
- Type: Optional
- Description: Directory (tenant) ID for single-tenant Microsoft Entra ID applications.
- Default: `common`
- Example: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
### AWS Cognito
#### `AUTH_COGNITO_ID`
@@ -160,6 +160,20 @@ LobeHub 在部署时提供了完善的身份验证服务能力,以下是相关
- 默认值:`-`
- 示例:`xxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
#### `AUTH_MICROSOFT_AUTHORITY_URL`
- 类型:可选
- 描述:Microsoft Entra ID 的 Authority URL。
- 默认值:`https://login.microsoftonline.com`
- 示例:`https://login.partner.microsoftonline.cn`
#### `AUTH_MICROSOFT_TENANT_ID`
- 类型:可选
- 描述:单租户 Microsoft Entra ID 应用的 Directory (tenant) ID。
- 默认值:`common`
- 示例:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
### AWS Cognito
#### `AUTH_COGNITO_ID`
@@ -57,7 +57,7 @@ For small self-hosted deployments, the simplest approach is to let users reset t
1. **Configure Email Service**
Set up email service for password reset functionality. See [Email Service Configuration](/docs/self-hosting/advanced/auth#email-service-configuration).
Set up email service for password reset functionality. See [Email Service Configuration](/docs/self-hosting/auth#email-service-configuration).
2. **Update Environment Variables**
@@ -80,7 +80,7 @@ For small self-hosted deployments, the simplest approach is to let users reset t
```
<Callout type={'tip'}>
See [Authentication Service Configuration](/docs/self-hosting/advanced/auth) for complete environment variables and SSO provider setup.
See [Authentication Service Configuration](/docs/self-hosting/auth) for complete environment variables and SSO provider setup.
</Callout>
3. **Redeploy LobeHub**
@@ -289,7 +289,7 @@ npx tsx scripts/clerk-to-betterauth/verify.ts
After migration is complete, follow [Simple Migration - Step 2](#steps) to configure Better Auth environment variables and redeploy.
<Callout type={'tip'}>
For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/advanced/auth).
For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/auth).
</Callout>
## What Gets Migrated
@@ -339,11 +339,11 @@ This error occurs because the database schema is outdated. Run `pnpm db:migrate`
## Related Reading
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/migration-internals'} title={'Migration Technical Deep Dive'} />
<Card href={'/docs/self-hosting/migration/v2/auth/migration-internals'} title={'Migration Technical Deep Dive'} />
<Card href={'/docs/self-hosting/advanced/auth'} title={'Authentication Service Configuration'} />
<Card href={'/docs/self-hosting/auth'} title={'Authentication Service Configuration'} />
<Card href={'/docs/self-hosting/environment-variables/auth'} title={'Auth Environment Variables'} />
<Card href={'/docs/self-hosting/advanced/auth/legacy'} title={'Legacy Authentication (NextAuth & Clerk)'} />
<Card href={'/docs/self-hosting/auth/legacy'} title={'Legacy Authentication (NextAuth & Clerk)'} />
</Cards>
@@ -55,7 +55,7 @@ tags:
1. **配置邮件服务**
设置邮件服务以支持密码重置功能。参阅 [邮件服务配置](/zh/docs/self-hosting/advanced/auth#邮件服务配置)。
设置邮件服务以支持密码重置功能。参阅 [邮件服务配置](/zh/docs/self-hosting/auth#邮件服务配置)。
2. **更新环境变量**
@@ -78,7 +78,7 @@ tags:
```
<Callout type={'tip'}>
查阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth) 了解完整的环境变量和 SSO 提供商配置。
查阅 [身份验证服务配置](/zh/docs/self-hosting/auth) 了解完整的环境变量和 SSO 提供商配置。
</Callout>
3. **重新部署 LobeHub**
@@ -283,7 +283,7 @@ npx tsx scripts/clerk-to-betterauth/verify.ts
迁移完成后,参照 [简单迁移 - 步骤 2](#步骤) 配置 Better Auth 环境变量并重新部署。
<Callout type={'tip'}>
完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth),包括所有支持的 SSO 提供商和邮件服务配置。
完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/auth),包括所有支持的 SSO 提供商和邮件服务配置。
</Callout>
## 迁移内容对比
@@ -333,11 +333,11 @@ npx tsx scripts/clerk-to-betterauth/verify.ts
## 相关阅读
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth/migration-internals'} title={'迁移技术原理'} />
<Card href={'/zh/docs/self-hosting/migration/v2/auth/migration-internals'} title={'迁移技术原理'} />
<Card href={'/zh/docs/self-hosting/advanced/auth'} title={'身份验证服务配置'} />
<Card href={'/zh/docs/self-hosting/auth'} title={'身份验证服务配置'} />
<Card href={'/zh/docs/self-hosting/environment-variables/auth'} title={'认证相关环境变量'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/legacy'} title={'旧版身份验证(NextAuth 和 Clerk'} />
<Card href={'/zh/docs/self-hosting/auth/legacy'} title={'旧版身份验证(NextAuth 和 Clerk'} />
</Cards>
@@ -15,7 +15,7 @@ tags:
This document explains the technical principles behind authentication migration in LobeHub. It's intended for users with database and development experience who want to understand how migration works under the hood.
<Callout type={'info'}>
For step-by-step migration instructions, see [NextAuth Migration](/docs/self-hosting/advanced/auth/nextauth-to-betterauth) or [Clerk Migration](/docs/self-hosting/advanced/auth/clerk-to-betterauth).
For step-by-step migration instructions, see [NextAuth Migration](/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth) or [Clerk Migration](/docs/self-hosting/migration/v2/auth/clerk-to-betterauth).
</Callout>
## Core Database Schema
@@ -193,9 +193,9 @@ This typically happens with simple migration when logging in with a secondary em
## Related Reading
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/nextauth-to-betterauth'} title={'NextAuth Migration Guide'} />
<Card href={'/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth'} title={'NextAuth Migration Guide'} />
<Card href={'/docs/self-hosting/advanced/auth/clerk-to-betterauth'} title={'Clerk Migration Guide'} />
<Card href={'/docs/self-hosting/migration/v2/auth/clerk-to-betterauth'} title={'Clerk Migration Guide'} />
<Card href={'/docs/self-hosting/advanced/auth'} title={'Authentication Configuration'} />
<Card href={'/docs/self-hosting/auth'} title={'Authentication Configuration'} />
</Cards>
@@ -14,7 +14,7 @@ tags:
本文档解释 LobeHub 认证迁移的技术原理,适合有数据库和开发经验的用户,帮助理解迁移的底层逻辑。
<Callout type={'info'}>
如需分步迁移指南,请参阅 [NextAuth 迁移](/docs/self-hosting/advanced/auth/nextauth-to-betterauth) 或 [Clerk 迁移](/docs/self-hosting/advanced/auth/clerk-to-betterauth)。
如需分步迁移指南,请参阅 [NextAuth 迁移](/zh/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth) 或 [Clerk 迁移](/zh/docs/self-hosting/migration/v2/auth/clerk-to-betterauth)。
</Callout>
## 核心数据库 Schema
@@ -192,9 +192,9 @@ tags:
## 相关阅读
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/nextauth-to-betterauth'} title={'NextAuth 迁移指南'} />
<Card href={'/zh/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth'} title={'NextAuth 迁移指南'} />
<Card href={'/docs/self-hosting/advanced/auth/clerk-to-betterauth'} title={'Clerk 迁移指南'} />
<Card href={'/zh/docs/self-hosting/migration/v2/auth/clerk-to-betterauth'} title={'Clerk 迁移指南'} />
<Card href={'/docs/self-hosting/advanced/auth'} title={'认证服务配置'} />
<Card href={'/zh/docs/self-hosting/auth'} title={'认证服务配置'} />
</Cards>
@@ -54,23 +54,23 @@ This guide helps you migrate your existing NextAuth-based LobeHub deployment to
SSO provider environment variables follow the same format: `AUTH_<PROVIDER>_ID` and `AUTH_<PROVIDER>_SECRET`.
| NextAuth (Old) | Better Auth (New) | Notes |
| ----------------------------------- | ----------------------- | ------------------- |
| `AUTH_GITHUB_ID` | `AUTH_GITHUB_ID` | ✅ Unchanged |
| `AUTH_GITHUB_SECRET` | `AUTH_GITHUB_SECRET` | ✅ Unchanged |
| `AUTH_GOOGLE_ID` | `AUTH_GOOGLE_ID` | ✅ Unchanged |
| `AUTH_GOOGLE_SECRET` | `AUTH_GOOGLE_SECRET` | ✅ Unchanged |
| `AUTH_AUTH0_ID` | `AUTH_AUTH0_ID` | ✅ Unchanged |
| `AUTH_AUTH0_SECRET` | `AUTH_AUTH0_SECRET` | ✅ Unchanged |
| `AUTH_AUTH0_ISSUER` | `AUTH_AUTH0_ISSUER` | ✅ Unchanged |
| `AUTH_AUTHENTIK_ID` | `AUTH_AUTHENTIK_ID` | ✅ Unchanged |
| `AUTH_AUTHENTIK_SECRET` | `AUTH_AUTHENTIK_SECRET` | ✅ Unchanged |
| `AUTH_AUTHENTIK_ISSUER` | `AUTH_AUTHENTIK_ISSUER` | ✅ Unchanged |
| `microsoft-entra-id` | `microsoft` | ⚠️ Provider renamed |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | `AUTH_MICROSOFT_ID` | ⚠️ Variable renamed |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | `AUTH_MICROSOFT_SECRET` | ⚠️ Variable renamed |
| `AUTH_MICROSOFT_ENTRA_ID_TENANT_ID` | - | ❌ No longer needed |
| `AUTH_MICROSOFT_ENTRA_ID_BASE_URL` | - | ❌ No longer needed |
| NextAuth (Old) | Better Auth (New) | Notes |
| ----------------------------------- | ------------------------------ | ------------------- |
| `AUTH_GITHUB_ID` | `AUTH_GITHUB_ID` | ✅ Unchanged |
| `AUTH_GITHUB_SECRET` | `AUTH_GITHUB_SECRET` | ✅ Unchanged |
| `AUTH_GOOGLE_ID` | `AUTH_GOOGLE_ID` | ✅ Unchanged |
| `AUTH_GOOGLE_SECRET` | `AUTH_GOOGLE_SECRET` | ✅ Unchanged |
| `AUTH_AUTH0_ID` | `AUTH_AUTH0_ID` | ✅ Unchanged |
| `AUTH_AUTH0_SECRET` | `AUTH_AUTH0_SECRET` | ✅ Unchanged |
| `AUTH_AUTH0_ISSUER` | `AUTH_AUTH0_ISSUER` | ✅ Unchanged |
| `AUTH_AUTHENTIK_ID` | `AUTH_AUTHENTIK_ID` | ✅ Unchanged |
| `AUTH_AUTHENTIK_SECRET` | `AUTH_AUTHENTIK_SECRET` | ✅ Unchanged |
| `AUTH_AUTHENTIK_ISSUER` | `AUTH_AUTHENTIK_ISSUER` | ✅ Unchanged |
| `microsoft-entra-id` | `microsoft` | ⚠️ Provider renamed |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | `AUTH_MICROSOFT_ID` | ⚠️ Variable renamed |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | `AUTH_MICROSOFT_SECRET` | ⚠️ Variable renamed |
| `AUTH_MICROSOFT_ENTRA_ID_TENANT_ID` | `AUTH_MICROSOFT_TENANT_ID` | ⚠️ Variable renamed |
| `AUTH_MICROSOFT_ENTRA_ID_BASE_URL` | `AUTH_MICROSOFT_AUTHORITY_URL` | ⚠️ Variable renamed |
<Callout type={'warning'}>
**Note**: Microsoft Entra ID provider name changed from `microsoft-entra-id` to `microsoft`, and the environment variable prefix changed from `AUTH_MICROSOFT_ENTRA_ID_` to `AUTH_MICROSOFT_`.
@@ -133,7 +133,7 @@ For small self-hosted deployments, the simplest approach is to let users re-logi
```
<Callout type={'tip'}>
See [Authentication Service Configuration](/docs/self-hosting/advanced/auth) for complete environment variables and SSO provider setup.
See [Authentication Service Configuration](/docs/self-hosting/auth) for complete environment variables and SSO provider setup.
</Callout>
2. **Redeploy LobeHub**
@@ -286,7 +286,7 @@ npx tsx scripts/nextauth-to-betterauth/verify.ts
After migration is complete, follow [Simple Migration - Step 1](#steps) to configure Better Auth environment variables and redeploy.
<Callout type={'tip'}>
For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/advanced/auth), including all supported SSO providers and email service configuration.
For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/auth), including all supported SSO providers and email service configuration.
</Callout>
## What Gets Migrated
@@ -360,19 +360,19 @@ For identity providers like Casdoor or Logto, users may not have an email config
Solution:
1. First configure the Webhook in LobeHub to sync user data from the identity provider:
- [Casdoor Webhook Configuration](/docs/self-hosting/advanced/auth/providers/casdoor)
- [Logto Webhook Configuration](/docs/self-hosting/advanced/auth/providers/logto)
- [Casdoor Webhook Configuration](/docs/self-hosting/auth/providers/casdoor)
- [Logto Webhook Configuration](/docs/self-hosting/auth/providers/logto)
2. Then configure the user's email in the identity provider's admin console
3. The user data will be synced to LobeHub via Webhook, and the user can then log in
## Related Reading
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/migration-internals'} title={'Migration Technical Deep Dive'} />
<Card href={'/docs/self-hosting/migration/v2/auth/migration-internals'} title={'Migration Technical Deep Dive'} />
<Card href={'/docs/self-hosting/advanced/auth'} title={'Authentication Service Configuration'} />
<Card href={'/docs/self-hosting/auth'} title={'Authentication Service Configuration'} />
<Card href={'/docs/self-hosting/environment-variables/auth'} title={'Auth Environment Variables'} />
<Card href={'/docs/self-hosting/advanced/auth/legacy'} title={'Legacy Authentication (NextAuth & Clerk)'} />
<Card href={'/docs/self-hosting/auth/legacy'} title={'Legacy Authentication (NextAuth & Clerk)'} />
</Cards>
@@ -52,21 +52,23 @@ tags:
SSO 提供商的环境变量格式保持一致:`AUTH_<PROVIDER>_ID` 和 `AUTH_<PROVIDER>_SECRET`。
| NextAuth (旧) | Better Auth (新) | 说明 |
| -------------------------------- | ----------------------- | ---------------- |
| `AUTH_GITHUB_ID` | `AUTH_GITHUB_ID` | ✅ 保持不变 |
| `AUTH_GITHUB_SECRET` | `AUTH_GITHUB_SECRET` | ✅ 保持不变 |
| `AUTH_GOOGLE_ID` | `AUTH_GOOGLE_ID` | ✅ 保持不变 |
| `AUTH_GOOGLE_SECRET` | `AUTH_GOOGLE_SECRET` | ✅ 保持不变 |
| `AUTH_AUTH0_ID` | `AUTH_AUTH0_ID` | ✅ 保持不变 |
| `AUTH_AUTH0_SECRET` | `AUTH_AUTH0_SECRET` | ✅ 保持不变 |
| `AUTH_AUTH0_ISSUER` | `AUTH_AUTH0_ISSUER` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_ID` | `AUTH_AUTHENTIK_ID` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_SECRET` | `AUTH_AUTHENTIK_SECRET` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_ISSUER` | `AUTH_AUTHENTIK_ISSUER` | ✅ 保持不变 |
| `microsoft-entra-id` | `microsoft` | ⚠️ provider 名称变更 |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | `AUTH_MICROSOFT_ID` | ⚠️ 变量名变更 |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | `AUTH_MICROSOFT_SECRET` | ⚠️ 变量名变更 |
| NextAuth (旧) | Better Auth (新) | 说明 |
| ----------------------------------- | ------------------------------ | ---------------- |
| `AUTH_GITHUB_ID` | `AUTH_GITHUB_ID` | ✅ 保持不变 |
| `AUTH_GITHUB_SECRET` | `AUTH_GITHUB_SECRET` | ✅ 保持不变 |
| `AUTH_GOOGLE_ID` | `AUTH_GOOGLE_ID` | ✅ 保持不变 |
| `AUTH_GOOGLE_SECRET` | `AUTH_GOOGLE_SECRET` | ✅ 保持不变 |
| `AUTH_AUTH0_ID` | `AUTH_AUTH0_ID` | ✅ 保持不变 |
| `AUTH_AUTH0_SECRET` | `AUTH_AUTH0_SECRET` | ✅ 保持不变 |
| `AUTH_AUTH0_ISSUER` | `AUTH_AUTH0_ISSUER` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_ID` | `AUTH_AUTHENTIK_ID` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_SECRET` | `AUTH_AUTHENTIK_SECRET` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_ISSUER` | `AUTH_AUTHENTIK_ISSUER` | ✅ 保持不变 |
| `microsoft-entra-id` | `microsoft` | ⚠️ provider 名称变更 |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | `AUTH_MICROSOFT_ID` | ⚠️ 变量名变更 |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | `AUTH_MICROSOFT_SECRET` | ⚠️ 变量名变更 |
| `AUTH_MICROSOFT_ENTRA_ID_TENANT_ID` | `AUTH_MICROSOFT_TENANT_ID` | ⚠️ 变量名变更 |
| `AUTH_MICROSOFT_ENTRA_ID_BASE_URL` | `AUTH_MICROSOFT_AUTHORITY_URL` | ⚠️ 变量名变更 |
<Callout type={'warning'}>
**注意**Microsoft Entra ID 的 provider 名称从 `microsoft-entra-id` 改为 `microsoft`,相应的环境变量前缀也从 `AUTH_MICROSOFT_ENTRA_ID_` 改为 `AUTH_MICROSOFT_`。
@@ -128,7 +130,7 @@ Better Auth 支持更多功能,以下是新增的环境变量:
```
<Callout type={'tip'}>
查阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth) 了解完整的环境变量和 SSO 提供商配置。
查阅 [身份验证服务配置](/zh/docs/self-hosting/auth) 了解完整的环境变量和 SSO 提供商配置。
</Callout>
2. **重新部署 LobeHub**
@@ -280,7 +282,7 @@ npx tsx scripts/nextauth-to-betterauth/verify.ts
迁移完成后,参照 [简单迁移 - 步骤 1](#步骤) 配置 Better Auth 环境变量并重新部署。
<Callout type={'tip'}>
完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth),包括所有支持的 SSO 提供商和邮件服务配置。
完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/auth),包括所有支持的 SSO 提供商和邮件服务配置。
</Callout>
## 迁移内容对比
@@ -354,19 +356,19 @@ npx tsx scripts/nextauth-to-betterauth/verify.ts
解决方案:
1. 先在 LobeHub 中配置身份提供商的 Webhook 以同步用户数据:
- [Casdoor Webhook 配置](/zh/docs/self-hosting/advanced/auth/providers/casdoor)
- [Logto Webhook 配置](/zh/docs/self-hosting/advanced/auth/providers/logto)
- [Casdoor Webhook 配置](/zh/docs/self-hosting/auth/providers/casdoor)
- [Logto Webhook 配置](/zh/docs/self-hosting/auth/providers/logto)
2. 然后在身份提供商的管理后台为用户配置邮箱
3. 用户数据通过 Webhook 同步到 LobeHub 后即可正常登录
## 相关阅读
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth/migration-internals'} title={'迁移技术原理'} />
<Card href={'/zh/docs/self-hosting/migration/v2/auth/migration-internals'} title={'迁移技术原理'} />
<Card href={'/zh/docs/self-hosting/advanced/auth'} title={'身份验证服务配置'} />
<Card href={'/zh/docs/self-hosting/auth'} title={'身份验证服务配置'} />
<Card href={'/zh/docs/self-hosting/environment-variables/auth'} title={'认证相关环境变量'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/legacy'} title={'旧版身份验证(NextAuth 和 Clerk'} />
<Card href={'/zh/docs/self-hosting/auth/legacy'} title={'旧版身份验证(NextAuth 和 Clerk'} />
</Cards>
@@ -60,11 +60,11 @@ LobeHub 2.0 only supports Better Auth authentication system. NextAuth and Clerk
### Migrating from NextAuth
See the [NextAuth Migration Guide](/docs/self-hosting/advanced/auth/nextauth-to-betterauth).
See the [NextAuth Migration Guide](/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth).
### Migrating from Clerk
See the [Clerk Migration Guide](/docs/self-hosting/advanced/auth/clerk-to-betterauth).
See the [Clerk Migration Guide](/docs/self-hosting/migration/v2/auth/clerk-to-betterauth).
## Database Mode Changes
@@ -58,11 +58,11 @@ LobeHub 2.0 仅支持 Better Auth 认证系统,不再支持 NextAuth 和 Clerk
### 从 NextAuth 迁移
请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/advanced/auth/nextauth-to-betterauth)。
请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/migration/v2/auth/nextauth-to-betterauth)。
### 从 Clerk 迁移
请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/advanced/auth/clerk-to-betterauth)。
请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/migration/v2/auth/clerk-to-betterauth)。
## 数据库模式变更
@@ -265,7 +265,7 @@ Generally, to fully run the LobeHub database version, you will need at least the
These services can be combined through self-hosting or online cloud services to meet various deployment needs. In this article, we provide a Docker Compose configuration entirely based on open-source self-hosted services, which can be used directly to start the LobeHub database version or modified to suit your requirements.
We use [RustFS](https://github.com/rustfs/rustfs) as the local S3 object storage service by default. To configure SSO authentication services, please refer to the [Authentication Services](/docs/self-hosting/advanced/auth) documentation.
We use [RustFS](https://github.com/rustfs/rustfs) as the local S3 object storage service by default. To configure SSO authentication services, please refer to the [Authentication Services](/docs/self-hosting/auth) documentation.
<Callout type="warning">
If your network topology is complex, please make sure these services can communicate properly
@@ -350,7 +350,7 @@ If `INTERNAL_APP_URL` is not set, it defaults to `APP_URL`.
## Configuring Authentication
To configure SSO authentication services (such as Casdoor, Logto, etc.), please refer to the [Authentication Services](/docs/self-hosting/advanced/auth) documentation.
To configure SSO authentication services (such as Casdoor, Logto, etc.), please refer to the [Authentication Services](/docs/self-hosting/auth) documentation.
[docker-pulls-link]: https://hub.docker.com/r/lobehub/lobehub
[docker-pulls-shield]: https://img.shields.io/docker/pulls/lobehub/lobehub?color=45cc11&labelColor=black&style=flat-square
@@ -262,7 +262,7 @@ mv .env.zh-CN.example .env
这些服务可以通过自建或者在线云服务组合搭配,以满足不同层次的部署需求。本文中,我们提供了完全基于开源自建服务的 Docker Compose 配置,你可以直接使用这份配置文件来启动 LobeHub,也可以对之进行修改以适应你的需求。
我们默认使用 [RustFS](https://github.com/rustfs/rustfs) 作为本地 S3 对象存储服务。如需配置 SSO 登录鉴权服务,请参考 [身份验证服务](/zh/docs/self-hosting/advanced/auth) 文档。
我们默认使用 [RustFS](https://github.com/rustfs/rustfs) 作为本地 S3 对象存储服务。如需配置 SSO 登录鉴权服务,请参考 [身份验证服务](/zh/docs/self-hosting/auth) 文档。
<Callout type="warning">
如果你的网络拓扑较为复杂,请先确保在你的网络环境中这些服务能够正常通讯。
@@ -346,7 +346,7 @@ environment:
## 配置身份验证
如需配置 SSO 登录鉴权服务(如 Casdoor、Logto 等),请参考 [身份验证服务](/zh/docs/self-hosting/advanced/auth) 文档。
如需配置 SSO 登录鉴权服务(如 Casdoor、Logto 等),请参考 [身份验证服务](/zh/docs/self-hosting/auth) 文档。
[docker-pulls-link]: https://hub.docker.com/r/lobehub/lobehub
[docker-pulls-shield]: https://img.shields.io/docker/pulls/lobehub/lobehub?color=45cc11&labelColor=black&style=flat-square
+1 -1
View File
@@ -61,7 +61,7 @@ You also need to configure the `JWKS_KEY` environment variable for signing and v
<GenerateJWKSKey />
<Callout type={'info'}>
For advanced features like SSO providers, magic link login, and email verification, see [Authentication Service](/docs/self-hosting/advanced/auth).
For advanced features like SSO providers, magic link login, and email verification, see [Authentication Service](/docs/self-hosting/auth).
</Callout>
## 2. Deploying the database on Dokploy
+1 -1
View File
@@ -62,7 +62,7 @@ S3_ENABLE_PATH_STYLE=
<GenerateJWKSKey />
<Callout type={'info'}>
如需 SSO 登录、魔法链接登录、邮箱验证等高级功能,请参阅 [身份验证服务](/zh/docs/self-hosting/advanced/auth)。
如需 SSO 登录、魔法链接登录、邮箱验证等高级功能,请参阅 [身份验证服务](/zh/docs/self-hosting/auth)。
</Callout>
## 二、在 Dokploy 上部署数据库
+1 -1
View File
@@ -112,7 +112,7 @@ The server-side database needs to be paired with a user authentication service t
With these variables, users can register and login with email and password.
<Callout type={'info'}>
For advanced features like SSO providers, magic link login, and email verification, see [Authentication Service](/docs/self-hosting/advanced/auth).
For advanced features like SSO providers, magic link login, and email verification, see [Authentication Service](/docs/self-hosting/auth).
</Callout>
</Steps>
+1 -1
View File
@@ -112,7 +112,7 @@ tags:
配置这些变量后,用户即可使用邮箱和密码注册登录。
<Callout type={'info'}>
如需 SSO 登录、魔法链接登录、邮箱验证等高级功能,请参阅 [身份验证服务](/zh/docs/self-hosting/advanced/auth)。
如需 SSO 登录、魔法链接登录、邮箱验证等高级功能,请参阅 [身份验证服务](/zh/docs/self-hosting/auth)。
</Callout>
</Steps>
+1 -1
View File
@@ -66,7 +66,7 @@ Here is the process for deploying the LobeHub server database version on Zeabur:
Fill in those variables into your LobeHub service on Zeabur, here is a more detailed guide for [editing environment variables on Zeabur](https://zeabur.com/docs/deploy/variables).
For detailed configuration of Logto, refer to [this document](/docs/self-hosting/advanced/auth/providers/logto).
For detailed configuration of Logto, refer to [this document](/docs/self-hosting/auth/providers/logto).
### Access your LobeHub Instance
+1 -1
View File
@@ -57,7 +57,7 @@ tags:
使用你刚绑定的域名来访问你的 Logto 控制台,创建一个新项目以获得对应的客户端 ID 与密钥,将它们填入你的 LobeHub 服务的变量中。关于如何填入变量,可以参照 [Zeabur 的官方文档](https://zeabur.com/docs/deploy/variables)。
Logto 的详细配置可以参考[这篇文档](/zh/docs/self-hosting/advanced/auth/providers/logto)。
Logto 的详细配置可以参考[这篇文档](/zh/docs/self-hosting/auth/providers/logto)。
### 访问你的 LobeHub
+2
View File
@@ -32,6 +32,8 @@
"chatList.longMessageDetail": "View Details",
"clearCurrentMessages": "Clear current session messages",
"compressedHistory": "Compressed History",
"compression.cancel": "Uncompress",
"compression.cancelConfirm": "Are you sure you want to uncompress? This will restore the original messages.",
"compression.history": "History",
"compression.summary": "Summary",
"confirmClearCurrentMessages": "You are about to clear the current session messages. Once cleared, they cannot be retrieved. Please confirm your action.",
+9
View File
@@ -490,6 +490,15 @@
"user.noForkedAgentGroups": "No forked Agent Groups yet",
"user.noForkedAgents": "No forked Agents yet",
"user.publishedAgents": "Created Agents",
"user.publishedGroups": "Created Groups",
"user.searchPlaceholder": "Search by name or description...",
"user.statusFilter.all": "All",
"user.statusFilter.archived": "Archived",
"user.statusFilter.deprecated": "Deprecated",
"user.statusFilter.favorite": "Favorite",
"user.statusFilter.forked": "Forked",
"user.statusFilter.published": "Published",
"user.statusFilter.unpublished": "Under Review",
"user.tabs.favorites": "Favorites",
"user.tabs.forkedAgents": "Forked",
"user.tabs.publishedAgents": "Created",
+3
View File
@@ -268,6 +268,9 @@
"marketPublish.upload.button": "Publish New Version",
"marketPublish.upload.tooltip": "Publish a new version to Agent Community",
"marketPublish.uploadGroup.tooltip": "Publish a new version to Group Community",
"marketPublish.validation.confirmPublish": "Are you sure you want to publish to the market?",
"marketPublish.validation.emptyName": "Cannot publish: Name is required",
"marketPublish.validation.emptySystemRole": "Cannot publish: System Role is required",
"memory.enabled.desc": "Allow LobeHub to extract preferences and info from conversations and use them later. You can view, edit, or clear memory anytime.",
"memory.enabled.title": "Enable Memory",
"memory.title": "Memory Settings",
+2
View File
@@ -32,6 +32,8 @@
"chatList.longMessageDetail": "查看详情",
"clearCurrentMessages": "清空当前会话消息",
"compressedHistory": "压缩历史",
"compression.cancel": "取消压缩",
"compression.cancelConfirm": "确定要取消压缩吗?这将恢复原始消息。",
"compression.history": "历史记录",
"compression.summary": "摘要",
"confirmClearCurrentMessages": "确认清空当前会话消息吗?清空后无法恢复",
+9
View File
@@ -490,6 +490,15 @@
"user.noForkedAgentGroups": "尚无已派生的代理组",
"user.noForkedAgents": "尚无已派生的代理",
"user.publishedAgents": "创作的助理",
"user.publishedGroups": "创作的群组",
"user.searchPlaceholder": "搜索名称或描述...",
"user.statusFilter.all": "全部",
"user.statusFilter.archived": "已归档",
"user.statusFilter.deprecated": "已废弃",
"user.statusFilter.favorite": "已收藏",
"user.statusFilter.forked": "已派生",
"user.statusFilter.published": "已发布",
"user.statusFilter.unpublished": "审核中",
"user.tabs.favorites": "收藏",
"user.tabs.forkedAgents": "已派生",
"user.tabs.publishedAgents": "创作",
+3
View File
@@ -268,6 +268,9 @@
"marketPublish.upload.button": "发布新版本",
"marketPublish.upload.tooltip": "发布新版本到助理社区",
"marketPublish.uploadGroup.tooltip": "向群组社区发布新版本",
"marketPublish.validation.confirmPublish": "确定要发布到市场吗?",
"marketPublish.validation.emptyName": "无法发布:名称不能为空",
"marketPublish.validation.emptySystemRole": "无法发布:系统角色不能为空",
"memory.enabled.desc": "允许 LobeHub 从对话中提取偏好和信息,并在之后使用。您可以随时查看、编辑或清除记忆内容。",
"memory.enabled.title": "启用记忆功能",
"memory.title": "记忆设置",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.1.6",
"version": "2.1.17",
"description": "LobeHub - an open-source,comprehensive AI Agent 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",
@@ -10,6 +10,26 @@ export const systemPrompt = `You have access to the Notebook tool for creating a
Note: The list of existing documents is automatically provided in the context, so you don't need to query for it.
</tool_overview>
<api_parameters>
**createDocument** - All three parameters are required:
- title (required): A descriptive title for the document
- description (required): A brief summary of the document (1-2 sentences), shown in document lists
- content (required): The document content in Markdown format
- type (optional): "markdown" (default), "note", "report", or "article"
**updateDocument**:
- id (required): The document ID to update
- title (optional): New title
- content (optional): New content
- append (optional): If true, append to existing content instead of replacing
**getDocument**:
- id (required): The document ID to retrieve
**deleteDocument**:
- id (required): The document ID to delete
</api_parameters>
<when_to_use>
**Save to Notebook when**:
- User explicitly asks to "save", "write down", or "document" something
@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS "messages_message_group_id_idx" ON "messages" USING btree ("message_group_id");
File diff suppressed because it is too large Load Diff
@@ -532,7 +532,14 @@
"when": 1769362978088,
"tag": "0075_add_user_memory_persona",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1770179814971,
"tag": "0076_add_message_group_index",
"breakpoints": true
}
],
"version": "6"
}
}
+1
View File
@@ -149,6 +149,7 @@ export const messages = pgTable(
index('messages_thread_id_idx').on(table.threadId),
index('messages_agent_id_idx').on(table.agentId),
index('messages_group_id_idx').on(table.groupId),
index('messages_message_group_id_idx').on(table.messageGroupId),
],
);
@@ -1,6 +1,35 @@
import { AIChatModelCard } from '../../../types/aiModel';
export const moonshotChatModels: AIChatModelCard[] = [
{
abilities: {
functionCall: true,
reasoning: true,
search: true,
structuredOutput: true,
vision: true,
},
contextWindowTokens: 262_144,
description:
'Kimi K2.5 is Kimi\'s most versatile model to date, featuring a native multimodal architecture that supports both vision and text inputs, "thinking" and "non-thinking" modes, and both conversational and agent tasks.',
displayName: 'Kimi K2.5',
enabled: true,
id: 'kimi-k2.5',
maxOutput: 32_768,
pricing: {
units: [
{ name: 'textInput', rate: 0.6, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textInput_cacheRead', rate: 0.1, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2026-01-27',
settings: {
extendParams: ['enableReasoning'],
searchImpl: 'params',
},
type: 'chat',
},
{
abilities: {
functionCall: true,
@@ -1,6 +1,5 @@
import { type ModelProviderCard } from '@/types/llm';
// ref: https://platform.moonshot.cn/docs/intro#model-list
const Moonshot: ModelProviderCard = {
chatModels: [],
checkModel: 'kimi-latest',
@@ -8,12 +7,12 @@ const Moonshot: ModelProviderCard = {
'Moonshot, from Moonshot AI (Beijing Moonshot Technology), offers multiple NLP models for use cases like content creation, research, recommendations, and medical analysis, with strong long-context and complex generation support.',
id: 'moonshot',
modelList: { showModelFetcher: true },
modelsUrl: 'https://platform.moonshot.cn/docs/intro',
modelsUrl: 'https://platform.moonshot.ai/docs/pricing/chat',
name: 'Moonshot',
settings: {
disableBrowserRequest: true, // CORS error
proxyUrl: {
placeholder: 'https://api.moonshot.cn/v1',
placeholder: 'https://api.moonshot.ai/v1',
},
responseAnimation: {
speed: 2,
@@ -22,7 +21,7 @@ const Moonshot: ModelProviderCard = {
sdkType: 'openai',
showModelFetcher: true,
},
url: 'https://www.moonshot.cn',
url: 'https://www.moonshot.ai/',
};
export default Moonshot;
@@ -6,7 +6,6 @@ import { LobeDeepSeekAI } from '../../providers/deepseek';
import { LobeFalAI } from '../../providers/fal';
import { LobeGoogleAI } from '../../providers/google';
import { LobeMinimaxAI } from '../../providers/minimax';
import { LobeMoonshotAI } from '../../providers/moonshot';
import { LobeOpenAI } from '../../providers/openai';
import { LobeQwenAI } from '../../providers/qwen';
import { LobeVertexAI } from '../../providers/vertexai';
@@ -21,7 +20,6 @@ export const baseRuntimeMap = {
fal: LobeFalAI,
google: LobeGoogleAI,
minimax: LobeMinimaxAI,
moonshot: LobeMoonshotAI,
openai: LobeOpenAI,
qwen: LobeQwenAI,
vertexai: LobeVertexAI,
@@ -452,6 +452,97 @@ describe('createRouterRuntime', () => {
});
describe('router matching', () => {
describe('baseURLPattern matching', () => {
it('should match router by baseURLPattern (RegExp)', async () => {
const mockChatOpenAI = vi.fn().mockResolvedValue('openai-response');
const mockChatAnthropic = vi.fn().mockResolvedValue('anthropic-response');
class OpenAIRuntime implements LobeRuntimeAI {
chat = mockChatOpenAI;
}
class AnthropicRuntime implements LobeRuntimeAI {
chat = mockChatAnthropic;
}
const Runtime = createRouterRuntime({
id: 'test-runtime',
routers: [
{
apiType: 'anthropic',
baseURLPattern: /\/anthropic\/?$/,
options: { apiKey: 'anthropic-key' },
runtime: AnthropicRuntime as any,
},
{
apiType: 'openai',
options: { apiKey: 'openai-key' },
runtime: OpenAIRuntime as any,
},
],
});
const runtime = new Runtime({
apiKey: 'test',
baseURL: 'https://api.example.com/anthropic',
});
const result = await runtime.chat({
model: 'test-model',
messages: [],
temperature: 0.7,
});
expect(result).toBe('anthropic-response');
expect(mockChatAnthropic).toHaveBeenCalled();
expect(mockChatOpenAI).not.toHaveBeenCalled();
});
it('should prioritize baseURLPattern over models matching', async () => {
const mockChatOpenAI = vi.fn().mockResolvedValue('openai-response');
const mockChatAnthropic = vi.fn().mockResolvedValue('anthropic-response');
class OpenAIRuntime implements LobeRuntimeAI {
chat = mockChatOpenAI;
}
class AnthropicRuntime implements LobeRuntimeAI {
chat = mockChatAnthropic;
}
const Runtime = createRouterRuntime({
id: 'test-runtime',
routers: [
{
apiType: 'anthropic',
baseURLPattern: /\/anthropic\/?$/,
options: { apiKey: 'anthropic-key' },
runtime: AnthropicRuntime as any,
models: ['claude-3'],
},
{
apiType: 'openai',
options: { apiKey: 'openai-key' },
runtime: OpenAIRuntime as any,
models: ['gpt-4', 'test-model'], // includes test-model
},
],
});
// Even though 'test-model' matches OpenAI router, baseURLPattern should win
const runtime = new Runtime({
apiKey: 'test',
baseURL: 'https://api.example.com/anthropic',
});
const result = await runtime.chat({
model: 'test-model',
messages: [],
temperature: 0.7,
});
expect(result).toBe('anthropic-response');
});
});
it('should fallback to last router when model does not match any', async () => {
const mockChatFirst = vi.fn().mockResolvedValue('first-response');
const mockChatLast = vi.fn().mockResolvedValue('last-response');
@@ -58,10 +58,11 @@ interface RouterOptionItem extends ProviderIniOptions {
type RouterOptions = RouterOptionItem | RouterOptionItem[];
export type RuntimeClass = typeof LobeOpenAI;
export type RuntimeClass = new (options?: any) => LobeRuntimeAI;
interface RouterInstance {
apiType: keyof typeof baseRuntimeMap;
baseURLPattern?: RegExp;
models?: string[];
options: RouterOptions;
runtime?: RuntimeClass;
@@ -177,14 +178,25 @@ export const createRouterRuntime = ({
private async resolveMatchedRouter(model: string): Promise<RouterInstance> {
const resolvedRouters = await this.resolveRouters(model);
return (
resolvedRouters.find((router) => {
if (router.models && router.models.length > 0) {
return router.models.includes(model);
}
return false;
}) ?? resolvedRouters.at(-1)!
);
const baseURL = this._options.baseURL;
// Priority 1: Match by baseURLPattern (RegExp only)
if (baseURL) {
const baseURLMatch = resolvedRouters.find((router) => router.baseURLPattern?.test(baseURL));
if (baseURLMatch) return baseURLMatch;
}
// Priority 2: Match by models
const modelMatch = resolvedRouters.find((router) => {
if (router.models && router.models.length > 0) {
return router.models.includes(model);
}
return false;
});
if (modelMatch) return modelMatch;
// Fallback: Use the last router
return resolvedRouters.at(-1)!;
}
private normalizeRouterOptions(router: RouterInstance): RouterOptionItem[] {
@@ -6,5 +6,5 @@ export interface RuntimeItem {
runtime: LobeRuntimeAI;
}
export type { UniformRuntime } from './createRuntime';
export type { CreateRouterRuntimeOptions, UniformRuntime } from './createRuntime';
export { createRouterRuntime } from './createRuntime';
@@ -1,7 +1,7 @@
import type Anthropic from '@anthropic-ai/sdk';
import debug from 'debug';
import { buildAnthropicMessages, buildAnthropicTools } from '../../core/contextBuilders/anthropic';
import { buildAnthropicMessages, buildAnthropicTools } from '../contextBuilders/anthropic';
import { GenerateObjectOptions, GenerateObjectPayload } from '../../types';
const log = debug('lobe-model-runtime:anthropic:generate-object');
@@ -0,0 +1,626 @@
import Anthropic, { ClientOptions } from '@anthropic-ai/sdk';
import type { Stream } from '@anthropic-ai/sdk/streaming';
import type { ChatModelCard } from '@lobechat/types';
import debug from 'debug';
import { hasTemperatureTopPConflict } from '../../const/models';
import {
buildAnthropicMessages,
buildAnthropicTools,
buildSearchTool,
} from '../contextBuilders/anthropic';
import { resolveParameters } from '../parameterResolver';
import {
ChatCompletionErrorPayload,
ChatMethodOptions,
ChatStreamCallbacks,
ChatStreamPayload,
GenerateObjectOptions,
GenerateObjectPayload,
} from '../../types';
import { AgentRuntimeErrorType, ILobeAgentRuntimeErrorType } from '../../types/error';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { desensitizeUrl } from '../../utils/desensitizeUrl';
import { getModelPricing } from '../../utils/getModelPricing';
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
import { StreamingResponse } from '../../utils/response';
import { LobeRuntimeAI } from '../BaseAI';
import { AnthropicStream } from '../streams';
import type { ComputeChatCostOptions } from '../usageConverters/utils/computeChatCost';
import { createAnthropicGenerateObject } from './generateObject';
import { handleAnthropicError } from './handleAnthropicError';
import { resolveCacheTTL } from './resolveCacheTTL';
import { resolveMaxTokens } from './resolveMaxTokens';
type ConstructorOptions<T extends Record<string, any> = any> = ClientOptions & T;
type AnthropicTools = Anthropic.Tool | Anthropic.WebSearchTool20250305;
export const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
export interface CustomClientOptions<T extends Record<string, any> = any> {
createClient?: (options: ConstructorOptions<T>) => Anthropic;
}
export interface AnthropicCompatibleFactoryOptions<T extends Record<string, any> = any> {
apiKey?: string;
baseURL?: string;
chatCompletion?: {
/**
* Build an Anthropic Messages API payload from ChatStreamPayload.
* This is required because Anthropic-compatible providers have different
* parameter constraints than OpenAI-compatible ones.
*/
getPricingOptions?: (
payload: ChatStreamPayload,
anthropicPayload: Anthropic.MessageCreateParams,
) => Promise<ComputeChatCostOptions | undefined> | ComputeChatCostOptions | undefined;
handleError?: (
error: any,
options: ConstructorOptions<T>,
) => Omit<ChatCompletionErrorPayload, 'provider'> | undefined;
handlePayload?: (
payload: ChatStreamPayload,
options: ConstructorOptions<T>,
) => Promise<Anthropic.MessageCreateParams> | Anthropic.MessageCreateParams;
handleStream?: (
stream: Stream<Anthropic.MessageStreamEvent> | ReadableStream,
{
callbacks,
inputStartAt,
payload,
}: { callbacks?: ChatStreamCallbacks; inputStartAt?: number; payload?: ChatStreamPayload },
) => ReadableStream;
};
constructorOptions?: ConstructorOptions<T>;
customClient?: CustomClientOptions<T>;
debug?: {
chatCompletion?: () => boolean;
};
errorType?: {
bizError: ILobeAgentRuntimeErrorType;
invalidAPIKey: ILobeAgentRuntimeErrorType;
};
generateObject?: (
client: Anthropic,
payload: GenerateObjectPayload,
options?: GenerateObjectOptions,
) => Promise<any>;
models?: (params: {
apiKey?: string;
baseURL: string;
client: Anthropic;
}) => Promise<ChatModelCard[]>;
provider: string;
}
export interface AnthropicCompatibleParamsInput<T extends Record<string, any> = any>
extends Omit<
AnthropicCompatibleFactoryOptions<T>,
'chatCompletion' | 'customClient' | 'generateObject' | 'models'
> {
chatCompletion?: Partial<NonNullable<AnthropicCompatibleFactoryOptions<T>['chatCompletion']>>;
customClient?: CustomClientOptions<T>;
generateObject?: AnthropicCompatibleFactoryOptions<T>['generateObject'];
models?: AnthropicCompatibleFactoryOptions<T>['models'];
}
/**
* Build the default Anthropic Messages payload with LobeChat normalization.
*/
export const buildDefaultAnthropicPayload = async (
payload: ChatStreamPayload,
): Promise<Anthropic.MessageCreateParams> => {
const {
messages,
model,
max_tokens,
temperature,
top_p,
tools,
thinking,
enabledContextCaching = true,
enabledSearch,
} = payload;
const { anthropic: anthropicModels } = await import('model-bank');
const resolvedMaxTokens = await resolveMaxTokens({
max_tokens,
model,
providerModels: anthropicModels,
thinking,
});
const systemMessage = messages.find((message) => message.role === 'system');
const userMessages = messages.filter((message) => message.role !== 'system');
const systemPrompts = systemMessage?.content
? ([
{
cache_control: enabledContextCaching ? { type: 'ephemeral' } : undefined,
text: systemMessage.content as string,
type: 'text',
},
] as Anthropic.TextBlockParam[])
: undefined;
const postMessages = await buildAnthropicMessages(userMessages, { enabledContextCaching });
let postTools = buildAnthropicTools(tools, { enabledContextCaching }) as
| AnthropicTools[]
| undefined;
if (enabledSearch) {
const webSearchTool = buildSearchTool();
postTools = postTools?.length ? [...postTools, webSearchTool] : [webSearchTool];
}
if (!!thinking && thinking.type === 'enabled') {
return {
max_tokens: resolvedMaxTokens,
messages: postMessages,
model,
system: systemPrompts,
thinking: {
...thinking,
budget_tokens: thinking?.budget_tokens
? Math.min(thinking.budget_tokens, resolvedMaxTokens - 1)
: 1024,
},
tools: postTools as Anthropic.MessageCreateParams['tools'],
} satisfies Anthropic.MessageCreateParams;
}
const hasConflict = hasTemperatureTopPConflict(model);
const resolvedParams = resolveParameters(
{ temperature, top_p },
{ hasConflict, normalizeTemperature: true, preferTemperature: true },
);
return {
max_tokens: resolvedMaxTokens,
messages: postMessages,
model,
system: systemPrompts,
temperature: resolvedParams.temperature,
tools: postTools as Anthropic.MessageCreateParams['tools'],
top_p: resolvedParams.top_p,
} satisfies Anthropic.MessageCreateParams;
};
/**
* Resolve cache-aware pricing options for usage cost calculation.
*/
export const resolveDefaultAnthropicPricingOptions = (
requestPayload: ChatStreamPayload,
anthropicPayload: Anthropic.MessageCreateParams,
): ComputeChatCostOptions | undefined => {
const cacheTTL = resolveCacheTTL(requestPayload, {
messages: anthropicPayload.messages,
system: anthropicPayload.system,
});
if (!cacheTTL) return undefined;
return { lookupParams: { ttl: cacheTTL } };
};
/**
* Create Anthropic SDK client with optional beta headers.
*/
export const createDefaultAnthropicClient = <T extends Record<string, any> = any>(
options: ConstructorOptions<T>,
) => {
const betaHeaders = process.env.ANTHROPIC_BETA_HEADERS;
const defaultHeaders = {
...options.defaultHeaders,
...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}),
};
return new Anthropic({ ...options, defaultHeaders });
};
/**
* Default Anthropic error handler with desensitized endpoint.
*/
export const handleDefaultAnthropicError = <T extends Record<string, any> = any>(
error: any,
options: ConstructorOptions<T>,
): Omit<ChatCompletionErrorPayload, 'provider'> => {
const baseURL =
typeof options.baseURL === 'string' && options.baseURL
? options.baseURL
: DEFAULT_ANTHROPIC_BASE_URL;
const desensitizedEndpoint =
baseURL !== DEFAULT_ANTHROPIC_BASE_URL ? desensitizeUrl(baseURL) : baseURL;
if ('status' in (error as any)) {
switch ((error as Response).status) {
case 401: {
return {
endpoint: desensitizedEndpoint,
error: error as any,
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
};
}
case 403: {
return {
endpoint: desensitizedEndpoint,
error: error as any,
errorType: AgentRuntimeErrorType.LocationNotSupportError,
};
}
default: {
break;
}
}
}
const { errorResult } = handleAnthropicError(error);
return {
endpoint: desensitizedEndpoint,
error: errorResult,
errorType: AgentRuntimeErrorType.ProviderBizError,
};
};
/**
* Default Anthropic models list fetcher.
*/
export const createDefaultAnthropicModels = async ({
apiKey,
baseURL,
}: {
apiKey?: string;
baseURL: string;
client?: Anthropic;
}): Promise<ChatModelCard[]> => {
if (!apiKey) {
throw new Error('Missing Anthropic API key for model listing');
}
const response = await fetch(`${baseURL}/v1/models`, {
headers: {
'anthropic-version': '2023-06-01',
'x-api-key': `${apiKey}`,
},
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch Anthropic models: ${response.status} ${response.statusText}`);
}
const json = await response.json();
const modelList = (json['data'] || []) as Array<{ created_at: string; display_name: string; id: string }>;
const standardModelList = modelList.map((model) => ({
created: model.created_at,
displayName: model.display_name,
id: model.id,
}));
return processModelList(standardModelList, MODEL_LIST_CONFIGS.anthropic, 'anthropic');
};
/**
* Build provider params by merging overrides with Anthropic defaults.
*/
export const createAnthropicCompatibleParams = <T extends Record<string, any> = any>(
options: AnthropicCompatibleParamsInput<T>,
): AnthropicCompatibleFactoryOptions<T> => {
const {
baseURL = DEFAULT_ANTHROPIC_BASE_URL,
chatCompletion,
customClient,
generateObject,
models,
...rest
} = options;
return {
...rest,
baseURL,
chatCompletion: {
getPricingOptions: resolveDefaultAnthropicPricingOptions,
handleError: handleDefaultAnthropicError,
handlePayload: buildDefaultAnthropicPayload,
...chatCompletion,
},
customClient: customClient ?? { createClient: createDefaultAnthropicClient },
generateObject: generateObject ?? createAnthropicGenerateObject,
models: models ?? createDefaultAnthropicModels,
} as AnthropicCompatibleFactoryOptions<T>;
};
export const createAnthropicCompatibleRuntime = <T extends Record<string, any> = any>({
provider,
baseURL: DEFAULT_BASE_URL = DEFAULT_ANTHROPIC_BASE_URL,
apiKey: DEFAULT_API_KEY,
errorType,
debug: debugParams,
constructorOptions,
chatCompletion,
customClient,
models,
generateObject,
}: AnthropicCompatibleFactoryOptions<T>) => {
const ErrorType = {
bizError: errorType?.bizError || AgentRuntimeErrorType.ProviderBizError,
invalidAPIKey: errorType?.invalidAPIKey || AgentRuntimeErrorType.InvalidProviderAPIKey,
};
return class LobeAnthropicCompatibleAI implements LobeRuntimeAI {
client!: Anthropic;
private id: string;
private logPrefix: string;
baseURL!: string;
protected _options: ConstructorOptions<T>;
constructor(options: ClientOptions & Record<string, any> = {}) {
const resolvedOptions = {
...options,
apiKey: options.apiKey?.trim() || DEFAULT_API_KEY,
baseURL: options.baseURL?.trim() || DEFAULT_BASE_URL,
};
const { apiKey, baseURL = DEFAULT_BASE_URL, ...rest } = resolvedOptions;
this._options = resolvedOptions as ConstructorOptions<T>;
if (!apiKey) throw AgentRuntimeError.createError(ErrorType.invalidAPIKey);
const initOptions = { apiKey, baseURL, ...constructorOptions, ...rest };
if (customClient?.createClient) {
this.client = customClient.createClient(initOptions as ConstructorOptions<T>);
} else {
this.client = new Anthropic(initOptions as ConstructorOptions<T>);
}
this.baseURL = baseURL || this.client.baseURL;
this.id = options.id || provider;
this.logPrefix = `lobe-model-runtime:${this.id}`;
}
async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
try {
if (!chatCompletion?.handlePayload) {
throw new Error('Anthropic-compatible runtime requires chatCompletion.handlePayload');
}
const log = debug(`${this.logPrefix}:chat`);
const inputStartAt = Date.now();
log('chat called with model: %s, stream: %s', payload.model, payload.stream ?? true);
const postPayload = await chatCompletion.handlePayload(payload, this._options);
const shouldStream = postPayload.stream ?? payload.stream ?? true;
const finalPayload = { ...postPayload, stream: shouldStream };
if (debugParams?.chatCompletion?.()) {
console.log('[requestPayload]');
console.log(JSON.stringify(finalPayload), '\n');
}
const response = await this.client.messages.create(
{
...finalPayload,
metadata: options?.user ? { user_id: options.user } : undefined,
},
{
headers: options?.requestHeaders,
signal: options?.signal,
},
);
const pricing = await getModelPricing(payload.model, this.id);
const pricingOptions = await chatCompletion?.getPricingOptions?.(payload, postPayload);
const streamOptions = {
callbacks: options?.callback,
payload: {
model: payload.model,
pricing,
pricingOptions,
provider: this.id,
},
};
if (shouldStream) {
const streamResponse = response as Stream<Anthropic.MessageStreamEvent>;
const [prod, useForDebug] = streamResponse.tee();
if (debugParams?.chatCompletion?.()) {
const useForDebugStream =
useForDebug instanceof ReadableStream ? useForDebug : useForDebug.toReadableStream();
debugStream(useForDebugStream).catch(console.error);
}
return StreamingResponse(
chatCompletion?.handleStream
? chatCompletion.handleStream(prod, {
callbacks: streamOptions.callbacks,
inputStartAt,
payload,
})
: AnthropicStream(prod, { ...streamOptions, inputStartAt }),
{
headers: options?.headers,
},
);
}
if (payload.responseMode === 'json') {
return Response.json(response);
}
const stream = new ReadableStream<Anthropic.MessageStreamEvent>({
start(controller) {
const message = response as Anthropic.Message;
controller.enqueue({
message,
type: 'message_start',
} satisfies Anthropic.MessageStreamEvent);
message.content?.forEach((block, index) => {
if (block.type === 'tool_use' || block.type === 'server_tool_use') {
controller.enqueue({
content_block: block,
index,
type: 'content_block_start',
} satisfies Anthropic.MessageStreamEvent);
controller.enqueue({
delta: { partial_json: JSON.stringify(block.input ?? {}), type: 'input_json_delta' },
index,
type: 'content_block_delta',
} satisfies Anthropic.MessageStreamEvent);
}
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
controller.enqueue({
content_block: block,
index,
type: 'content_block_start',
} satisfies Anthropic.MessageStreamEvent);
}
if (block.type === 'text') {
controller.enqueue({
delta: { text: block.text, type: 'text_delta' },
index,
type: 'content_block_delta',
} satisfies Anthropic.MessageStreamEvent);
}
});
controller.enqueue({
delta: {
stop_reason: message.stop_reason,
stop_sequence: message.stop_sequence ?? null,
},
type: 'message_delta',
usage: {
cache_creation_input_tokens: message.usage?.cache_creation_input_tokens ?? null,
cache_read_input_tokens: message.usage?.cache_read_input_tokens ?? null,
input_tokens: message.usage?.input_tokens ?? null,
output_tokens: 0,
server_tool_use: message.usage?.server_tool_use ?? null,
},
} satisfies Anthropic.MessageStreamEvent);
controller.enqueue({ type: 'message_stop' } satisfies Anthropic.MessageStreamEvent);
controller.close();
},
});
return StreamingResponse(
chatCompletion?.handleStream
? chatCompletion.handleStream(stream, {
callbacks: streamOptions.callbacks,
inputStartAt,
payload,
})
: AnthropicStream(stream, {
...streamOptions,
enableStreaming: false,
inputStartAt,
}),
{
headers: options?.headers,
},
);
} catch (error) {
throw this.handleError(error);
}
}
async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
if (!generateObject) {
throw new Error('GenerateObject is not supported by this provider');
}
try {
return await generateObject(this.client, payload, options);
} catch (error) {
throw this.handleError(error);
}
}
async models() {
if (!models) return [];
return models({
apiKey: this._options.apiKey ?? undefined,
baseURL: this.baseURL,
client: this.client,
});
}
protected handleError(error: any): ChatCompletionErrorPayload {
const log = debug(`${this.logPrefix}:error`);
log('handling error: %O', error);
let desensitizedEndpoint = this.baseURL;
if (this.baseURL !== DEFAULT_BASE_URL) {
desensitizedEndpoint = desensitizeUrl(this.baseURL);
}
if (chatCompletion?.handleError) {
const errorResult = chatCompletion.handleError(error, this._options);
if (errorResult)
return AgentRuntimeError.chat({
...errorResult,
provider: this.id,
} as ChatCompletionErrorPayload);
}
if ('status' in (error as any)) {
switch ((error as Response).status) {
case 401: {
return AgentRuntimeError.chat({
endpoint: desensitizedEndpoint,
error: error as any,
errorType: ErrorType.invalidAPIKey,
provider: this.id,
});
}
case 403: {
return AgentRuntimeError.chat({
endpoint: desensitizedEndpoint,
error: error as any,
errorType: AgentRuntimeErrorType.LocationNotSupportError,
provider: this.id,
});
}
default: {
break;
}
}
}
const errorResult = (() => {
if (error?.error) {
const innerError = error.error;
if ('error' in innerError) {
return innerError.error;
}
return innerError;
}
return { headers: error?.headers, stack: error?.stack, status: error?.status };
})();
return AgentRuntimeError.chat({
endpoint: desensitizedEndpoint,
error: errorResult,
errorType: ErrorType.bizError,
provider: this.id,
});
}
};
};
@@ -1,6 +1,7 @@
// @vitest-environment node
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDefaultAnthropicPayload } from '../../core/anthropicCompatibleFactory';
import * as anthropicHelpers from '../../core/contextBuilders/anthropic';
import { ChatCompletionTool, ChatStreamPayload } from '../../types/chat';
import * as debugStreamModule from '../../utils/debugStream';
@@ -14,13 +15,13 @@ const invalidErrorType = 'InvalidProviderAPIKey';
// Mock the console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
let instance: LobeAnthropicAI;
let instance: InstanceType<typeof LobeAnthropicAI>;
beforeEach(() => {
instance = new LobeAnthropicAI({ apiKey: 'test' });
// 使用 vi.spyOn 来模拟 chat.completions.create 方法
vi.spyOn(instance['client'].messages, 'create').mockReturnValue(new ReadableStream() as any);
// Use vi.spyOn to mock the Anthropic messages.create call.
vi.spyOn(instance['client'].messages, 'create').mockResolvedValue(new ReadableStream() as any);
});
afterEach(() => {
@@ -87,7 +88,7 @@ describe('LobeAnthropicAI', () => {
// Assert
expect(instance['client'].messages.create).toHaveBeenCalledWith(
{
expect.objectContaining({
max_tokens: 4096,
messages: [
{
@@ -99,8 +100,8 @@ describe('LobeAnthropicAI', () => {
stream: true,
temperature: 0,
top_p: 1,
},
{},
}),
expect.objectContaining({}),
);
expect(result).toBeInstanceOf(Response);
});
@@ -128,7 +129,7 @@ describe('LobeAnthropicAI', () => {
// Assert
expect(instance['client'].messages.create).toHaveBeenCalledWith(
{
expect.objectContaining({
max_tokens: 64000,
messages: [
{
@@ -149,10 +150,8 @@ describe('LobeAnthropicAI', () => {
metadata: undefined,
tools: undefined,
top_p: undefined,
},
{
signal: undefined,
},
}),
expect.objectContaining({ signal: undefined }),
);
expect(result).toBeInstanceOf(Response);
});
@@ -179,7 +178,7 @@ describe('LobeAnthropicAI', () => {
// Assert
expect(instance['client'].messages.create).toHaveBeenCalledWith(
{
expect.objectContaining({
max_tokens: 2048,
messages: [
{
@@ -191,8 +190,8 @@ describe('LobeAnthropicAI', () => {
stream: true,
temperature: 0.25,
top_p: 1,
},
{},
}),
expect.objectContaining({}),
);
expect(result).toBeInstanceOf(Response);
});
@@ -221,7 +220,7 @@ describe('LobeAnthropicAI', () => {
// Assert
expect(instance['client'].messages.create).toHaveBeenCalledWith(
{
expect.objectContaining({
max_tokens: 2048,
messages: [
{
@@ -233,8 +232,8 @@ describe('LobeAnthropicAI', () => {
stream: true,
temperature: 0.25,
top_p: 1,
},
{},
}),
expect.objectContaining({}),
);
expect(result).toBeInstanceOf(Response);
});
@@ -321,20 +320,19 @@ describe('LobeAnthropicAI', () => {
enabledSearch: true,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(anthropicHelpers.buildAnthropicTools).toHaveBeenCalledWith(tools, {
enabledContextCaching: true,
});
// Should include both the converted tools and web search tool
expect(result.tools).toEqual([
...mockAnthropicTools,
{
name: 'web_search',
type: 'web_search_20250305',
},
]);
expect(result.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'tool1' }),
expect.objectContaining({ name: 'web_search', type: 'web_search_20250305' }),
]),
);
});
it('should build payload with web search enabled but no other tools', async () => {
@@ -347,19 +345,18 @@ describe('LobeAnthropicAI', () => {
enabledSearch: true,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(anthropicHelpers.buildAnthropicTools).toHaveBeenCalledWith(undefined, {
enabledContextCaching: true,
});
// Should only include web search tool
expect(result.tools).toEqual([
{
name: 'web_search',
type: 'web_search_20250305',
},
]);
expect(result.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'web_search', type: 'web_search_20250305' }),
]),
);
});
});
@@ -520,7 +517,7 @@ describe('LobeAnthropicAI', () => {
// Assert
expect(instance['client'].messages.create).toHaveBeenCalledWith(
expect.objectContaining({}),
{ signal: controller.signal },
expect.objectContaining({ signal: controller.signal }),
);
});
@@ -576,7 +573,7 @@ describe('LobeAnthropicAI', () => {
});
});
describe('buildAnthropicPayload', () => {
describe('buildDefaultAnthropicPayload', () => {
it('should correctly build payload with user messages only', async () => {
const payload: ChatStreamPayload = {
messages: [{ content: 'Hello', role: 'user' }],
@@ -584,19 +581,21 @@ describe('LobeAnthropicAI', () => {
temperature: 0.5,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Hello', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
temperature: 0.25,
});
expect(result).toEqual(
expect.objectContaining({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Hello', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
temperature: 0.25,
}),
);
});
it('should correctly build payload with system message', async () => {
@@ -609,26 +608,28 @@ describe('LobeAnthropicAI', () => {
temperature: 0.7,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Hello', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
system: [
{
cache_control: { type: 'ephemeral' },
text: 'You are a helpful assistant',
type: 'text',
},
],
temperature: 0.35,
});
expect(result).toEqual(
expect.objectContaining({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Hello', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
system: [
{
cache_control: { type: 'ephemeral' },
text: 'You are a helpful assistant',
type: 'text',
},
],
temperature: 0.35,
}),
);
});
it('should correctly build payload with tools', async () => {
@@ -650,20 +651,24 @@ describe('LobeAnthropicAI', () => {
tools,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Use a tool', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
temperature: 0.4,
tools: [{ name: 'tool1', description: 'desc1' }],
});
expect(result).toEqual(
expect.objectContaining({
max_tokens: 4096,
messages: [
{
content: [
{ cache_control: { type: 'ephemeral' }, text: 'Use a tool', type: 'text' },
],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
temperature: 0.4,
tools: [{ name: 'tool1', description: 'desc1' }],
}),
);
expect(spyOn).toHaveBeenCalledWith(tools, {
enabledContextCaching: true,
@@ -678,7 +683,7 @@ describe('LobeAnthropicAI', () => {
thinking: { type: 'enabled', budget_tokens: 0 },
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 4096,
@@ -706,7 +711,7 @@ describe('LobeAnthropicAI', () => {
thinking: { type: 'enabled', budget_tokens: 0 },
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 1000,
@@ -734,7 +739,7 @@ describe('LobeAnthropicAI', () => {
thinking: { type: 'enabled', budget_tokens: 2000 },
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 1000,
@@ -762,7 +767,7 @@ describe('LobeAnthropicAI', () => {
thinking: { type: 'enabled', budget_tokens: 60000 },
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result).toEqual({
max_tokens: 10000,
@@ -788,7 +793,7 @@ describe('LobeAnthropicAI', () => {
temperature: 0.7,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result.max_tokens).toBe(4096);
});
@@ -801,7 +806,7 @@ describe('LobeAnthropicAI', () => {
temperature: 0.7,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result.max_tokens).toBe(2000);
});
@@ -813,7 +818,7 @@ describe('LobeAnthropicAI', () => {
temperature: 1.0,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result.temperature).toBe(0.5); // Anthropic uses 0-1 scale, so divide by 2
});
@@ -829,7 +834,7 @@ describe('LobeAnthropicAI', () => {
// Delete the temperature property to simulate it not being provided
delete (partialPayload as any).temperature;
const result = await instance['buildAnthropicPayload'](partialPayload);
const result = await buildDefaultAnthropicPayload(partialPayload);
expect(result.temperature).toBeUndefined();
});
@@ -843,7 +848,7 @@ describe('LobeAnthropicAI', () => {
top_p: 0.9,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result.top_p).toBeUndefined();
});
@@ -856,7 +861,7 @@ describe('LobeAnthropicAI', () => {
top_p: 0.9,
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
expect(result.top_p).toBe(0.9);
});
@@ -869,20 +874,22 @@ describe('LobeAnthropicAI', () => {
thinking: { type: 'disabled', budget_tokens: 0 },
};
const result = await instance['buildAnthropicPayload'](payload);
const result = await buildDefaultAnthropicPayload(payload);
// When thinking is disabled, it should be treated as if thinking wasn't provided
expect(result).toEqual({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Hello', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
temperature: 0.35,
});
expect(result).toEqual(
expect.objectContaining({
max_tokens: 4096,
messages: [
{
content: [{ cache_control: { type: 'ephemeral' }, text: 'Hello', type: 'text' }],
role: 'user',
},
],
model: 'claude-3-haiku-20240307',
temperature: 0.35,
}),
);
});
});
});
@@ -1,286 +1,17 @@
import Anthropic, { ClientOptions } from '@anthropic-ai/sdk';
import { ModelProvider } from 'model-bank';
import { hasTemperatureTopPConflict } from '../../const/models';
import { LobeRuntimeAI } from '../../core/BaseAI';
import {
buildAnthropicMessages,
buildAnthropicTools,
buildSearchTool,
} from '../../core/contextBuilders/anthropic';
import { resolveParameters } from '../../core/parameterResolver';
import { AnthropicStream } from '../../core/streams';
import {
type ChatCompletionErrorPayload,
ChatMethodOptions,
ChatStreamPayload,
GenerateObjectOptions,
GenerateObjectPayload,
} from '../../types';
import { AgentRuntimeErrorType } from '../../types/error';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { desensitizeUrl } from '../../utils/desensitizeUrl';
import { getModelPricing } from '../../utils/getModelPricing';
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
import { StreamingResponse } from '../../utils/response';
import { createAnthropicGenerateObject } from './generateObject';
import { handleAnthropicError } from './handleAnthropicError';
import { resolveCacheTTL } from './resolveCacheTTL';
import { resolveMaxTokens } from './resolveMaxTokens';
createAnthropicCompatibleParams,
createAnthropicCompatibleRuntime,
} from '../../core/anthropicCompatibleFactory';
export interface AnthropicModelCard {
created_at: string;
display_name: string;
id: string;
}
export const params = createAnthropicCompatibleParams({
debug: {
chatCompletion: () => process.env.DEBUG_ANTHROPIC_CHAT_COMPLETION === '1',
},
provider: ModelProvider.Anthropic,
});
type anthropicTools = Anthropic.Tool | Anthropic.WebSearchTool20250305;
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
interface AnthropicAIParams extends ClientOptions {
id?: string;
}
export class LobeAnthropicAI implements LobeRuntimeAI {
private client: Anthropic;
baseURL: string;
apiKey?: string;
private id: string;
private isDebug() {
return process.env.DEBUG_ANTHROPIC_CHAT_COMPLETION === '1';
}
constructor({
apiKey,
baseURL = DEFAULT_BASE_URL,
id,
defaultHeaders,
...res
}: AnthropicAIParams = {}) {
if (!apiKey) throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
const betaHeaders = process.env.ANTHROPIC_BETA_HEADERS;
this.client = new Anthropic({
apiKey,
baseURL,
defaultHeaders: { ...defaultHeaders, 'anthropic-beta': betaHeaders },
...res,
});
this.baseURL = this.client.baseURL;
this.apiKey = apiKey;
this.id = id || ModelProvider.Anthropic;
}
async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
try {
const anthropicPayload = await this.buildAnthropicPayload(payload);
const inputStartAt = Date.now();
if (this.isDebug()) {
console.log('[requestPayload]');
console.log(JSON.stringify(anthropicPayload), '\n');
}
const response = await this.client.messages.create(
{
...anthropicPayload,
metadata: options?.user ? { user_id: options?.user } : undefined,
stream: true,
},
{
signal: options?.signal,
},
);
const [prod, debug] = response.tee();
if (this.isDebug()) {
debugStream(debug.toReadableStream()).catch(console.error);
}
const pricing = await getModelPricing(payload.model, this.id);
const cacheTTL = resolveCacheTTL(payload, anthropicPayload);
const pricingOptions = cacheTTL ? { lookupParams: { ttl: cacheTTL } } : undefined;
return StreamingResponse(
AnthropicStream(prod, {
callbacks: options?.callback,
inputStartAt,
payload: { model: payload.model, pricing, pricingOptions, provider: this.id },
}),
{
headers: options?.headers,
},
);
} catch (error) {
throw this.handleError(error);
}
}
async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
try {
return await createAnthropicGenerateObject(this.client, payload, options);
} catch (error) {
throw this.handleError(error);
}
}
private async buildAnthropicPayload(payload: ChatStreamPayload) {
const {
messages,
model,
max_tokens,
temperature,
top_p,
tools,
thinking,
enabledContextCaching = true,
enabledSearch,
} = payload;
const { anthropic: anthropicModels } = await import('model-bank');
const resolvedMaxTokens = await resolveMaxTokens({
max_tokens,
model,
providerModels: anthropicModels,
thinking,
});
const system_message = messages.find((m) => m.role === 'system');
const user_messages = messages.filter((m) => m.role !== 'system');
const systemPrompts = !!system_message?.content
? ([
{
cache_control: enabledContextCaching ? { type: 'ephemeral' } : undefined,
text: system_message?.content as string,
type: 'text',
},
] as Anthropic.TextBlockParam[])
: undefined;
const postMessages = await buildAnthropicMessages(user_messages, { enabledContextCaching });
let postTools: anthropicTools[] | undefined = buildAnthropicTools(tools, {
enabledContextCaching,
});
if (enabledSearch) {
const webSearchTool = buildSearchTool();
if (postTools && postTools.length > 0) {
postTools = [...postTools, webSearchTool];
} else {
postTools = [webSearchTool];
}
}
if (!!thinking && thinking.type === 'enabled') {
// `temperature` may only be set to 1 when thinking is enabled.
// `top_p` must be unset when thinking is enabled.
return {
max_tokens: resolvedMaxTokens,
messages: postMessages,
model,
system: systemPrompts,
thinking: {
...thinking,
budget_tokens: thinking?.budget_tokens
? Math.min(thinking.budget_tokens, resolvedMaxTokens - 1) // `max_tokens` must be greater than `thinking.budget_tokens`.
: 1024,
},
tools: postTools,
} satisfies Anthropic.MessageCreateParams;
}
// Resolve temperature and top_p parameters based on model constraints
const hasConflict = hasTemperatureTopPConflict(model);
const resolvedParams = resolveParameters(
{ temperature, top_p },
{ hasConflict, normalizeTemperature: true, preferTemperature: true },
);
return {
// claude 3 series model hax max output token of 4096, 3.x series has 8192
// https://docs.anthropic.com/en/docs/about-claude/models/all-models#:~:text=200K-,Max%20output,-Normal%3A
max_tokens: resolvedMaxTokens,
messages: postMessages,
model,
system: systemPrompts,
temperature: resolvedParams.temperature,
tools: postTools,
top_p: resolvedParams.top_p,
} satisfies Anthropic.MessageCreateParams;
}
async models() {
const url = `${this.baseURL}/v1/models`;
const response = await fetch(url, {
headers: {
'anthropic-version': '2023-06-01',
'x-api-key': `${this.apiKey}`,
},
method: 'GET',
});
const json = await response.json();
const modelList: AnthropicModelCard[] = json['data'];
const standardModelList = modelList.map((model) => ({
created: model.created_at,
displayName: model.display_name,
id: model.id,
}));
return processModelList(standardModelList, MODEL_LIST_CONFIGS.anthropic, 'anthropic');
}
private handleError(error: any): ChatCompletionErrorPayload {
let desensitizedEndpoint = this.baseURL;
if (this.baseURL !== DEFAULT_BASE_URL) {
desensitizedEndpoint = desensitizeUrl(this.baseURL);
}
if ('status' in (error as any)) {
switch ((error as Response).status) {
case 401: {
throw AgentRuntimeError.chat({
endpoint: desensitizedEndpoint,
error: error as any,
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
provider: this.id,
});
}
case 403: {
throw AgentRuntimeError.chat({
endpoint: desensitizedEndpoint,
error: error as any,
errorType: AgentRuntimeErrorType.LocationNotSupportError,
provider: this.id,
});
}
default: {
break;
}
}
}
const { errorResult } = handleAnthropicError(error);
throw AgentRuntimeError.chat({
endpoint: desensitizedEndpoint,
error: errorResult,
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: this.id,
});
}
}
export const LobeAnthropicAI = createAnthropicCompatibleRuntime(params);
export default LobeAnthropicAI;
@@ -28,8 +28,8 @@ import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { getModelPricing } from '../../utils/getModelPricing';
import { StreamingResponse } from '../../utils/response';
import { resolveCacheTTL } from '../anthropic/resolveCacheTTL';
import { resolveMaxTokens } from '../anthropic/resolveMaxTokens';
import { resolveCacheTTL } from '../../core/anthropicCompatibleFactory/resolveCacheTTL';
import { resolveMaxTokens } from '../../core/anthropicCompatibleFactory/resolveMaxTokens';
/**
* A prompt constructor for HuggingFace LLama 2 chat models.
@@ -1,57 +1,97 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import OpenAI from 'openai';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeOpenAICompatibleRuntime } from '../../core/BaseAI';
import { testProvider } from '../../providerTestUtils';
import { LobeMoonshotAI, params } from './index';
import {
LobeMoonshotAI,
LobeMoonshotAnthropicAI,
LobeMoonshotOpenAI,
anthropicParams,
params,
} from './index';
const provider = 'moonshot';
const defaultBaseURL = 'https://api.moonshot.cn/v1';
const defaultOpenAIBaseURL = 'https://api.moonshot.ai/v1';
const anthropicBaseURL = 'https://api.moonshot.ai/anthropic';
testProvider({
Runtime: LobeMoonshotAI,
provider,
defaultBaseURL,
chatDebugEnv: 'DEBUG_MOONSHOT_CHAT_COMPLETION',
chatModel: 'moonshot-v1-8k',
test: {
skipAPICall: true,
},
});
// Mock the console.error to avoid polluting test output
// Mock the console.error and console.warn to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
let instance: LobeOpenAICompatibleRuntime;
describe('LobeMoonshotAI', () => {
describe('RouterRuntime baseURL routing', () => {
it('should route to OpenAI format by default', async () => {
const runtime = new LobeMoonshotAI({ apiKey: 'test' });
expect(runtime).toBeInstanceOf(LobeMoonshotAI);
});
beforeEach(() => {
instance = new LobeMoonshotAI({ apiKey: 'test' });
it('should route to OpenAI format when baseURL ends with /v1', async () => {
const runtime = new LobeMoonshotAI({
apiKey: 'test',
baseURL: 'https://api.moonshot.ai/v1',
});
expect(runtime).toBeInstanceOf(LobeMoonshotAI);
});
// 使用 vi.spyOn 来模拟 chat.completions.create 方法
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
new ReadableStream() as any,
);
});
it('should route to Anthropic format when baseURL ends with /anthropic', async () => {
const runtime = new LobeMoonshotAI({
apiKey: 'test',
baseURL: 'https://api.moonshot.ai/anthropic',
});
expect(runtime).toBeInstanceOf(LobeMoonshotAI);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should route to Anthropic format when baseURL ends with /anthropic/', async () => {
const runtime = new LobeMoonshotAI({
apiKey: 'test',
baseURL: 'https://api.moonshot.ai/anthropic/',
});
expect(runtime).toBeInstanceOf(LobeMoonshotAI);
});
});
describe('LobeMoonshotAI - custom features', () => {
describe('Debug Configuration', () => {
it('should disable debug by default', () => {
delete process.env.DEBUG_MOONSHOT_CHAT_COMPLETION;
const result = params.debug.chatCompletion();
const result = anthropicParams.debug!.chatCompletion!();
expect(result).toBe(false);
});
it('should enable debug when env is set', () => {
process.env.DEBUG_MOONSHOT_CHAT_COMPLETION = '1';
const result = params.debug.chatCompletion();
const result = anthropicParams.debug!.chatCompletion!();
expect(result).toBe(true);
delete process.env.DEBUG_MOONSHOT_CHAT_COMPLETION;
});
});
});
describe('LobeMoonshotOpenAI', () => {
let instance: InstanceType<typeof LobeMoonshotOpenAI>;
const getLastRequestPayload = () => {
const calls = ((instance as any).client.chat.completions.create as Mock).mock.calls;
return calls[calls.length - 1]?.[0];
};
beforeEach(() => {
instance = new LobeMoonshotOpenAI({ apiKey: 'test' });
vi.spyOn((instance as any).client.chat.completions, 'create').mockResolvedValue(
new ReadableStream() as any,
);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('init', () => {
it('should correctly initialize with an API key', async () => {
const runtime = new LobeMoonshotOpenAI({ apiKey: 'test_api_key' });
expect(runtime).toBeInstanceOf(LobeMoonshotOpenAI);
expect((runtime as any).baseURL).toEqual(defaultOpenAIBaseURL);
});
});
describe('handlePayload', () => {
describe('empty assistant messages', () => {
@@ -66,16 +106,12 @@ describe('LobeMoonshotAI - custom features', () => {
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ content: 'Hello', role: 'user' },
{ content: ' ', role: 'assistant' },
{ content: 'Follow-up', role: 'user' },
],
}),
expect.anything(),
const payload = getLastRequestPayload();
const assistantMessage = payload.messages.find(
(message: any) => message.role === 'assistant',
);
expect(assistantMessage?.content).toBe(' ');
});
it('should replace null content assistant message with a space', async () => {
@@ -88,36 +124,12 @@ describe('LobeMoonshotAI - custom features', () => {
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ content: 'Hello', role: 'user' },
{ content: ' ', role: 'assistant' },
],
}),
expect.anything(),
const payload = getLastRequestPayload();
const assistantMessage = payload.messages.find(
(message: any) => message.role === 'assistant',
);
});
it('should replace undefined content assistant message with a space', async () => {
await instance.chat({
messages: [
{ content: 'Hello', role: 'user' },
{ content: undefined as any, role: 'assistant' },
],
model: 'moonshot-v1-8k',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ content: 'Hello', role: 'user' },
{ content: ' ', role: 'assistant' },
],
}),
expect.anything(),
);
expect(assistantMessage?.content).toBe(' ');
});
it('should not modify non-empty assistant messages', async () => {
@@ -130,43 +142,193 @@ describe('LobeMoonshotAI - custom features', () => {
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ content: 'Hello', role: 'user' },
{ content: 'I am here', role: 'assistant' },
],
}),
expect.anything(),
const payload = getLastRequestPayload();
const assistantMessage = payload.messages.find(
(message: any) => message.role === 'assistant',
);
expect(assistantMessage?.content).toBe('I am here');
});
});
describe('web search functionality', () => {
it('should add web_search tool when enabledSearch is true', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
enabledSearch: true,
});
const payload = getLastRequestPayload();
expect(payload.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'builtin_function',
function: { name: '$web_search' },
}),
]),
);
});
it('should not modify user or system messages', async () => {
it('should not add web_search tool when enabledSearch is false', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
enabledSearch: false,
});
const payload = getLastRequestPayload();
expect(payload.tools).toBeUndefined();
});
});
describe('temperature normalization', () => {
it('should normalize temperature (divide by 2)', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0.8,
});
const payload = getLastRequestPayload();
expect(payload.temperature).toBe(0.4);
});
it('should normalize temperature to 0.5 when temperature is 1', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 1,
});
const payload = getLastRequestPayload();
expect(payload.temperature).toBe(0.5);
});
it('should normalize temperature to 0 when temperature is 0', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
});
const payload = getLastRequestPayload();
expect(payload.temperature).toBe(0);
});
it('should handle kimi-k2.5 model with thinking enabled by default', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.5',
temperature: 0.5,
top_p: 0.8,
});
const payload = getLastRequestPayload();
expect(payload.temperature).toBe(1);
expect(payload.top_p).toBe(0.95);
expect(payload.frequency_penalty).toBe(0);
expect(payload.presence_penalty).toBe(0);
expect(payload.thinking).toEqual({ type: 'enabled' });
});
it('should handle kimi-k2.5 model with thinking disabled', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.5',
thinking: { budget_tokens: 0, type: 'disabled' },
});
const payload = getLastRequestPayload();
expect(payload.temperature).toBe(0.6);
expect(payload.thinking).toEqual({ type: 'disabled' });
});
});
describe('interleaved thinking', () => {
it('should convert reasoning to reasoning_content for assistant messages', async () => {
await instance.chat({
messages: [
{ content: '', role: 'system' },
{ content: '', role: 'user' },
{ content: 'Hello', role: 'user' },
{
content: 'Response',
role: 'assistant',
reasoning: { content: 'My reasoning process' },
} as any,
],
model: 'moonshot-v1-8k',
temperature: 0.5,
});
const payload = getLastRequestPayload();
const assistantMessage = payload.messages.find(
(message: any) => message.role === 'assistant',
);
expect(assistantMessage?.reasoning_content).toBe('My reasoning process');
expect(assistantMessage?.reasoning).toBeUndefined();
});
});
});
});
describe('LobeMoonshotAnthropicAI', () => {
let instance: InstanceType<typeof LobeMoonshotAnthropicAI>;
const getLastRequestPayload = () => {
const calls = ((instance as any).client.messages.create as Mock).mock.calls;
return calls[calls.length - 1]?.[0];
};
beforeEach(() => {
instance = new LobeMoonshotAnthropicAI({ apiKey: 'test' });
vi.spyOn((instance as any).client.messages, 'create').mockResolvedValue(
new ReadableStream() as any,
);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('init', () => {
it('should correctly initialize with an API key', async () => {
const runtime = new LobeMoonshotAnthropicAI({ apiKey: 'test_api_key' });
expect(runtime).toBeInstanceOf(LobeMoonshotAnthropicAI);
expect((runtime as any).baseURL).toEqual(anthropicBaseURL);
});
});
describe('handlePayload', () => {
describe('empty assistant messages', () => {
it('should replace empty string assistant message with a space', async () => {
await instance.chat({
messages: [
{ content: 'Hello', role: 'user' },
{ content: '', role: 'assistant' },
{ content: 'Follow-up', role: 'user' },
],
model: 'moonshot-v1-8k',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ content: '', role: 'system' },
{ content: '', role: 'user' },
{ content: ' ', role: 'assistant' },
],
}),
expect.anything(),
const payload = getLastRequestPayload();
const assistantMessage = payload.messages.find(
(message: any) => message.role === 'assistant',
);
expect(assistantMessage?.content).toEqual(
expect.arrayContaining([expect.objectContaining({ text: ' ' })]),
);
});
});
describe('web search functionality', () => {
it('should add $web_search tool when enabledSearch is true', async () => {
it('should add web_search tool when enabledSearch is true', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
@@ -174,109 +336,15 @@ describe('LobeMoonshotAI - custom features', () => {
enabledSearch: true,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
{
function: {
name: '$web_search',
},
type: 'builtin_function',
},
],
}),
expect.anything(),
);
});
const payload = getLastRequestPayload();
it('should add $web_search tool along with existing tools when enabledSearch is true', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
enabledSearch: true,
tools: [
{
type: 'function',
function: { name: 'custom_tool', description: 'A custom tool', parameters: {} },
},
],
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
{
type: 'function',
function: { name: 'custom_tool', description: 'A custom tool', parameters: {} },
},
{
function: {
name: '$web_search',
},
type: 'builtin_function',
},
],
}),
expect.anything(),
);
});
it('should not add $web_search tool when enabledSearch is false', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
enabledSearch: false,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: undefined,
}),
expect.anything(),
);
});
it('should not add $web_search tool when enabledSearch is not specified', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: undefined,
}),
expect.anything(),
);
});
it('should preserve existing tools when enabledSearch is false', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
enabledSearch: false,
tools: [
{
type: 'function',
function: { name: 'custom_tool', description: 'A custom tool', parameters: {} },
},
],
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
{
type: 'function',
function: { name: 'custom_tool', description: 'A custom tool', parameters: {} },
},
],
}),
expect.anything(),
expect(payload.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'builtin_function',
function: { name: '$web_search' },
}),
]),
);
});
});
@@ -289,216 +357,114 @@ describe('LobeMoonshotAI - custom features', () => {
temperature: 0.8,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.4,
}),
expect.anything(),
);
});
it('should normalize temperature to 0.5 when temperature is 1', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 1,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
expect.anything(),
);
});
it('should normalize temperature to 0 when temperature is 0', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0,
}),
expect.anything(),
);
});
it('should handle high temperature values (2.0 normalized to 1.0)', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 2,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 1,
}),
expect.anything(),
);
});
it('should normalize negative temperature values', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: -1,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: -0.5,
}),
expect.anything(),
);
const payload = getLastRequestPayload();
expect(payload.temperature).toBe(0.4);
});
});
describe('other payload properties', () => {
it('should preserve other payload properties', async () => {
describe('kimi-k2.5 thinking support', () => {
it('should add thinking params for kimi-k2.5 model', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.5',
temperature: 0.5,
});
const payload = getLastRequestPayload();
expect(payload.thinking).toEqual({
budget_tokens: 1024,
type: 'enabled',
});
expect(payload.temperature).toBe(1);
expect(payload.top_p).toBe(0.95);
});
it('should disable thinking when type is disabled', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.5',
temperature: 0.5,
thinking: { budget_tokens: 0, type: 'disabled' },
});
const payload = getLastRequestPayload();
expect(payload.thinking).toEqual({ type: 'disabled' });
expect(payload.temperature).toBe(0.6);
});
it('should respect custom thinking budget', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'kimi-k2.5',
max_tokens: 4096,
thinking: { budget_tokens: 2048, type: 'enabled' },
});
const payload = getLastRequestPayload();
expect(payload.thinking).toEqual({
budget_tokens: 2048,
type: 'enabled',
});
});
it('should not add thinking params for non-kimi-k2.5 models', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0.5,
max_tokens: 100,
top_p: 0.9,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [{ content: 'Hello', role: 'user' }],
model: 'moonshot-v1-8k',
temperature: 0.25,
max_tokens: 100,
top_p: 0.9,
}),
expect.anything(),
);
const payload = getLastRequestPayload();
expect(payload.thinking).toBeUndefined();
});
it('should combine all features together', async () => {
await instance.chat({
messages: [
{ content: 'Hello', role: 'user' },
{ content: '', role: 'assistant' },
{ content: 'Question?', role: 'user' },
],
model: 'moonshot-v1-8k',
temperature: 0.7,
max_tokens: 2000,
enabledSearch: true,
tools: [
{
type: 'function',
function: { name: 'custom_tool', description: 'A custom tool', parameters: {} },
},
],
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ content: 'Hello', role: 'user' },
{ content: ' ', role: 'assistant' },
{ content: 'Question?', role: 'user' },
],
model: 'moonshot-v1-8k',
temperature: 0.35,
max_tokens: 2000,
tools: [
{
type: 'function',
function: { name: 'custom_tool', description: 'A custom tool', parameters: {} },
},
{
function: {
name: '$web_search',
},
type: 'builtin_function',
},
],
}),
expect.anything(),
);
});
});
});
describe('models', () => {
const mockClient = {
models: {
list: vi.fn(),
},
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch and process models successfully', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'moonshot-v1-8k' }, { id: 'moonshot-v1-32k' }, { id: 'moonshot-v1-128k' }],
});
const models = await params.models({ client: mockClient as any });
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toHaveLength(3);
expect(models[0].id).toBe('moonshot-v1-8k');
expect(models[1].id).toBe('moonshot-v1-32k');
expect(models[2].id).toBe('moonshot-v1-128k');
});
it('should handle single model', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'moonshot-v1-8k' }],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0].id).toBe('moonshot-v1-8k');
});
it('should handle empty model list', async () => {
mockClient.models.list.mockResolvedValue({
data: [],
});
const models = await params.models({ client: mockClient as any });
expect(models).toEqual([]);
});
it('should process models with MODEL_LIST_CONFIGS', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'moonshot-v1-8k' }],
});
const models = await params.models({ client: mockClient as any });
// The processModelList function should merge with known model list
expect(models[0]).toHaveProperty('id');
expect(models[0].id).toBe('moonshot-v1-8k');
});
it('should preserve model properties from API response', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'moonshot-v1-8k', extra_field: 'value' },
{ id: 'moonshot-v1-32k', another_field: 123 },
],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(2);
expect(models[0].id).toBe('moonshot-v1-8k');
expect(models[1].id).toBe('moonshot-v1-32k');
});
});
});
describe('models', () => {
const fetchModels = params.models as (params: { client: OpenAI }) => Promise<any[]>;
it('should use OpenAI client to fetch models', async () => {
const mockClient = {
models: {
list: vi.fn().mockResolvedValue({
data: [{ id: 'moonshot-v1-8k' }, { id: 'moonshot-v1-32k' }],
}),
},
} as unknown as OpenAI;
const models = await fetchModels({ client: mockClient });
expect(mockClient.models.list).toHaveBeenCalled();
expect(models).toHaveLength(2);
expect(models[0].id).toBe('moonshot-v1-8k');
});
it('should handle empty model list', async () => {
const mockClient = {
models: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
} as unknown as OpenAI;
const models = await fetchModels({ client: mockClient });
expect(models).toEqual([]);
});
it('should handle fetch error gracefully', async () => {
const mockClient = {
models: {
list: vi.fn().mockRejectedValue(new Error('Network error')),
},
} as unknown as OpenAI;
const models = await fetchModels({ client: mockClient });
expect(models).toEqual([]);
});
});
@@ -1,99 +1,245 @@
import type Anthropic from '@anthropic-ai/sdk';
import type { ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import OpenAI from 'openai';
import { CreateRouterRuntimeOptions, createRouterRuntime } from '../../core/RouterRuntime';
import {
type OpenAICompatibleFactoryOptions,
createOpenAICompatibleRuntime,
} from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
buildDefaultAnthropicPayload,
createAnthropicCompatibleParams,
createAnthropicCompatibleRuntime,
} from '../../core/anthropicCompatibleFactory';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { ChatStreamPayload } from '../../types';
import { getModelPropertyWithFallback } from '../../utils/getFallbackModelProperty';
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
export interface MoonshotModelCard {
id: string;
}
export const params = {
baseURL: 'https://api.moonshot.cn/v1',
const DEFAULT_MOONSHOT_BASE_URL = 'https://api.moonshot.ai/v1';
const DEFAULT_MOONSHOT_ANTHROPIC_BASE_URL = 'https://api.moonshot.ai/anthropic';
/**
* Normalize empty assistant messages by adding a space placeholder (#8418)
*/
const normalizeMoonshotMessages = (messages: ChatStreamPayload['messages']) =>
messages.map((message) => {
if (message.role !== 'assistant') return message;
if (message.content !== '' && message.content !== null && message.content !== undefined)
return message;
return { ...message, content: [{ text: ' ', type: 'text' as const }] };
});
/**
* Append Moonshot web search tool for builtin search capability
*/
const appendMoonshotSearchTool = (
tools: Anthropic.MessageCreateParams['tools'] | undefined,
enabledSearch?: boolean,
) => {
if (!enabledSearch) return tools;
const moonshotSearchTool = {
function: { name: '$web_search' },
type: 'builtin_function',
} as any;
return tools?.length ? [...tools, moonshotSearchTool] : [moonshotSearchTool];
};
/**
* Build Moonshot Anthropic format payload with special handling for kimi-k2.5 thinking
*/
const buildMoonshotAnthropicPayload = async (
payload: ChatStreamPayload,
): Promise<Anthropic.MessageCreateParams> => {
const normalizedMessages = normalizeMoonshotMessages(payload.messages);
const resolvedMaxTokens =
payload.max_tokens ??
(await getModelPropertyWithFallback<number | undefined>(
payload.model,
'maxOutput',
ModelProvider.Moonshot,
)) ??
8192;
const basePayload = await buildDefaultAnthropicPayload({
...payload,
enabledSearch: false,
max_tokens: resolvedMaxTokens,
messages: normalizedMessages,
});
const tools = appendMoonshotSearchTool(basePayload.tools, payload.enabledSearch);
const basePayloadWithSearch = { ...basePayload, tools };
const isK25Model = payload.model === 'kimi-k2.5';
if (!isK25Model) return basePayloadWithSearch;
const resolvedThinkingBudget = payload.thinking?.budget_tokens
? Math.min(payload.thinking.budget_tokens, resolvedMaxTokens - 1)
: 1024;
const thinkingParam =
payload.thinking?.type === 'disabled'
? ({ type: 'disabled' } as const)
: ({ budget_tokens: resolvedThinkingBudget, type: 'enabled' } as const);
const isThinkingEnabled = thinkingParam.type === 'enabled';
return {
...basePayloadWithSearch,
temperature: isThinkingEnabled ? 1 : 0.6,
thinking: thinkingParam,
top_p: 0.95,
};
};
/**
* Build Moonshot OpenAI format payload with temperature normalization
*/
const buildMoonshotOpenAIPayload = (
payload: ChatStreamPayload,
): OpenAI.ChatCompletionCreateParamsStreaming => {
const { enabledSearch, messages, model, temperature, thinking, tools, ...rest } = payload;
// Normalize messages: handle empty assistant messages and interleaved thinking
const normalizedMessages = messages.map((message: any) => {
let normalizedMessage = message;
// Add a space for empty assistant messages (#8418)
if (
message.role === 'assistant' &&
(message.content === '' || message.content === null || message.content === undefined)
) {
normalizedMessage = { ...normalizedMessage, content: ' ' };
}
// Interleaved thinking: convert reasoning to reasoning_content
if (message.role === 'assistant' && message.reasoning) {
const { reasoning, ...messageWithoutReasoning } = normalizedMessage;
return {
...messageWithoutReasoning,
...(!reasoning.signature && reasoning.content
? { reasoning_content: reasoning.content }
: {}),
};
}
return normalizedMessage;
});
const moonshotTools = enabledSearch
? [
...(tools || []),
{
function: { name: '$web_search' },
type: 'builtin_function',
},
]
: tools;
const isK25Model = model === 'kimi-k2.5';
if (isK25Model) {
const thinkingParam =
thinking?.type === 'disabled' ? { type: 'disabled' } : { type: 'enabled' };
const isThinkingEnabled = thinkingParam.type === 'enabled';
return {
...rest,
frequency_penalty: 0,
messages: normalizedMessages,
model,
presence_penalty: 0,
stream: payload.stream ?? true,
temperature: isThinkingEnabled ? 1 : 0.6,
thinking: thinkingParam,
tools: moonshotTools?.length ? moonshotTools : undefined,
top_p: 0.95,
} as any;
}
// Moonshot temperature is normalized by dividing by 2
const normalizedTemperature = temperature !== undefined ? temperature / 2 : undefined;
return {
...rest,
messages: normalizedMessages,
model,
stream: payload.stream ?? true,
temperature: normalizedTemperature,
tools: moonshotTools?.length ? moonshotTools : undefined,
} as OpenAI.ChatCompletionCreateParamsStreaming;
};
/**
* Fetch Moonshot models from the API using OpenAI client
*/
const fetchMoonshotModels = async ({ client }: { client: OpenAI }): Promise<ChatModelCard[]> => {
try {
const modelsPage = (await client.models.list()) as any;
const modelList: MoonshotModelCard[] = modelsPage.data || [];
return processModelList(modelList, MODEL_LIST_CONFIGS.moonshot, 'moonshot');
} catch (error) {
console.warn('Failed to fetch Moonshot models:', error);
return [];
}
};
/**
* Moonshot Anthropic format runtime
*/
export const anthropicParams = createAnthropicCompatibleParams({
baseURL: DEFAULT_MOONSHOT_ANTHROPIC_BASE_URL,
chatCompletion: {
handlePayload: buildMoonshotAnthropicPayload,
},
customClient: {},
debug: {
chatCompletion: () => process.env.DEBUG_MOONSHOT_CHAT_COMPLETION === '1',
},
provider: ModelProvider.Moonshot,
});
export const LobeMoonshotAnthropicAI = createAnthropicCompatibleRuntime(anthropicParams);
/**
* Moonshot OpenAI format runtime
*/
export const LobeMoonshotOpenAI = createOpenAICompatibleRuntime({
baseURL: DEFAULT_MOONSHOT_BASE_URL,
chatCompletion: {
forceImageBase64: true,
handlePayload: (payload: ChatStreamPayload) => {
const { enabledSearch, messages, model, temperature, thinking, tools, ...rest } = payload;
const filteredMessages = messages.map((message: any) => {
let normalizedMessage = message;
// Add a space for empty assistant messages (#8418)
if (message.role === 'assistant' && (!message.content || message.content === '')) {
normalizedMessage = { ...normalizedMessage, content: ' ' };
}
// Interleaved thinking
if (message.role === 'assistant' && message.reasoning) {
const { reasoning, ...messageWithoutReasoning } = normalizedMessage;
return {
...messageWithoutReasoning,
...(!reasoning.signature && reasoning.content
? { reasoning_content: reasoning.content }
: {}),
};
}
return normalizedMessage;
});
const moonshotTools = enabledSearch
? [
...(tools || []),
{
function: {
name: '$web_search',
},
type: 'builtin_function',
},
]
: tools;
const isK25Model = model === 'kimi-k2.5';
if (isK25Model) {
const thinkingParam =
thinking?.type === 'disabled' ? { type: 'disabled' } : { type: 'enabled' };
const isThinkingEnabled = thinkingParam.type === 'enabled';
return {
...rest,
frequency_penalty: 0,
messages: filteredMessages,
model,
presence_penalty: 0,
temperature: isThinkingEnabled ? 1 : 0.6,
thinking: thinkingParam,
tools: moonshotTools,
top_p: 0.95,
} as any;
}
// Resolve parameters with normalization for non-K2.5 models
const resolvedParams = resolveParameters({ temperature }, { normalizeTemperature: true });
return {
...rest,
messages: filteredMessages,
model,
temperature: resolvedParams.temperature,
tools: moonshotTools,
} as any;
},
handlePayload: buildMoonshotOpenAIPayload,
},
debug: {
chatCompletion: () => process.env.DEBUG_MOONSHOT_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
const modelsPage = (await client.models.list()) as any;
const modelList: MoonshotModelCard[] = modelsPage.data;
return processModelList(modelList, MODEL_LIST_CONFIGS.moonshot, 'moonshot');
},
provider: ModelProvider.Moonshot,
} satisfies OpenAICompatibleFactoryOptions;
});
export const LobeMoonshotAI = createOpenAICompatibleRuntime(params);
/**
* RouterRuntime configuration for Moonshot
* Routes to Anthropic format for /anthropic URLs, otherwise uses OpenAI format
*/
export const params: CreateRouterRuntimeOptions = {
id: ModelProvider.Moonshot,
models: fetchMoonshotModels,
routers: [
{
apiType: 'anthropic',
baseURLPattern: /\/anthropic\/?$/,
options: {},
runtime: LobeMoonshotAnthropicAI,
},
{
apiType: 'openai',
options: {},
runtime: LobeMoonshotOpenAI,
},
],
};
export const LobeMoonshotAI = createRouterRuntime(params);
@@ -62,6 +62,7 @@ export interface DiscoverAssistantItem extends Omit<LobeAgentSettings, 'meta'>,
status?: AgentStatus;
tokenUsage: number;
type?: AgentType;
updatedAt?: string;
userName?: string;
}
+8
View File
@@ -67,6 +67,14 @@ export interface DiscoverUserInfo {
export interface DiscoverUserProfile {
agentGroups?: DiscoverGroupAgentItem[];
agents: DiscoverAssistantItem[];
/**
* Agent groups favorited by the user
*/
favoriteAgentGroups?: DiscoverGroupAgentItem[];
/**
* Agents favorited by the user
*/
favoriteAgents?: DiscoverAssistantItem[];
/**
* Agent groups forked by the user
*/
@@ -202,7 +202,7 @@ export const handleSingle = async (
* to avoid breaking structured XML/JSON-like content mid-way.
*
* Bisection example (8 segments, keep newest):
* try 4 (fits?) yes try 6 no try 5 yes best=5
* try 4 (fits?) -> yes -> try 6 -> no -> try 5 -> yes => best=5
* if compact retry needed, repeat with build(true) and pick the better fit.
*
* This minimizes structural breakage by preferring whole built segments and only truncating the last one as a last resort.
+6 -6
View File
@@ -2,9 +2,9 @@ import { Pricing, PricingUnit, PricingUnitName } from 'model-bank';
/**
* Internal helper to extract the displayed unit rate from a pricing unit by strategy
* - fixed rate
* - tiered tiers[0].rate
* - lookup first price value
* - fixed: rate
* - tiered: tiers[0].rate
* - lookup: first price value
*/
const getRateFromUnit = (unit: PricingUnit): number | undefined => {
switch (unit.strategy) {
@@ -39,9 +39,9 @@ export const getUnitRateByName = (
/**
* Get text input unit rate from pricing
* - fixed rate
* - tiered tiers[0].rate
* - lookup Object.values(lookup.prices)[0]
* - fixed: rate
* - tiered: tiers[0].rate
* - lookup: Object.values(lookup.prices)[0]
*/
export function getTextInputUnitRate(pricing?: Pricing): number | undefined {
return getUnitRateByName(pricing, 'textInput');
+27 -11
View File
@@ -5,7 +5,7 @@
* IMPORTANT: Keep this file as CommonJS (.js) for compatibility with startServer.js
*/
const MIGRATION_DOC_BASE = 'https://lobehub.com/docs/self-hosting/advanced/auth';
const MIGRATION_DOC_BASE = 'https://lobehub.com/docs/self-hosting/migration/v2/auth';
/**
* Deprecated environment variable checks configuration
@@ -86,10 +86,10 @@ const DEPRECATED_CHECKS = [
const mapping = {
AUTH_AZURE_AD_ID: 'AUTH_MICROSOFT_ID',
AUTH_AZURE_AD_SECRET: 'AUTH_MICROSOFT_SECRET',
AUTH_AZURE_AD_TENANT_ID: 'No longer needed',
AUTH_AZURE_AD_TENANT_ID: 'AUTH_MICROSOFT_TENANT_ID',
AZURE_AD_CLIENT_ID: 'AUTH_MICROSOFT_ID',
AZURE_AD_CLIENT_SECRET: 'AUTH_MICROSOFT_SECRET',
AZURE_AD_TENANT_ID: 'No longer needed',
AZURE_AD_TENANT_ID: 'AUTH_MICROSOFT_TENANT_ID',
};
return `${envVar}${mapping[envVar]}`;
},
@@ -167,10 +167,10 @@ const DEPRECATED_CHECKS = [
docUrl: `${MIGRATION_DOC_BASE}/nextauth-to-betterauth`,
formatVar: (envVar) => {
const mapping = {
AUTH_MICROSOFT_ENTRA_ID_BASE_URL: 'No longer needed',
AUTH_MICROSOFT_ENTRA_ID_BASE_URL: 'AUTH_MICROSOFT_AUTHORITY_URL',
AUTH_MICROSOFT_ENTRA_ID_ID: 'AUTH_MICROSOFT_ID',
AUTH_MICROSOFT_ENTRA_ID_SECRET: 'AUTH_MICROSOFT_SECRET',
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: 'No longer needed',
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: 'AUTH_MICROSOFT_TENANT_ID',
};
return `${envVar}${mapping[envVar]}`;
},
@@ -213,7 +213,11 @@ function printIssueBlock(name, vars, message, docUrl, formatVar, severity = 'err
log(`\n${icon} ${name}`);
log('─'.repeat(50));
log(isWarning ? 'Missing recommended environment variables:' : 'Detected deprecated environment variables:');
log(
isWarning
? 'Missing recommended environment variables:'
: 'Detected deprecated environment variables:',
);
for (const envVar of vars) {
log(`${formatVar ? formatVar(envVar) : envVar}`);
}
@@ -253,7 +257,14 @@ function checkDeprecatedAuth(options = {}) {
console.warn('═'.repeat(70));
for (const issue of warnings) {
printIssueBlock(issue.name, issue.foundVars, issue.message, issue.docUrl, issue.formatVar, 'warning');
printIssueBlock(
issue.name,
issue.foundVars,
issue.message,
issue.docUrl,
issue.formatVar,
'warning',
);
}
console.warn('\n' + '═'.repeat(70));
@@ -264,13 +275,18 @@ function checkDeprecatedAuth(options = {}) {
// Print errors and exit (blocking)
if (errors.length > 0) {
console.error('\n' + '═'.repeat(70));
console.error(
`❌ ERROR: Found ${errors.length} deprecated environment variable issue(s)!`,
);
console.error(`❌ ERROR: Found ${errors.length} deprecated environment variable issue(s)!`);
console.error('═'.repeat(70));
for (const issue of errors) {
printIssueBlock(issue.name, issue.foundVars, issue.message, issue.docUrl, issue.formatVar, 'error');
printIssueBlock(
issue.name,
issue.foundVars,
issue.message,
issue.docUrl,
issue.formatVar,
'error',
);
}
console.error('\n' + '═'.repeat(70));
@@ -0,0 +1,14 @@
import { redirect } from 'next/navigation';
import { type PropsWithChildren } from 'react';
import { authEnv } from '@/envs/auth';
const ResetPasswordLayout = ({ children }: PropsWithChildren) => {
if (authEnv.AUTH_DISABLE_EMAIL_PASSWORD) {
redirect('/signin');
}
return children;
};
export default ResetPasswordLayout;
@@ -33,7 +33,7 @@ const Nav = memo(() => {
const switchTopic = useChatStore((s) => s.switchTopic);
const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]);
const { mutate, isValidating } = useActionSWR('openNewTopicOrSaveTopic', openNewTopicOrSaveTopic);
const { mutate } = useActionSWR('openNewTopicOrSaveTopic', openNewTopicOrSaveTopic);
const handleNewTopic = () => {
// If in agent sub-route, navigate back to agent chat first
if (isProfileActive && agentId) {
@@ -46,7 +46,6 @@ const Nav = memo(() => {
<Flexbox gap={1} paddingInline={4}>
<NavItem
icon={MessageSquarePlusIcon}
loading={isValidating}
onClick={handleNewTopic}
title={tTopic('actions.addNewTopic')}
/>
@@ -1,11 +1,15 @@
import { Button } from '@lobehub/ui';
import { ShapesUploadIcon } from '@lobehub/ui/icons';
import { Popconfirm } from 'antd';
import isEqual from 'fast-deep-equal';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { resolveMarketAuthError } from '@/layout/AuthProvider/MarketAuth/errors';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import ForkConfirmModal from './ForkConfirmModal';
import type { MarketPublishAction } from './types';
@@ -25,10 +29,17 @@ const PublishButton = memo<MarketPublishButtonProps>(({ action, onPublishSuccess
onSuccess: onPublishSuccess,
});
// Agent data for validation
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
const systemRole = useAgentStore(agentSelectors.currentAgentSystemRole);
// Fork confirmation modal state
const [showForkModal, setShowForkModal] = useState(false);
const [originalAgentInfo, setOriginalAgentInfo] = useState<OriginalAgentInfo | null>(null);
// Publish confirmation popconfirm state
const [confirmOpened, setConfirmOpened] = useState(false);
const buttonConfig = useMemo(() => {
if (action === 'upload') {
return {
@@ -60,7 +71,25 @@ const PublishButton = memo<MarketPublishButtonProps>(({ action, onPublishSuccess
await publish();
}, [checkOwnership, publish]);
const handleButtonClick = useCallback(async () => {
const handleButtonClick = useCallback(() => {
// Validate name and systemRole
if (!meta?.title || meta.title.trim() === '') {
message.error({ content: t('marketPublish.validation.emptyName') });
return;
}
if (!systemRole || systemRole.trim() === '') {
message.error({ content: t('marketPublish.validation.emptySystemRole') });
return;
}
// Open popconfirm for user confirmation
setConfirmOpened(true);
}, [meta?.title, systemRole, t]);
const handleConfirmPublish = useCallback(async () => {
setConfirmOpened(false);
if (!isAuthenticated) {
try {
await signIn();
@@ -98,14 +127,29 @@ const PublishButton = memo<MarketPublishButtonProps>(({ action, onPublishSuccess
return (
<>
<Button
icon={ShapesUploadIcon}
loading={loading}
onClick={handleButtonClick}
title={buttonTitle}
<Popconfirm
arrow={false}
okButtonProps={{ type: 'primary' }}
onCancel={() => setConfirmOpened(false)}
onConfirm={handleConfirmPublish}
onOpenChange={(open) => {
if (!open) {
setConfirmOpened(false);
}
}}
open={confirmOpened}
placement="bottomRight"
title={t('marketPublish.validation.confirmPublish')}
>
{t('publishToCommunity')}
</Button>
<Button
icon={ShapesUploadIcon}
loading={loading}
onClick={handleButtonClick}
title={buttonTitle}
>
{t('publishToCommunity')}
</Button>
</Popconfirm>
<ForkConfirmModal
loading={isPublishing}
onCancel={handleForkCancel}
@@ -75,7 +75,7 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
// Fetch favorite status
const { data: favoriteStatus, mutate: mutateFavorite } = useSWR(
identifier && isAuthenticated ? ['favorite-status', 'agent', identifier] : null,
() => socialService.checkFavoriteStatus('agent', identifier!),
() => socialService.checkFavoriteStatus('agent-group', identifier!),
{ revalidateOnFocus: false },
);
@@ -100,10 +100,10 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
setFavoriteLoading(true);
try {
if (isFavorited) {
await socialService.removeFavorite('agent', identifier);
await socialService.removeFavorite('agent-group', identifier);
message.success(t('assistant.unfavoriteSuccess'));
} else {
await socialService.addFavorite('agent', identifier);
await socialService.addFavorite('agent-group', identifier);
message.success(t('assistant.favoriteSuccess'));
}
await mutateFavorite();
@@ -13,6 +13,8 @@ export interface UserDetailContextConfig {
agentCount: number;
agentGroups?: DiscoverGroupAgentItem[];
agents: DiscoverAssistantItem[];
favoriteAgentGroups?: DiscoverGroupAgentItem[];
favoriteAgents?: DiscoverAssistantItem[];
forkedAgentGroups?: DiscoverGroupAgentItem[];
forkedAgents?: DiscoverAssistantItem[];
groupCount: number;
@@ -0,0 +1,36 @@
'use client';
import { Select } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export type StatusFilterValue = 'published' | 'unpublished' | 'deprecated' | 'archived' | 'forked' | 'favorite';
interface StatusFilterProps {
onChange: (value: StatusFilterValue) => void;
value: StatusFilterValue;
}
const StatusFilter = memo<StatusFilterProps>(({ value, onChange }) => {
const { t } = useTranslation('discover');
const options = [
{ label: t('user.statusFilter.published'), value: 'published' as const },
{ label: t('user.statusFilter.unpublished'), value: 'unpublished' as const },
{ label: t('user.statusFilter.deprecated'), value: 'deprecated' as const },
{ label: t('user.statusFilter.archived'), value: 'archived' as const },
{ label: t('user.statusFilter.forked'), value: 'forked' as const },
{ label: t('user.statusFilter.favorite'), value: 'favorite' as const },
];
return (
<Select
onChange={onChange}
options={options}
style={{ minWidth: 120 }}
value={value}
/>
);
});
export default StatusFilter;
@@ -1,12 +1,13 @@
'use client';
import { Flexbox, Grid, Tag, Text } from '@lobehub/ui';
import { Pagination } from 'antd';
import { Input, Pagination } from 'antd';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AssistantEmpty from '../../../features/AssistantEmpty';
import { useUserDetailContext } from './DetailProvider';
import StatusFilter, { type StatusFilterValue } from './StatusFilter';
import UserAgentCard from './UserAgentCard';
interface UserAgentListProps {
@@ -14,27 +15,82 @@ interface UserAgentListProps {
rows?: number;
}
const UserAgentList = memo<UserAgentListProps>(({ rows = 4, pageSize = 10 }) => {
const UserAgentList = memo<UserAgentListProps>(({ rows = 4, pageSize = 8 }) => {
const { t } = useTranslation('discover');
const { agents, agentCount } = useUserDetailContext();
const { agents, agentCount, forkedAgents = [], favoriteAgents = [], isOwner } = useUserDetailContext();
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilterValue>('published');
const [searchQuery, setSearchQuery] = useState('');
// Combine agents and forked agents, then filter based on status and search
const filteredAgents = useMemo(() => {
let allAgents = [...agents];
if (statusFilter === 'forked') {
// Show only forked agents (those with forkedFromAgentId)
allAgents = forkedAgents;
} else if (statusFilter === 'favorite') {
// Show only favorited agents
allAgents = favoriteAgents;
} else {
// Filter by status for non-forked agents
allAgents = allAgents.filter((agent) => {
return agent.status === statusFilter;
});
}
// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
allAgents = allAgents.filter((agent) => {
console.log('agent', agent);
const name = agent?.title?.toLowerCase() || '';
const description = agent?.description?.toLowerCase() || '';
return name.includes(query) || description.includes(query);
});
}
return allAgents;
}, [agents, forkedAgents, statusFilter, searchQuery]);
const paginatedAgents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return agents.slice(startIndex, startIndex + pageSize);
}, [agents, currentPage, pageSize]);
return filteredAgents.slice(startIndex, startIndex + pageSize);
}, [filteredAgents, currentPage, pageSize]);
if (agents.length === 0) return <AssistantEmpty />;
// Reset to page 1 when filter or search changes
useMemo(() => {
setCurrentPage(1);
}, [statusFilter, searchQuery]);
const showPagination = agents.length > pageSize;
if (agents.length === 0 && forkedAgents.length === 0) return <AssistantEmpty />;
const showPagination = filteredAgents.length > pageSize;
return (
<Flexbox gap={16}>
<Flexbox align={'center'} gap={8} horizontal>
<Text fontSize={16} weight={500}>
{t('user.publishedAgents')}
</Text>
{agentCount > 0 && <Tag>{agentCount}</Tag>}
<Flexbox align={'center'} gap={8} horizontal justify={'space-between'}>
<Flexbox align={'center'} gap={8} horizontal>
<Text fontSize={16} weight={500}>
{t('user.publishedAgents')}
</Text>
{agentCount > 0 && <Tag>{filteredAgents.length}</Tag>}
</Flexbox>
{isOwner && (
<Flexbox align={'center'} gap={8} horizontal>
<Input.Search
allowClear
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('user.searchPlaceholder')}
style={{ width: 200 }}
value={searchQuery}
/>
<StatusFilter
onChange={(value) => setStatusFilter(value)}
value={statusFilter}
/>
</Flexbox>
)}
</Flexbox>
<Grid rows={rows} width={'100%'}>
{paginatedAgents.map((item, index) => (
@@ -48,7 +104,7 @@ const UserAgentList = memo<UserAgentListProps>(({ rows = 4, pageSize = 10 }) =>
onChange={(page) => setCurrentPage(page)}
pageSize={pageSize}
showSizeChanger={false}
total={agents.length}
total={filteredAgents.length}
/>
</Flexbox>
)}
@@ -3,25 +3,14 @@
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useUserDetailContext } from './DetailProvider';
import UserAgentList from './UserAgentList';
import UserFavoriteAgents from './UserFavoriteAgents';
import UserFavoritePlugins from './UserFavoritePlugins';
import UserForkedAgentGroups from './UserForkedAgentGroups';
import UserForkedAgents from './UserForkedAgents';
import UserGroupList from './UserGroupList';
const UserContent = memo(() => {
const { forkedAgents, forkedAgentGroups } = useUserDetailContext();
return (
<Flexbox gap={32}>
<UserAgentList />
<UserGroupList />
<UserForkedAgents agents={forkedAgents} />
<UserForkedAgentGroups agentGroups={forkedAgentGroups} />
<UserFavoriteAgents />
<UserFavoritePlugins />
</Flexbox>
);
});
@@ -1,11 +1,12 @@
'use client';
import { Flexbox, Grid, Tag, Text } from '@lobehub/ui';
import { Pagination } from 'antd';
import { Input, Pagination } from 'antd';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserDetailContext } from './DetailProvider';
import StatusFilter, { type StatusFilterValue } from './StatusFilter';
import UserGroupCard from './UserGroupCard';
interface UserGroupListProps {
@@ -13,28 +14,81 @@ interface UserGroupListProps {
rows?: number;
}
const UserGroupList = memo<UserGroupListProps>(({ rows = 4, pageSize = 10 }) => {
const UserGroupList = memo<UserGroupListProps>(({ rows = 4, pageSize = 8 }) => {
const { t } = useTranslation('discover');
const { agentGroups, groupCount } = useUserDetailContext();
const { agentGroups = [], groupCount, forkedAgentGroups = [], favoriteAgentGroups = [], isOwner } = useUserDetailContext();
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilterValue>('published');
const [searchQuery, setSearchQuery] = useState('');
// Combine groups and forked groups, then filter based on status and search
const filteredGroups = useMemo(() => {
let allGroups = [...agentGroups];
if (statusFilter === 'forked') {
// Show only forked groups (those with forkedFromAgentId)
allGroups = forkedAgentGroups;
} else if (statusFilter === 'favorite') {
// Show only favorited groups
allGroups = favoriteAgentGroups;
} else {
// Filter by status for non-forked groups
allGroups = allGroups.filter((group) => {
return group.status === statusFilter;
});
}
// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
allGroups = allGroups.filter((group) => {
const name = group?.title?.toLowerCase() || '';
const description = group?.description?.toLowerCase() || '';
return name.includes(query) || description.includes(query);
});
}
return allGroups;
}, [agentGroups, forkedAgentGroups, statusFilter, searchQuery]);
const paginatedGroups = useMemo(() => {
if (!agentGroups) return [];
const startIndex = (currentPage - 1) * pageSize;
return agentGroups.slice(startIndex, startIndex + pageSize);
}, [agentGroups, currentPage, pageSize]);
return filteredGroups.slice(startIndex, startIndex + pageSize);
}, [filteredGroups, currentPage, pageSize]);
if (!agentGroups || agentGroups.length === 0) return null;
// Reset to page 1 when filter or search changes
useMemo(() => {
setCurrentPage(1);
}, [statusFilter, searchQuery]);
const showPagination = agentGroups.length > pageSize;
if (agentGroups.length === 0 && forkedAgentGroups.length === 0) return null;
const showPagination = filteredGroups.length > pageSize;
return (
<Flexbox gap={16}>
<Flexbox align={'center'} gap={8} horizontal>
<Text fontSize={16} weight={500}>
{t('user.publishedGroups', { defaultValue: '创作的群组' })}
</Text>
{groupCount > 0 && <Tag>{groupCount}</Tag>}
<Flexbox align={'center'} gap={8} horizontal justify={'space-between'}>
<Flexbox align={'center'} gap={8} horizontal>
<Text fontSize={16} weight={500}>
{t('user.publishedGroups', { defaultValue: '创作的群组' })}
</Text>
{groupCount > 0 && <Tag>{filteredGroups.length}</Tag>}
</Flexbox>
{isOwner && (
<Flexbox align={'center'} gap={8} horizontal>
<Input.Search
allowClear
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('user.searchPlaceholder')}
style={{ width: 200 }}
value={searchQuery}
/>
<StatusFilter
onChange={(value) => setStatusFilter(value)}
value={statusFilter}
/>
</Flexbox>
)}
</Flexbox>
<Grid rows={rows} width={'100%'}>
{paginatedGroups.map((item, index) => (
@@ -48,7 +102,7 @@ const UserGroupList = memo<UserGroupListProps>(({ rows = 4, pageSize = 10 }) =>
onChange={(page) => setCurrentPage(page)}
pageSize={pageSize}
showSizeChanger={false}
total={agentGroups.length}
total={filteredGroups.length}
/>
</Flexbox>
)}
@@ -61,12 +61,14 @@ const UserDetailPage = memo<UserDetailPageProps>(({ mobile }) => {
const contextConfig = useMemo(() => {
if (!data || !data.user) return null;
const { user, agents, agentGroups, forkedAgents, forkedAgentGroups } = data;
const { user, agents, agentGroups, forkedAgents, forkedAgentGroups, favoriteAgents, favoriteAgentGroups } = data;
const totalInstalls = agents.reduce((sum, agent) => sum + (agent.installCount || 0), 0);
return {
agentCount: agents.length,
agentGroups: agentGroups || [],
agents,
favoriteAgentGroups: favoriteAgentGroups || [],
favoriteAgents: favoriteAgents || [],
forkedAgentGroups: forkedAgentGroups || [],
forkedAgents: forkedAgents || [],
groupCount: agentGroups?.length || 0,
@@ -56,6 +56,7 @@ const styles = createStaticStyles(({ css, cssVar }) => {
const AssistantItem = memo<DiscoverAssistantItem>(
({
createdAt,
updatedAt,
author,
avatar,
title,
@@ -225,7 +226,7 @@ const AssistantItem = memo<DiscoverAssistantItem>(
<Icon icon={ClockIcon} size={14} />
<PublishedTime
className={styles.secondaryDesc}
date={createdAt}
date={updatedAt || createdAt}
template={'MMM DD, YYYY'}
/>
</Flexbox>
@@ -1,11 +1,15 @@
import { Button } from '@lobehub/ui';
import { ShapesUploadIcon } from '@lobehub/ui/icons';
import { Popconfirm } from 'antd';
import isEqual from 'fast-deep-equal';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { resolveMarketAuthError } from '@/layout/AuthProvider/MarketAuth/errors';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import GroupForkConfirmModal from './GroupForkConfirmModal';
import type { MarketPublishAction, OriginalGroupInfo } from './types';
@@ -25,10 +29,17 @@ const PublishButton = memo<GroupPublishButtonProps>(({ action, onPublishSuccess
onSuccess: onPublishSuccess,
});
// Group data for validation
const currentGroupMeta = useAgentGroupStore(agentGroupSelectors.currentGroupMeta, isEqual);
const currentGroup = useAgentGroupStore(agentGroupSelectors.currentGroup);
// Fork confirmation modal state
const [showForkModal, setShowForkModal] = useState(false);
const [originalGroupInfo, setOriginalGroupInfo] = useState<OriginalGroupInfo | null>(null);
// Publish confirmation popconfirm state
const [confirmOpened, setConfirmOpened] = useState(false);
const buttonConfig = useMemo(() => {
if (action === 'upload') {
return {
@@ -60,7 +71,25 @@ const PublishButton = memo<GroupPublishButtonProps>(({ action, onPublishSuccess
await publish();
}, [checkOwnership, publish]);
const handleButtonClick = useCallback(async () => {
const handleButtonClick = useCallback(() => {
// Validate name and systemRole (stored in content)
if (!currentGroupMeta?.title || currentGroupMeta.title.trim() === '') {
message.error({ content: t('marketPublish.validation.emptyName') });
return;
}
if (!currentGroup?.content || currentGroup.content.trim() === '') {
message.error({ content: t('marketPublish.validation.emptySystemRole') });
return;
}
// Open popconfirm for user confirmation
setConfirmOpened(true);
}, [currentGroupMeta?.title, currentGroup?.content, t]);
const handleConfirmPublish = useCallback(async () => {
setConfirmOpened(false);
if (!isAuthenticated) {
try {
await signIn();
@@ -98,14 +127,29 @@ const PublishButton = memo<GroupPublishButtonProps>(({ action, onPublishSuccess
return (
<>
<Button
icon={ShapesUploadIcon}
loading={loading}
onClick={handleButtonClick}
title={buttonTitle}
<Popconfirm
arrow={false}
okButtonProps={{ type: 'primary' }}
onCancel={() => setConfirmOpened(false)}
onConfirm={handleConfirmPublish}
onOpenChange={(open) => {
if (!open) {
setConfirmOpened(false);
}
}}
open={confirmOpened}
placement="bottomRight"
title={t('marketPublish.validation.confirmPublish')}
>
{t('publishToCommunity')}
</Button>
<Button
icon={ShapesUploadIcon}
loading={loading}
onClick={handleButtonClick}
title={buttonTitle}
>
{t('publishToCommunity')}
</Button>
</Popconfirm>
<GroupForkConfirmModal
loading={isPublishing}
onCancel={handleForkCancel}
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { modal, notification } from '@/components/AntdStaticMethods';
import AuthIcons from '@/components/AuthIcons';
import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
import { useUserStore } from '@/store/user';
@@ -33,8 +34,11 @@ export const SSOProvidersList = memo(() => {
}, [providers]);
// Get available providers for linking (filter out already linked)
// Normalize provider IDs when comparing to handle aliases (e.g. microsoft-entra-id → microsoft)
const availableProviders = useMemo(() => {
return (oAuthSSOProviders || []).filter((provider) => !linkedProviderIds.has(provider));
return (oAuthSSOProviders || []).filter(
(provider) => !linkedProviderIds.has(normalizeProviderId(provider)),
);
}, [oAuthSSOProviders, linkedProviderIds]);
const handleUnlinkSSO = async (provider: string) => {
@@ -63,14 +67,24 @@ export const SSOProvidersList = memo(() => {
};
const handleLinkSSO = async (provider: string) => {
if (enableAuthActions) {
// Use better-auth native linkSocial API
const { linkSocial } = await import('@/libs/better-auth/auth-client');
if (!enableAuthActions) return;
const normalizedProvider = normalizeProviderId(provider);
const { linkSocial, oauth2 } = await import('@/libs/better-auth/auth-client');
if (isBuiltinProvider(normalizedProvider)) {
// Use better-auth native linkSocial API for built-in providers
await linkSocial({
callbackURL: '/profile',
provider: provider as any,
provider: normalizedProvider as any,
});
return;
}
await oauth2.link({
callbackURL: '/profile',
providerId: normalizedProvider,
});
};
// Dropdown menu items for linking new providers
@@ -59,6 +59,7 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
const isLoadedAuthProviders = useUserStore(authSelectors.isLoadedAuthProviders);
const fetchAuthProviders = useUserStore((s) => s.fetchAuthProviders);
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const disableEmailPassword = useServerConfigStore(serverConfigSelectors.disableEmailPassword);
const [servers, isServersInit, useFetchUserKlavisServers] = useToolStore((s) => [
s.servers,
s.isServersInit,
@@ -113,7 +114,7 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
<InterestsRow mobile={mobile} />
{/* Password Row - For logged in users to change or set password */}
{!isDesktop && isLogin && (
{!isDesktop && isLogin && !disableEmailPassword && (
<>
<Divider style={{ margin: 0 }} />
<PasswordRow mobile={mobile} />
+6
View File
@@ -33,8 +33,10 @@ declare global {
AUTH_COGNITO_REGION?: string;
AUTH_COGNITO_USERPOOL_ID?: string;
AUTH_MICROSOFT_AUTHORITY_URL?: string;
AUTH_MICROSOFT_ID?: string;
AUTH_MICROSOFT_SECRET?: string;
AUTH_MICROSOFT_TENANT_ID?: string;
AUTH_AUTH0_ID?: string;
AUTH_AUTH0_SECRET?: string;
@@ -132,8 +134,10 @@ export const getAuthConfig = () => {
AUTH_COGNITO_REGION: z.string().optional(),
AUTH_COGNITO_USERPOOL_ID: z.string().optional(),
AUTH_MICROSOFT_AUTHORITY_URL: z.string().optional(),
AUTH_MICROSOFT_ID: z.string().optional(),
AUTH_MICROSOFT_SECRET: z.string().optional(),
AUTH_MICROSOFT_TENANT_ID: z.string().optional(),
AUTH_AUTH0_ID: z.string().optional(),
AUTH_AUTH0_SECRET: z.string().optional(),
@@ -219,8 +223,10 @@ export const getAuthConfig = () => {
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
AUTH_MICROSOFT_AUTHORITY_URL: process.env.AUTH_MICROSOFT_AUTHORITY_URL,
AUTH_MICROSOFT_ID: process.env.AUTH_MICROSOFT_ID,
AUTH_MICROSOFT_SECRET: process.env.AUTH_MICROSOFT_SECRET,
AUTH_MICROSOFT_TENANT_ID: process.env.AUTH_MICROSOFT_TENANT_ID,
AUTH_COGNITO_ID: process.env.AUTH_COGNITO_ID,
AUTH_COGNITO_SECRET: process.env.AUTH_COGNITO_SECRET,
@@ -20,10 +20,12 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
`,
paramKey: css`
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
paramValue: css`
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
root: css`
@@ -10,9 +10,10 @@ import {
Tabs,
type TabsProps,
} from '@lobehub/ui';
import { App } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ChevronDown, ChevronUp, History, Sparkles } from 'lucide-react';
import { ChevronDown, ChevronUp, History, Sparkles, Undo2 } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -66,6 +67,7 @@ export interface CompressedGroupMessageProps {
const CompressedGroupMessage = memo<CompressedGroupMessageProps>(({ id }) => {
const { t } = useTranslation('chat');
const { modal } = App.useApp();
const [activeTab, setActiveTab] = useState<string>(() => getStoredTab(id));
const handleTabChange = useCallback(
@@ -80,6 +82,16 @@ const CompressedGroupMessage = memo<CompressedGroupMessageProps>(({ id }) => {
const toggleCompressedGroupExpanded = useConversationStore(
(s) => s.toggleCompressedGroupExpanded,
);
const cancelCompression = useConversationStore((s) => s.cancelCompression);
const handleCancelCompression = useCallback(() => {
modal.confirm({
centered: true,
content: t('compression.cancelConfirm'),
onOk: () => cancelCompression(id),
title: t('compression.cancel'),
});
}, [id, cancelCompression, modal, t]);
const content = message?.content;
const rawCompressedMessages = (message as UIChatMessage)?.compressedMessages;
@@ -145,11 +157,19 @@ const CompressedGroupMessage = memo<CompressedGroupMessageProps>(({ id }) => {
onChange={handleTabChange}
variant={'rounded'}
/>
<ActionIcon
icon={expanded ? ChevronUp : ChevronDown}
onClick={() => toggleCompressedGroupExpanded(id)}
size={'small'}
/>
<Flexbox gap={4} horizontal>
<ActionIcon
icon={Undo2}
onClick={handleCancelCompression}
size={'small'}
title={t('compression.cancel')}
/>
<ActionIcon
icon={expanded ? ChevronUp : ChevronDown}
onClick={() => toggleCompressedGroupExpanded(id)}
size={'small'}
/>
</Flexbox>
</Flexbox>
)}
{!showContent ? null : activeTab === 'summary' ? (

Some files were not shown because too many files have changed in this diff Show More