Compare commits

...

151 Commits

Author SHA1 Message Date
arvinxx 4fa6d64df2 add plugin settings 2025-07-29 14:48:46 +08:00
Zhijie He 403aebd52e 💄 style: add more OpenAI SDK Text2Image providers (#8573) 2025-07-29 14:45:19 +08:00
LobeHub Bot 356cf0c392 💄 style: update i18n (#8593)
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-07-29 14:37:29 +08:00
Arvin Xu be98d56ef4 ♻️ refactor: refactor jose-JWT to xor obfuscation (#8595)
* refactor jose-jwt to xor obfuscation

* rename JWTPayload type to ClientSecretPayload

* fix tests

* revert next version
2025-07-29 14:37:01 +08:00
lobehubbot 3fae1b2638 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-29 04:07:54 +00:00
semantic-release-bot ee4cc6c2e0 🔖 chore(release): v1.105.1 [skip ci]
### [Version&nbsp;1.105.1](https://github.com/lobehub/lobe-chat/compare/v1.105.0...v1.105.1)
<sup>Released on **2025-07-29**</sup>

#### 💄 Styles

- **misc**: Support more Text2Image from Qwen.

<br/>

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

#### Styles

* **misc**: Support more Text2Image from Qwen, closes [#8574](https://github.com/lobehub/lobe-chat/issues/8574) ([b8c0e2d](https://github.com/lobehub/lobe-chat/commit/b8c0e2d))

</details>

<div align="right">

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

</div>
2025-07-29 04:07:02 +00:00
Zhijie He b8c0e2d639 💄 style: support more Text2Image from Qwen (#8574) 2025-07-29 11:51:40 +08:00
lobehubbot 74d20bdbe8 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-28 09:12:32 +00:00
semantic-release-bot 7bab44e74c 🔖 chore(release): v1.105.0 [skip ci]
## [Version&nbsp;1.105.0](https://github.com/lobehub/lobe-chat/compare/v1.104.5...v1.105.0)
<sup>Released on **2025-07-28**</sup>

####  Features

- **misc**: Implement API Key management functionality.

<br/>

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

#### What's improved

* **misc**: Implement API Key management functionality, closes [#8535](https://github.com/lobehub/lobe-chat/issues/8535) ([fdaa725](https://github.com/lobehub/lobe-chat/commit/fdaa725))

</details>

<div align="right">

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

</div>
2025-07-28 09:11:38 +00:00
Zephyr fdaa72564c feat: Implement API Key management functionality (#8535)
*  feat: Implement API Key management functionality

- Added new components for API Key management including creation, deletion, and display.
- Introduced a new database schema for storing API Keys.
- Implemented server and client services for API Key operations.
- Integrated API Key management into the profile section with appropriate routing and feature flags.
- Enhanced localization support for API Key related UI elements.

This commit lays the groundwork for managing API Keys within the application, allowing users to create, view, and manage their keys effectively.

* fix: server config unit test

*  feat(database): Create api_keys table with conditional existence check

- Added a conditional check to create the "api_keys" table only if it does not already exist.
- Ensured the foreign key constraint for "user_id" references the "users" table remains intact.

This change enhances the migration process by preventing errors during table creation if the table already exists.

* feat: Implement API Key management interface

- Introduced a new Client component for managing API keys, including creation, updating, and deletion functionalities.
- Replaced the previous page component with the new Client component in the API key management page.
- Removed obsolete client and server service files related to API key management, streamlining the service layer.

This update enhances the user experience by providing a dedicated interface for API key operations.
2025-07-28 16:55:57 +08:00
lobehubbot 7d85151cb6 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-28 08:53:48 +00:00
semantic-release-bot 23ef2eea59 🔖 chore(release): v1.104.5 [skip ci]
### [Version&nbsp;1.104.5](https://github.com/lobehub/lobe-chat/compare/v1.104.4...v1.104.5)
<sup>Released on **2025-07-28**</sup>

#### 💄 Styles

- **misc**: Fix setting window layout when in desktop was disappear.

<br/>

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

#### Styles

* **misc**: Fix setting window layout when in desktop was disappear, closes [#8585](https://github.com/lobehub/lobe-chat/issues/8585) ([74ab822](https://github.com/lobehub/lobe-chat/commit/74ab822))

</details>

<div align="right">

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

</div>
2025-07-28 08:52:59 +00:00
Shinji-Li 74ab822140 💄 style: fix setting window layout when in desktop was disappear (#8585) 2025-07-28 16:37:45 +08:00
lobehubbot d726ff108d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-28 02:37:48 +00:00
semantic-release-bot dd7b661140 🔖 chore(release): v1.104.4 [skip ci]
### [Version&nbsp;1.104.4](https://github.com/lobehub/lobe-chat/compare/v1.104.3...v1.104.4)
<sup>Released on **2025-07-28**</sup>

#### 💄 Styles

- **misc**: Fix setting window layout size, update i18n.

<br/>

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

#### Styles

* **misc**: Fix setting window layout size, closes [#8483](https://github.com/lobehub/lobe-chat/issues/8483) ([4902341](https://github.com/lobehub/lobe-chat/commit/4902341))
* **misc**: Update i18n, closes [#8579](https://github.com/lobehub/lobe-chat/issues/8579) ([2eccbc7](https://github.com/lobehub/lobe-chat/commit/2eccbc7))

</details>

<div align="right">

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

</div>
2025-07-28 02:36:53 +00:00
Shinji-Li 49023419cf 💄 style: fix setting window layout size (#8483) 2025-07-28 10:21:30 +08:00
LobeHub Bot 2eccbc79eb 💄 style: update i18n (#8579)
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-07-28 10:19:46 +08:00
lobehubbot 3527cb65f1 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-26 09:48:50 +00:00
semantic-release-bot 1731c841d8 🔖 chore(release): v1.104.3 [skip ci]
### [Version&nbsp;1.104.3](https://github.com/lobehub/lobe-chat/compare/v1.104.2...v1.104.3)
<sup>Released on **2025-07-26**</sup>

#### 💄 Styles

- **misc**: Add Gemini 2.5 Flash-Lite GA model.

<br/>

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

#### Styles

* **misc**: Add Gemini 2.5 Flash-Lite GA model, closes [#8539](https://github.com/lobehub/lobe-chat/issues/8539) ([404ac21](https://github.com/lobehub/lobe-chat/commit/404ac21))

</details>

<div align="right">

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

</div>
2025-07-26 09:47:57 +00:00
afon 404ac21229 💄 style: Add Gemini 2.5 Flash-Lite GA model (#8539) 2025-07-26 17:32:34 +08:00
Arvin Xu 2eaa2dbea0 🔨 chore: add react scan debugger and bump deps (#8576)
* add REACT_SCAN debug

* upgrade lobehub/ui

* clean

* head

* update
2025-07-26 16:32:55 +08:00
lobehubbot 50c0ed168d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-26 07:32:43 +00:00
semantic-release-bot 780e231afa 🔖 chore(release): v1.104.2 [skip ci]
### [Version&nbsp;1.104.2](https://github.com/lobehub/lobe-chat/compare/v1.104.1...v1.104.2)
<sup>Released on **2025-07-26**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix update hotkey invalid when input mod in desktop.

<br/>

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

#### What's fixed

* **misc**: Fix update hotkey invalid when input mod in desktop, closes [#8572](https://github.com/lobehub/lobe-chat/issues/8572) ([07f3e6a](https://github.com/lobehub/lobe-chat/commit/07f3e6a))

</details>

<div align="right">

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

</div>
2025-07-26 07:31:53 +00:00
Arvin Xu 07f3e6a4c4 🐛 fix: fix update hotkey invalid when input mod in desktop (#8572)
* fix hotkey with mod

* fix invalid hotkeys

* add tests
2025-07-26 15:16:45 +08:00
lobehubbot 3c35edced5 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-25 09:59:34 +00:00
semantic-release-bot 15770f188f 🔖 chore(release): v1.104.1 [skip ci]
### [Version&nbsp;1.104.1](https://github.com/lobehub/lobe-chat/compare/v1.104.0...v1.104.1)
<sup>Released on **2025-07-25**</sup>

#### 🐛 Bug Fixes

- **misc**: Update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider.

<br/>

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

#### What's fixed

* **misc**: Update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider, closes [#8557](https://github.com/lobehub/lobe-chat/issues/8557) ([d1e4a54](https://github.com/lobehub/lobe-chat/commit/d1e4a54))

</details>

<div align="right">

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

</div>
2025-07-25 09:58:34 +00:00
YuTengjing 9d7c6014fd chore: improve image display quality (#8571) 2025-07-25 17:42:23 +08:00
sxjeru d1e4a54b01 🐛 fix: update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider (#8557) 2025-07-25 16:16:54 +08:00
lobehubbot 1bc8815fb4 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-24 17:20:43 +00:00
semantic-release-bot 284826bed0 🔖 chore(release): v1.104.0 [skip ci]
## [Version&nbsp;1.104.0](https://github.com/lobehub/lobe-chat/compare/v1.103.2...v1.104.0)
<sup>Released on **2025-07-24**</sup>

####  Features

- **misc**: Support custom hotkey on desktop.

<br/>

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

#### What's improved

* **misc**: Support custom hotkey on desktop, closes [#8559](https://github.com/lobehub/lobe-chat/issues/8559) ([b50f121](https://github.com/lobehub/lobe-chat/commit/b50f121))

</details>

<div align="right">

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

</div>
2025-07-24 17:19:53 +00:00
Arvin Xu b50f1212cb feat: support custom hotkey on desktop (#8559)
* support custom hotkey

* update tests

* clean

* fix tests
2025-07-25 01:04:18 +08:00
lobehubbot 946517a52e 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-24 04:20:29 +00:00
semantic-release-bot d30cc62acf 🔖 chore(release): v1.103.2 [skip ci]
### [Version&nbsp;1.103.2](https://github.com/lobehub/lobe-chat/compare/v1.103.1...v1.103.2)
<sup>Released on **2025-07-24**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix chat stream in desktop and update shortcut.

#### 💄 Styles

- **misc**: Add cached token count to usage of GoogleAI and VertexAI, fix desktop titlebar style in window, fix sub topic width in md responsive.

<br/>

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

#### What's fixed

* **misc**: Fix chat stream in desktop and update shortcut, closes [#8520](https://github.com/lobehub/lobe-chat/issues/8520) ([0192140](https://github.com/lobehub/lobe-chat/commit/0192140))

#### Styles

* **misc**: Add cached token count to usage of GoogleAI and VertexAI, closes [#8545](https://github.com/lobehub/lobe-chat/issues/8545) ([66dbb24](https://github.com/lobehub/lobe-chat/commit/66dbb24))
* **misc**: Fix desktop titlebar style in window, closes [#8439](https://github.com/lobehub/lobe-chat/issues/8439) ([fd7662c](https://github.com/lobehub/lobe-chat/commit/fd7662c))
* **misc**: Fix sub topic width in md responsive, closes [#8443](https://github.com/lobehub/lobe-chat/issues/8443) ([9bae13b](https://github.com/lobehub/lobe-chat/commit/9bae13b))

</details>

<div align="right">

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

</div>
2025-07-24 04:19:17 +00:00
Arvin Xu 0192140909 🐛 fix: fix chat stream in desktop and update shortcut (#8520)
* disable nginx chunk buffering

* make setting keep alive

* update shortcut

* update shortcut

* fix lint
2025-07-24 12:04:06 +08:00
Shinji-Li 9bae13b6c1 💄 style: fix sub topic width in md responsive (#8443) 2025-07-24 11:02:38 +08:00
CanisMinor fd7662c3ac 💄 style: fix desktop titlebar style in window (#8439)
* 💄 style: Fix win electron style

📝 docs: Update readme

💄 style: Update useWatchThemeUpdate

💄 style: Update Tray icon

🔧 chore: Update windows

🔧 chore: Update filetree

🔧 chore: Update core

💄 style: Fix desktop draw style

💄 style: Update style

💄 style: Fix backgroundColor

💄 style: Update titlebar style

* 💄 style: Fix windows icon

* 🔧 chore: Clean

* update theme

* 💄 style: Update broswer

* 💄 style: HandleAppThemeChange

* clean

* fix memory leak

---------

Co-authored-by: arvinxx <arvinx@foxmail.com>
2025-07-24 11:02:07 +08:00
afon 66dbb246d9 💄 style: Add cached token count to usage of GoogleAI and VertexAI (#8545) 2025-07-24 10:56:03 +08:00
Jamie Stivala 3a2935a6e3 🔨 chore: fix an issue where some tests were failing if Branding items were updated (#8549)
* Update test data for plugin action to use avatar icon path (rather than hard coded)

* Update tests to use BRANDING_NAME constant instead of hardcoded 'LobeChat' and update avatar icon path in chat message tests

* Update tests to replace hardcoded avatar paths with constants for inbox and user avatars

* Update plugin action tests to use DEFAULT_INBOX_AVATAR constant instead of hardcoded path
2025-07-24 10:54:42 +08:00
YuTengjing 82ca0074d4 🔨 chore: some ai image optimization (#8543) 2025-07-23 15:33:20 +08:00
lobehubbot 225d3b6ed5 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-23 05:34:17 +00:00
semantic-release-bot 66984d9418 🔖 chore(release): v1.103.1 [skip ci]
### [Version&nbsp;1.103.1](https://github.com/lobehub/lobe-chat/compare/v1.103.0...v1.103.1)
<sup>Released on **2025-07-23**</sup>

#### 💄 Styles

- **misc**: Update i18n.

<br/>

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

#### Styles

* **misc**: Update i18n, closes [#8537](https://github.com/lobehub/lobe-chat/issues/8537) ([b16f19b](https://github.com/lobehub/lobe-chat/commit/b16f19b))

</details>

<div align="right">

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

</div>
2025-07-23 05:33:24 +00:00
LobeHub Bot b16f19bfed 💄 style: update i18n (#8537)
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-07-23 13:17:56 +08:00
huangkairan f61b222e9e 📝 docs: fix desktop & mcp mdx (#8541) 2025-07-23 13:17:30 +08:00
lobehubbot 368f7dbbc1 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-22 14:59:13 +00:00
semantic-release-bot 9c2350e643 🔖 chore(release): v1.103.0 [skip ci]
## [Version&nbsp;1.103.0](https://github.com/lobehub/lobe-chat/compare/v1.102.4...v1.103.0)
<sup>Released on **2025-07-22**</sup>

####  Features

- **misc**: Add Qwen image generation capabilities.

<br/>

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

#### What's improved

* **misc**: Add Qwen image generation capabilities, closes [#8534](https://github.com/lobehub/lobe-chat/issues/8534) ([7e8e5ef](https://github.com/lobehub/lobe-chat/commit/7e8e5ef))

</details>

<div align="right">

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

</div>
2025-07-22 14:58:22 +00:00
YuTengjing 7e8e5ef5b2 feat: add Qwen image generation capabilities (#8534) 2025-07-22 22:43:10 +08:00
lobehubbot 0ed9e7d947 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-22 06:02:40 +00:00
semantic-release-bot 19cc6e8562 🔖 chore(release): v1.102.4 [skip ci]
### [Version&nbsp;1.102.4](https://github.com/lobehub/lobe-chat/compare/v1.102.3...v1.102.4)
<sup>Released on **2025-07-22**</sup>

#### ♻ Code Refactoring

- **misc**: Add badge and improve document.

#### 💄 Styles

- **misc**: Update tray icon.

<br/>

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

#### Code refactoring

* **misc**: Add badge and improve document, closes [#8528](https://github.com/lobehub/lobe-chat/issues/8528) ([9fb4b0d](https://github.com/lobehub/lobe-chat/commit/9fb4b0d))

#### Styles

* **misc**: Update tray icon, closes [#8530](https://github.com/lobehub/lobe-chat/issues/8530) ([2696de4](https://github.com/lobehub/lobe-chat/commit/2696de4))

</details>

<div align="right">

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

</div>
2025-07-22 06:01:46 +00:00
Arvin Xu 2696de4078 💄 style: Update tray icon (#8530)
Co-authored-by: canisminor1990 <i@canisminor.cc>
2025-07-22 13:46:39 +08:00
Arvin Xu 9fb4b0dfc3 ♻️ refactor: add badge and improve document (#8528)
* update readme

* 📝 docs: Update readme

* 💄 style: Update PlanIcon

* improve tooltip placement

* improve tray

---------

Co-authored-by: canisminor1990 <i@canisminor.cc>
2025-07-22 13:43:57 +08:00
lobehubbot caffbbd384 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-22 03:38:51 +00:00
semantic-release-bot 8a89b1a14e 🔖 chore(release): v1.102.3 [skip ci]
### [Version&nbsp;1.102.3](https://github.com/lobehub/lobe-chat/compare/v1.102.2...v1.102.3)
<sup>Released on **2025-07-22**</sup>

#### 🐛 Bug Fixes

- **misc**: Remove debug logging from ModelRuntime and async caller.

<br/>

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

#### What's fixed

* **misc**: Remove debug logging from ModelRuntime and async caller, closes [#8525](https://github.com/lobehub/lobe-chat/issues/8525) ([dd1a635](https://github.com/lobehub/lobe-chat/commit/dd1a635))

</details>

<div align="right">

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

</div>
2025-07-22 03:37:59 +00:00
YuTengjing dd1a6356b7 🐛 fix: remove debug logging from ModelRuntime and async caller (#8525) 2025-07-22 11:23:05 +08:00
LovelyGuYiMeng d87919eba2 👷 build: add default app url for docker image to avoid building error (#8526) 2025-07-22 11:17:43 +08:00
lobehubbot 4bcf9822f7 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-22 03:12:31 +00:00
semantic-release-bot 9f2eea3d2f 🔖 chore(release): v1.102.2 [skip ci]
### [Version&nbsp;1.102.2](https://github.com/lobehub/lobe-chat/compare/v1.102.1...v1.102.2)
<sup>Released on **2025-07-22**</sup>

#### 💄 Styles

- **misc**: Add notification for desktop.

<br/>

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

#### Styles

* **misc**: Add notification for desktop, closes [#8523](https://github.com/lobehub/lobe-chat/issues/8523) ([4917d17](https://github.com/lobehub/lobe-chat/commit/4917d17))

</details>

<div align="right">

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

</div>
2025-07-22 03:11:41 +00:00
Arvin Xu 4917d175bb 💄 style: add notification for desktop (#8523)
* add notification for desktop

* update i18n

* fix tests
2025-07-22 10:56:17 +08:00
lobehubbot 5b53773dc6 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-21 16:01:39 +00:00
semantic-release-bot 46bdf21f37 🔖 chore(release): v1.102.1 [skip ci]
### [Version&nbsp;1.102.1](https://github.com/lobehub/lobe-chat/compare/v1.102.0...v1.102.1)
<sup>Released on **2025-07-21**</sup>

#### 🐛 Bug Fixes

- **groq**: Enable streaming for tool calls and add Kimi K2 model.

#### 💄 Styles

- **misc**: Modal list header sticky style.

<br/>

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

#### What's fixed

* **groq**: Enable streaming for tool calls and add Kimi K2 model, closes [#8510](https://github.com/lobehub/lobe-chat/issues/8510) ([60739bc](https://github.com/lobehub/lobe-chat/commit/60739bc))

#### Styles

* **misc**: Modal list header sticky style, closes [#8514](https://github.com/lobehub/lobe-chat/issues/8514) ([75273d5](https://github.com/lobehub/lobe-chat/commit/75273d5))

</details>

<div align="right">

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

</div>
2025-07-21 16:00:42 +00:00
afon 60739bc903 🐛 fix(groq): Enable streaming for tool calls and add Kimi K2 model (#8510) 2025-07-21 23:45:30 +08:00
Innei 75273d5497 💄 style: modal list header sticky style (#8514) 2025-07-21 23:43:25 +08:00
Innei 22e2de2b00 🔨 chore(typo): fix redirectUrl typo (#8513) 2025-07-21 23:42:53 +08:00
lobehubbot cbddf3ed25 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-21 15:28:30 +00:00
semantic-release-bot 14fd4bb2e7 🔖 chore(release): v1.102.0 [skip ci]
## [Version&nbsp;1.102.0](https://github.com/lobehub/lobe-chat/compare/v1.101.2...v1.102.0)
<sup>Released on **2025-07-21**</sup>

####  Features

- **misc**: Add image generation capabilities using Google AI Imagen API.

<br/>

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

#### What's improved

* **misc**: Add image generation capabilities using Google AI Imagen API, closes [#8503](https://github.com/lobehub/lobe-chat/issues/8503) ([cef8208](https://github.com/lobehub/lobe-chat/commit/cef8208))

</details>

<div align="right">

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

</div>
2025-07-21 15:27:38 +00:00
YuTengjing cef8208457 feat: add image generation capabilities using Google AI Imagen API (#8503) 2025-07-21 23:11:31 +08:00
lobehubbot 7d85a772db 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-21 06:00:46 +00:00
semantic-release-bot 83dd2865f0 🔖 chore(release): v1.101.2 [skip ci]
### [Version&nbsp;1.101.2](https://github.com/lobehub/lobe-chat/compare/v1.101.1...v1.101.2)
<sup>Released on **2025-07-21**</sup>

#### 💄 Styles

- **misc**: Fix lobehub provider `/chat` in desktop.

<br/>

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

#### Styles

* **misc**: Fix lobehub provider `/chat` in desktop, closes [#8508](https://github.com/lobehub/lobe-chat/issues/8508) ([c801f9c](https://github.com/lobehub/lobe-chat/commit/c801f9c))

</details>

<div align="right">

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

</div>
2025-07-21 05:59:47 +00:00
Arvin Xu c04507e34f 🔨 chore: fix model runtime test issue (#8511) 2025-07-21 13:44:54 +08:00
Arvin Xu c801f9ce58 💄 style: fix lobehub provider /chat in desktop (#8508) 2025-07-21 11:43:20 +08:00
vual ac2a83a3ce 👷 build: add default APP_URL for docker image to avoid building error (#8507) 2025-07-21 11:39:59 +08:00
lobehubbot 7bdda9bab4 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-19 17:19:58 +00:00
semantic-release-bot e8321355f8 🔖 chore(release): v1.101.1 [skip ci]
### [Version&nbsp;1.101.1](https://github.com/lobehub/lobe-chat/compare/v1.101.0...v1.101.1)
<sup>Released on **2025-07-19**</sup>

#### 🐛 Bug Fixes

- **misc**: Try fix authorization code exchange & pin next-auto to `beta.29`.

<br/>

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

#### What's fixed

* **misc**: Try fix authorization code exchange & pin next-auto to `beta.29`, closes [#8496](https://github.com/lobehub/lobe-chat/issues/8496) ([27c4881](https://github.com/lobehub/lobe-chat/commit/27c4881))

</details>

<div align="right">

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

</div>
2025-07-19 17:19:05 +00:00
Rylan Cai 27c4881205 🐛 fix: Try fix authorization code exchange & pin next-auto to beta.29 (#8496)
* 📌 pin: next-auth@beta.29

* 🐛 fix: infinite redirection
2025-07-20 01:03:35 +08:00
lobehubbot 472c40e969 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-19 07:32:42 +00:00
semantic-release-bot c98e7f8032 🔖 chore(release): v1.101.0 [skip ci]
## [Version&nbsp;1.101.0](https://github.com/lobehub/lobe-chat/compare/v1.100.2...v1.101.0)
<sup>Released on **2025-07-19**</sup>

####  Features

- **misc**: Add zhipu cogview4.

#### 🐛 Bug Fixes

- **misc**: Some ai image bugs.

<br/>

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

#### What's improved

* **misc**: Add zhipu cogview4, closes [#8486](https://github.com/lobehub/lobe-chat/issues/8486) ([0b1557d](https://github.com/lobehub/lobe-chat/commit/0b1557d))

#### What's fixed

* **misc**: Some ai image bugs, closes [#8490](https://github.com/lobehub/lobe-chat/issues/8490) ([5d852be](https://github.com/lobehub/lobe-chat/commit/5d852be))

</details>

<div align="right">

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

</div>
2025-07-19 07:31:47 +00:00
YuTengjing 0b1557d6ad feat: add zhipu cogview4 (#8486) 2025-07-18 23:01:12 +08:00
YuTengjing 5d852be8a2 🐛 fix: some ai image bugs (#8490) 2025-07-18 23:00:56 +08:00
lobehubbot 993b0fa81f 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-18 05:13:35 +00:00
semantic-release-bot 83783b4650 🔖 chore(release): v1.100.2 [skip ci]
### [Version&nbsp;1.100.2](https://github.com/lobehub/lobe-chat/compare/v1.100.1...v1.100.2)
<sup>Released on **2025-07-18**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix webapi proxy with clerk.

<br/>

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

#### What's fixed

* **misc**: Fix webapi proxy with clerk, closes [#8479](https://github.com/lobehub/lobe-chat/issues/8479) ([7dd65f0](https://github.com/lobehub/lobe-chat/commit/7dd65f0))

</details>

<div align="right">

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

</div>
2025-07-18 05:12:40 +00:00
Arvin Xu 7dd65f0cb4 🐛 fix: fix webapi proxy with clerk (#8479)
* fix webapi proxy with clerk

* Update jwt.ts
2025-07-18 12:56:55 +08:00
lobehubbot ecf1fdc2f7 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-17 18:57:38 +00:00
semantic-release-bot 71c33300cf 🔖 chore(release): v1.100.1 [skip ci]
### [Version&nbsp;1.100.1](https://github.com/lobehub/lobe-chat/compare/v1.100.0...v1.100.1)
<sup>Released on **2025-07-17**</sup>

#### 🐛 Bug Fixes

- **misc**: Use server env config image models.

<br/>

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

#### What's fixed

* **misc**: Use server env config image models, closes [#8478](https://github.com/lobehub/lobe-chat/issues/8478) ([768ee2b](https://github.com/lobehub/lobe-chat/commit/768ee2b))

</details>

<div align="right">

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

</div>
2025-07-17 18:56:46 +00:00
YuTengjing 768ee2bf23 🐛 fix: use server env config image models (#8478)
* docs: update fal provider invalid image links

* docs: add FAL model provider environment variables documentation

* 🐛 fix: update model type assignment in parseModels.ts to use dynamic lookup

* 📝 docs: add FAQ for resolving AI image generation timeout issues on Vercel

*  feat: implement getModelPropertyWithFallback utility for dynamic model property retrieval

* 📝 docs: expand testing guide with best practices for mock data strategies, error handling, and module pollution prevention

* 🐛 fix: update model type in LobeOpenAICompatibleFactory tests to 'chat'
2025-07-18 02:41:27 +08:00
lobehubbot d23d8f1c29 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-17 05:25:51 +00:00
semantic-release-bot 7eadecd340 🔖 chore(release): v1.100.0 [skip ci]
## [Version&nbsp;1.100.0](https://github.com/lobehub/lobe-chat/compare/v1.99.6...v1.100.0)
<sup>Released on **2025-07-17**</sup>

####  Features

- **misc**: Refactor desktop oauth and use JWTs token to support remote chat.

<br/>

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

#### What's improved

* **misc**: Refactor desktop oauth and use JWTs token to support remote chat, closes [#8446](https://github.com/lobehub/lobe-chat/issues/8446) ([054ca5f](https://github.com/lobehub/lobe-chat/commit/054ca5f))

</details>

<div align="right">

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

</div>
2025-07-17 05:24:42 +00:00
Arvin Xu 785406be9a 🔨 chore: improve code (#8469) 2025-07-17 13:09:41 +08:00
Arvin Xu 054ca5fd97 feat: refactor desktop oauth and use JWTs token to support remote chat (#8446)
* refactor the oauth

* refactor the oauth

* refactor the oauth

* improve oauth status

* fix desktop auth

* fix tests

* improve clean handoff

* try to fix handoff public issue

* fix route protection

* refactor anim

* refactor

* update to access token to jwt

* update to access token to jwt

* improve config

* refactor for JWKs token

* fix auto refresh issue

*  feat: support webapi proxy

* wip: 完成新流式接口

* wip: 跑通流式框架

* fix webhooks

* fix network proxy

* try to fix workflow

* fix proxy in remote sync

* fix tests

* fix tests

* fix oauth bypass route

* fix webapi proxy
2025-07-17 12:54:37 +08:00
lobehubbot 5d3a4ad460 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-16 18:05:16 +00:00
semantic-release-bot 8eb257bb2e 🔖 chore(release): v1.99.6 [skip ci]
### [Version&nbsp;1.99.6](https://github.com/lobehub/lobe-chat/compare/v1.99.5...v1.99.6)
<sup>Released on **2025-07-16**</sup>

#### 🐛 Bug Fixes

- **misc**: Desktop local db can't upload image.

<br/>

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

#### What's fixed

* **misc**: Desktop local db can't upload image, closes [#8459](https://github.com/lobehub/lobe-chat/issues/8459) ([25bfc80](https://github.com/lobehub/lobe-chat/commit/25bfc80))

</details>

<div align="right">

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

</div>
2025-07-16 18:04:24 +00:00
YuTengjing 25bfc802b5 🐛 fix: desktop local db can't upload image (#8459) 2025-07-17 01:49:11 +08:00
lobehubbot 752e576b80 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-16 13:57:35 +00:00
semantic-release-bot 166f3e2400 🔖 chore(release): v1.99.5 [skip ci]
### [Version&nbsp;1.99.5](https://github.com/lobehub/lobe-chat/compare/v1.99.4...v1.99.5)
<sup>Released on **2025-07-16**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix page error when url is not defined in web search plugin.

<br/>

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

#### What's fixed

* **misc**: Fix page error when url is not defined in web search plugin, closes [#8441](https://github.com/lobehub/lobe-chat/issues/8441) ([a55b65b](https://github.com/lobehub/lobe-chat/commit/a55b65b))

</details>

<div align="right">

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

</div>
2025-07-16 13:56:43 +00:00
vual a55b65b0b1 🐛 fix: fix page error when url is not defined in web search plugin (#8441)
Co-authored-by: Arvin Xu <arvinx@foxmail.com>
2025-07-16 21:41:14 +08:00
lobehubbot 3359b3f237 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-16 13:22:45 +00:00
semantic-release-bot 8ba8e8217b 🔖 chore(release): v1.99.4 [skip ci]
### [Version&nbsp;1.99.4](https://github.com/lobehub/lobe-chat/compare/v1.99.3...v1.99.4)
<sup>Released on **2025-07-16**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix apikey issue on server log.

<br/>

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

#### What's fixed

* **misc**: Fix apikey issue on server log, closes [#8457](https://github.com/lobehub/lobe-chat/issues/8457) ([43be2d1](https://github.com/lobehub/lobe-chat/commit/43be2d1))

</details>

<div align="right">

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

</div>
2025-07-16 13:21:54 +00:00
Arvin Xu 43be2d1905 🐛 fix: fix apikey issue on server log (#8457) 2025-07-16 21:06:51 +08:00
lobehubbot 53e0b51cbd 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-16 08:52:28 +00:00
semantic-release-bot c530000334 🔖 chore(release): v1.99.3 [skip ci]
### [Version&nbsp;1.99.3](https://github.com/lobehub/lobe-chat/compare/v1.99.2...v1.99.3)
<sup>Released on **2025-07-16**</sup>

#### 🐛 Bug Fixes

- **misc**: Chat model list should not show image model.

<br/>

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

#### What's fixed

* **misc**: Chat model list should not show image model, closes [#8448](https://github.com/lobehub/lobe-chat/issues/8448) ([2bb1506](https://github.com/lobehub/lobe-chat/commit/2bb1506))

</details>

<div align="right">

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

</div>
2025-07-16 08:51:36 +00:00
YuTengjing 2bb1506ea2 🐛 fix: chat model list should not show image model (#8448) 2025-07-16 16:36:43 +08:00
YuTengjing c8f32c301e ✏️ docs: replace all 'Language Model' with 'Al Service Provider' in provider docs (#8444) 2025-07-15 17:23:09 +08:00
lobehubbot 1261cee35d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-15 08:36:44 +00:00
semantic-release-bot 8c99c5dfd4 🔖 chore(release): v1.99.2 [skip ci]
### [Version&nbsp;1.99.2](https://github.com/lobehub/lobe-chat/compare/v1.99.1...v1.99.2)
<sup>Released on **2025-07-15**</sup>

#### 🐛 Bug Fixes

- **misc**: Some ai image generation feedback issues.

<br/>

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

#### What's fixed

* **misc**: Some ai image generation feedback issues, closes [#8440](https://github.com/lobehub/lobe-chat/issues/8440) ([bc41329](https://github.com/lobehub/lobe-chat/commit/bc41329))

</details>

<div align="right">

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

</div>
2025-07-15 08:35:52 +00:00
YuTengjing bc413299ba 🐛 fix: some ai image generation feedback issues (#8440) 2025-07-15 16:20:41 +08:00
lobehubbot f8369e1e3d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-15 08:00:07 +00:00
semantic-release-bot 8e4f59c25b 🔖 chore(release): v1.99.1 [skip ci]
### [Version&nbsp;1.99.1](https://github.com/lobehub/lobe-chat/compare/v1.99.0...v1.99.1)
<sup>Released on **2025-07-15**</sup>

<br/>

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

</details>

<div align="right">

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

</div>
2025-07-15 07:59:14 +00:00
Arvin Xu 28c1fe060a ️ perf: improve memory usage in desktop (#8431)
* improve usage of memory

* try to improve
2025-07-15 15:44:10 +08:00
YuTengjing 9557d79e33 🐛 fix: some ai image bugs (#8432) 2025-07-14 23:11:02 +08:00
lobehubbot 876df9ca08 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-14 12:51:05 +00:00
semantic-release-bot 025529d94f 🔖 chore(release): v1.99.0 [skip ci]
## [Version&nbsp;1.99.0](https://github.com/lobehub/lobe-chat/compare/v1.98.2...v1.99.0)
<sup>Released on **2025-07-14**</sup>

####  Features

- **plugin**: Support Streamable HTTP MCP Server Auth.
- **misc**:  support AI Image.

<br/>

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

#### What's improved

* **plugin**: Support Streamable HTTP MCP Server Auth, closes [#8425](https://github.com/lobehub/lobe-chat/issues/8425) ([853a09a](https://github.com/lobehub/lobe-chat/commit/853a09a))
* **misc**:  support AI Image, closes [#8312](https://github.com/lobehub/lobe-chat/issues/8312) ([095de57](https://github.com/lobehub/lobe-chat/commit/095de57))

</details>

<div align="right">

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

</div>
2025-07-14 12:50:05 +00:00
Arvin Xu 853a09af1b feat(plugin): support Streamable HTTP MCP Server Auth (#8425)
*  feat: support http streamable auth and headers

*  feat: support http streamable auth and headers

* improve

* improve token

* add i18n

* update i18n
2025-07-14 20:34:39 +08:00
YuTengjing 095de57675 feat: support AI Image (#8312)
Co-authored-by: canisminor1990 <i@canisminor.cc>
2025-07-14 20:29:57 +08:00
lobehubbot c98860e6cf 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-14 05:50:19 +00:00
semantic-release-bot f64f00fd6d 🔖 chore(release): v1.98.2 [skip ci]
### [Version&nbsp;1.98.2](https://github.com/lobehub/lobe-chat/compare/v1.98.1...v1.98.2)
<sup>Released on **2025-07-14**</sup>

#### 💄 Styles

- **misc**: Update i18n.

<br/>

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

#### Styles

* **misc**: Update i18n, closes [#8422](https://github.com/lobehub/lobe-chat/issues/8422) ([5b89ec8](https://github.com/lobehub/lobe-chat/commit/5b89ec8))

</details>

<div align="right">

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

</div>
2025-07-14 05:49:30 +00:00
LobeHub Bot 5b89ec8bd9 💄 style: update i18n (#8422)
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-07-14 13:35:27 +08:00
lobehubbot 3b37094dfb 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-14 03:01:40 +00:00
semantic-release-bot b2a2fe3617 🔖 chore(release): v1.98.1 [skip ci]
### [Version&nbsp;1.98.1](https://github.com/lobehub/lobe-chat/compare/v1.98.0...v1.98.1)
<sup>Released on **2025-07-14**</sup>

#### 💄 Styles

- **misc**: Fix discover translation.

<br/>

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

#### Styles

* **misc**: Fix discover translation, closes [#8423](https://github.com/lobehub/lobe-chat/issues/8423) ([15ae35c](https://github.com/lobehub/lobe-chat/commit/15ae35c))

</details>

<div align="right">

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

</div>
2025-07-14 03:00:53 +00:00
René Wang 15ae35ca2a 💄 style: fix discover translation (#8423) 2025-07-14 10:46:57 +08:00
lobehubbot e9e11cbbed 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-13 05:45:45 +00:00
semantic-release-bot 1c9c8d75ee 🔖 chore(release): v1.98.0 [skip ci]
## [Version&nbsp;1.98.0](https://github.com/lobehub/lobe-chat/compare/v1.97.17...v1.98.0)
<sup>Released on **2025-07-13**</sup>

####  Features

- **misc**: Add network proxy for desktop.

<br/>

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

#### What's improved

* **misc**: Add network proxy for desktop, closes [#7848](https://github.com/lobehub/lobe-chat/issues/7848) ([46d2509](https://github.com/lobehub/lobe-chat/commit/46d2509))

</details>

<div align="right">

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

</div>
2025-07-13 05:44:37 +00:00
renovate[bot] 95ac795573 Update dependency @anthropic-ai/sdk to ^0.56.0 (#8412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 13:30:10 +08:00
renovate[bot] a173f6e401 Update dependency lucide-react to ^0.525.0 (#8413)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 13:29:05 +08:00
Arvin Xu 37bb8ba2b7 🔨 chore: fix settings in desktop (#8414) 2025-07-13 13:26:22 +08:00
Arvin Xu 46d25092a4 feat: add network proxy for desktop (#7848)
* add network proxy

* update network proxy

* refactor network proxy

* support network proxy

* fix types

* fix lint

* fix lint
2025-07-13 13:19:21 +08:00
lobehubbot 88c86179ba 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-13 05:07:12 +00:00
semantic-release-bot 127d38d702 🔖 chore(release): v1.97.17 [skip ci]
### [Version&nbsp;1.97.17](https://github.com/lobehub/lobe-chat/compare/v1.97.16...v1.97.17)
<sup>Released on **2025-07-13**</sup>

#### 💄 Styles

- **misc**: Support Hunyuan A13B thinking model.

<br/>

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

#### Styles

* **misc**: Support Hunyuan A13B thinking model, closes [#8278](https://github.com/lobehub/lobe-chat/issues/8278) ([09ca978](https://github.com/lobehub/lobe-chat/commit/09ca978))

</details>

<div align="right">

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

</div>
2025-07-13 05:06:26 +00:00
sxjeru 09ca978f9c 💄 style: Support Hunyuan A13B thinking model (#8278)
* Update novita.ts

* feat: 添加新的混元模型并更新处理负载逻辑

* fix

* free Gemini 2.5 Pro

* 添加 ERNIE 4.5 300B A47B 模型到 siliconcloudChatModels

* novita

* 添加 GLM-4.1V-Thinking 系列模型到 zhipuChatModels,并更新现有模型的上下文窗口和最大输出设置

* 更新 zhipuChatModels,添加视觉能力并启用新模型;修改 ZhiPu 的检查模型 ID

* 移除多个 siliconcloud 废弃模型

* fix

* 移除 groq Qwen QwQ 32B 模型配置

* 更新 siliconcloud 模型

* 更新 novita/qwen 模型

* update siliconcloud model

* add Pangu Pro MoE 72B A16B

* Update novita.ts

* Update novita.ts

* update novita

---------

Co-authored-by: Arvin Xu <arvinx@foxmail.com>
2025-07-13 12:52:39 +08:00
lobehubbot 1696bf114b 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-13 03:48:21 +00:00
semantic-release-bot feb42fcc9e 🔖 chore(release): v1.97.16 [skip ci]
### [Version&nbsp;1.97.16](https://github.com/lobehub/lobe-chat/compare/v1.97.15...v1.97.16)
<sup>Released on **2025-07-13**</sup>

#### 💄 Styles

- **misc**: Update i18n.

<br/>

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

#### Styles

* **misc**: Update i18n, closes [#8410](https://github.com/lobehub/lobe-chat/issues/8410) ([2515875](https://github.com/lobehub/lobe-chat/commit/2515875))

</details>

<div align="right">

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

</div>
2025-07-13 03:47:33 +00:00
LobeHub Bot 25158750c0 💄 style: update i18n (#8410)
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-07-13 11:33:28 +08:00
lobehubbot d3d7a158fc 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-12 17:17:24 +00:00
semantic-release-bot 2212ea98ac 🔖 chore(release): v1.97.15 [skip ci]
### [Version&nbsp;1.97.15](https://github.com/lobehub/lobe-chat/compare/v1.97.14...v1.97.15)
<sup>Released on **2025-07-12**</sup>

#### 🐛 Bug Fixes

- **misc**: Add vision support to Grok 4.

<br/>

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

#### What's fixed

* **misc**: Add vision support to Grok 4, closes [#8386](https://github.com/lobehub/lobe-chat/issues/8386) ([8512f5a](https://github.com/lobehub/lobe-chat/commit/8512f5a))

</details>

<div align="right">

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

</div>
2025-07-12 17:16:32 +00:00
bbbugg 8512f5a9ae 🐛 fix: add vision support to Grok 4 (#8386)
*  feat: add vision support to Grok 4

*  feat: disable Grok 2 Vision 1212 model
2025-07-13 01:02:02 +08:00
lobehubbot a2d40643c7 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-12 17:00:04 +00:00
semantic-release-bot 2ef77567f7 🔖 chore(release): v1.97.14 [skip ci]
### [Version&nbsp;1.97.14](https://github.com/lobehub/lobe-chat/compare/v1.97.13...v1.97.14)
<sup>Released on **2025-07-12**</sup>

#### 🐛 Bug Fixes

- **misc**: Revert "💄 style: Open new topic by tap Just Chat again".

#### 💄 Styles

- **misc**: Add Kimi K2 model.

<br/>

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

#### What's fixed

* **misc**: Revert "💄 style: Open new topic by tap Just Chat again", closes [#8402](https://github.com/lobehub/lobe-chat/issues/8402) ([55462b9](https://github.com/lobehub/lobe-chat/commit/55462b9))

#### Styles

* **misc**: Add Kimi K2 model, closes [#8401](https://github.com/lobehub/lobe-chat/issues/8401) ([4cb1a18](https://github.com/lobehub/lobe-chat/commit/4cb1a18))

</details>

<div align="right">

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

</div>
2025-07-12 16:59:10 +00:00
Arvin Xu 755e878a44 🔨 chore: fix lint (#8407) 2025-07-13 00:45:15 +08:00
sxjeru 4cb1a185ff 💄 style: Add Kimi K2 model (#8401) 2025-07-13 00:10:22 +08:00
Arvin Xu 55462b95ff 🐛 fix: Revert "💄 style: Open new topic by tap Just Chat again" (#8402)
This reverts commit 7e2f4ce5dd.
2025-07-12 23:04:10 +08:00
lobehubbot 9a95886fe7 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-12 05:04:50 +00:00
semantic-release-bot fd44b088d6 🔖 chore(release): v1.97.13 [skip ci]
### [Version&nbsp;1.97.13](https://github.com/lobehub/lobe-chat/compare/v1.97.12...v1.97.13)
<sup>Released on **2025-07-12**</sup>

#### 💄 Styles

- **misc**: Support new Doubao thinking models, update i18n.

<br/>

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

#### Styles

* **misc**: Support new Doubao thinking models, closes [#8174](https://github.com/lobehub/lobe-chat/issues/8174) ([637d75c](https://github.com/lobehub/lobe-chat/commit/637d75c))
* **misc**: Update i18n, closes [#8400](https://github.com/lobehub/lobe-chat/issues/8400) ([790eeb8](https://github.com/lobehub/lobe-chat/commit/790eeb8))

</details>

<div align="right">

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

</div>
2025-07-12 05:03:58 +00:00
LobeHub Bot 790eeb86c3 💄 style: update i18n (#8400) 2025-07-12 12:49:42 +08:00
sxjeru 637d75cde0 💄 style: Support new Doubao thinking models (#8174) 2025-07-12 12:49:21 +08:00
lobehubbot 526b93470d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-11 06:18:11 +00:00
semantic-release-bot 4963c599c9 🔖 chore(release): v1.97.12 [skip ci]
### [Version&nbsp;1.97.12](https://github.com/lobehub/lobe-chat/compare/v1.97.11...v1.97.12)
<sup>Released on **2025-07-11**</sup>

#### 🐛 Bug Fixes

- **misc**: Grok-4 reasoning model universal matching.

<br/>

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

#### What's fixed

* **misc**: Grok-4 reasoning model universal matching, closes [#8390](https://github.com/lobehub/lobe-chat/issues/8390) ([d6f17f8](https://github.com/lobehub/lobe-chat/commit/d6f17f8))

</details>

<div align="right">

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

</div>
2025-07-11 06:17:05 +00:00
Arvin Xu 09f921d531 ️ perf: add database autovacuum sql (#8395)
* remove ai sdk

* add autovacuum tuning sql
2025-07-11 14:03:05 +08:00
sxjeru d6f17f8246 🐛 fix: Grok-4 reasoning model universal matching (#8390)
* Update index.ts

* Update xai.ts
2025-07-11 14:02:08 +08:00
lobehubbot efb9311e6f 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-11 03:39:38 +00:00
semantic-release-bot 1353d208e9 🔖 chore(release): v1.97.11 [skip ci]
### [Version&nbsp;1.97.11](https://github.com/lobehub/lobe-chat/compare/v1.97.10...v1.97.11)
<sup>Released on **2025-07-11**</sup>

#### 💄 Styles

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

<br/>

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

#### Styles

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

</details>

<div align="right">

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

</div>
2025-07-11 03:38:50 +00:00
René Wang 7e2f4ce5dd 💄 style: Open new topic by tap Just Chat again (#8311)
* feat: Open new topic by tap Just Chat again

* feat: Prevent re-render of message update

* fix: Import loop
2025-07-11 11:24:43 +08:00
lobehubbot ca5432ea6d 📝 docs(bot): Auto sync agents & plugin to readme 2025-07-11 03:02:49 +00:00
semantic-release-bot 86ff384587 🔖 chore(release): v1.97.10 [skip ci]
### [Version&nbsp;1.97.10](https://github.com/lobehub/lobe-chat/compare/v1.97.9...v1.97.10)
<sup>Released on **2025-07-11**</sup>

#### 💄 Styles

- **misc**: Update i18n.

<br/>

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

#### Styles

* **misc**: Update i18n, closes [#8387](https://github.com/lobehub/lobe-chat/issues/8387) ([00215c0](https://github.com/lobehub/lobe-chat/commit/00215c0))

</details>

<div align="right">

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

</div>
2025-07-11 03:01:58 +00:00
LobeHub Bot 00215c02eb 💄 style: update i18n (#8387) 2025-07-11 10:47:51 +08:00
922 changed files with 72328 additions and 5294 deletions
+93 -17
View File
@@ -1,8 +1,9 @@
---
description:
description:
globs: src/services/**/*,src/database/**/*,src/server/**/*
alwaysApply: false
---
# LobeChat 后端技术架构指南
本指南旨在阐述 LobeChat 项目的后端分层架构,重点介绍各核心目录的职责以及它们之间的协作方式。
@@ -29,24 +30,21 @@ LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应
其主要分层如下:
1. 客户端服务层 (`src/services`):
- 位于 src/services/。
- 这是客户端业务逻辑的核心层,负责封装各种业务操作和数据处理逻辑。
- 环境适配: 根据不同的运行环境,服务层会选择合适的数据访问方式:
- 本地数据库模式: 直接调用 `Model` 层进行数据操作,适用于浏览器 PGLite 和本地 Electron 应用。
- 远程数据库模式: 通过 `tRPC` 客户端调用服务端 API,适用于需要云同步的场景。
- 本地数据库模式: 直接调用 `Model` 层进行数据操作,适用于浏览器 PGLite 和本地 Electron 应用。
- 远程数据库模式: 通过 `tRPC` 客户端调用服务端 API,适用于需要云同步的场景。
- 类型转换: 对于简单的数据类型转换,直接在此层进行类型断言,如 `this.pluginModel.query() as Promise<LobeTool[]>`
- 每个服务模块通常包含 `client.ts`(本地模式)、`server.ts`(远程模式)和 `type.ts`(接口定义)文件,在实现时应该确保本地模式和远程模式业务逻辑实现一致,只是数据库不同。
2. API 接口层 (`TRPC`):
- 位于 src/server/routers/
- 使用 `tRPC` 构建类型安全的 API。Router 根据运行时环境(如 Edge Functions, Node.js Lambda)进行组织。
- 负责接收客户端请求,并将其路由到相应的 `Service` 层进行处理。
- 新建 lambda 端点时可以参考 src/server/routers/lambda/\_template.ts
3. 仓库层 (`Repositories`):
- 位于 src/database/repositories/。
- 主要处理复杂的跨表查询和数据聚合逻辑,特别是当需要从多个 `Model` 获取数据并进行组合时。
- 与 `Model` 层不同,`Repository` 层专注于复杂的业务查询场景,而不涉及简单的领域模型转换。
@@ -54,7 +52,6 @@ LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应
- 如果数据操作简单(仅涉及单个 `Model`),则通常直接在 `src/services` 层调用 `Model` 并进行简单的类型断言。
4. 模型层 (`Models`):
- 位于 src/database/models/ (例如 src/database/models/plugin.ts 和 src/database/models/document.ts)。
- 提供对数据库中各个表(由 src/database/schemas/ 中的 Drizzle ORM schema 定义)的基本 CRUD (创建、读取、更新、删除) 操作和简单的查询能力。
- `Model` 类专注于单个数据表的直接操作,不涉及复杂的领域模型转换,这些转换通常在上层的 `src/services` 中通过类型断言完成。
@@ -65,11 +62,11 @@ LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应
- 客户端模式 (浏览器/PWA): 使用 PGLite (基于 WASM 的 PostgreSQL),数据存储在用户浏览器本地。
- 服务端模式 (云部署): 使用远程 PostgreSQL 数据库。
- Electron 桌面应用:
- Electron 客户端会启动一个本地 Node.js 服务。
- 本地服务通过 `tRPC` 与 Electron 的渲染进程通信。
- 数据库选择依赖于是否开启云同步功能:
- 云同步开启: 连接到远程 PostgreSQL 数据库。
- 云同步关闭: 使用 PGLite (通过 Node.js 的 WASM 实现) 在本地存储数据。
- Electron 客户端会启动一个本地 Node.js 服务。
- 本地服务通过 `tRPC` 与 Electron 的渲染进程通信。
- 数据库选择依赖于是否开启云同步功能:
- 云同步开启: 连接到远程 PostgreSQL 数据库。
- 云同步关闭: 使用 PGLite (通过 Node.js 的 WASM 实现) 在本地存储数据。
## 数据流向说明
@@ -93,8 +90,87 @@ UI (Electron Renderer) → Zustand action → Client Service -> TRPC Client →
## 服务层 (Server Services)
- 位于 src/server/services/。
- 核心职责是封装独立的、可复用的业务逻辑单元。这些服务应易于测试。
- 平台差异抽象: 一个关键特性是通过其内部的 `impls` 子目录(例如 src/server/services/file/impls 包含 s3.ts 和 local.ts)来抹平不同运行环境带来的差异(例如云端使用 S3 存储,桌面版使用本地文件系统)。这使得上层(如 `tRPC` routers)无需关心底层具体实现。
- 目标是使 `tRPC` router 层的逻辑尽可能纯粹,专注于请求处理和业务流程编排。
- 服务可能会调用 `Repository` 层或直接调用 `Model` 层进行数据持久化和检索,也可能调用其他服务。
- 位于 src/server/services/。
- 核心职责是封装独立的、可复用的业务逻辑单元。这些服务应易于测试。
- 平台差异抽象: 一个关键特性是通过其内部的 `impls` 子目录(例如 src/server/services/file/impls 包含 s3.ts 和 local.ts)来抹平不同运行环境带来的差异(例如云端使用 S3 存储,桌面版使用本地文件系统)。这使得上层(如 `tRPC` routers)无需关心底层具体实现。
- 目标是使 `tRPC` router 层的逻辑尽可能纯粹,专注于请求处理和业务流程编排。
- 服务可能会调用 `Repository` 层或直接调用 `Model` 层进行数据持久化和检索,也可能调用其他服务。
## 最佳实践 (Best Practices)
### 数据库操作封装原则
**连续的数据库操作应该封装到 Model 层**
当业务逻辑涉及多个相关的数据库操作时,建议将这些操作封装到 Model 层中,而不是在上层(Service 或 Router 层)中进行多次数据库调用。
**优势:**
- **代码复用**: Client DB 环境的 service 实现和 Server DB 的 lambda 层实现可以复用相同的 Model 方法
- **事务一致性**: 相关的数据库操作可以在同一个方法中管理,便于维护数据一致性
- **性能优化**: 减少数据库连接次数,提高查询效率
- **职责清晰**: Model 层专注数据访问,上层专注业务协调
**示例:**
```typescript
// ✅ 推荐:在 Model 层封装连续的数据库操作
class GenerationBatchModel {
async delete(id: string): Promise<{ deletedBatch: BatchItem; thumbnailUrls: string[] }> {
// 1. 查询相关数据
const batchWithGenerations = await this.db.query.generationBatches.findFirst({...});
// 2. 收集需要处理的数据
const thumbnailUrls = [...];
// 3. 执行删除操作
const [deletedBatch] = await this.db.delete(generationBatches)...;
return { deletedBatch, thumbnailUrls };
}
}
// ✅ 上层使用简洁
const { thumbnailUrls } = await model.delete(id);
await fileService.deleteFiles(thumbnailUrls);
```
### 文件操作与数据库操作的执行顺序
**删除操作原则:数据库删除在前,文件删除在后**
当业务逻辑同时涉及数据库记录和文件系统操作时,应该遵循"数据库优先"的原则。
**原因:**
- **用户体验优先**: 如果先删除文件再删除数据库记录,可能出现文件已删除但数据库记录仍存在的情况,用户访问时会遇到文件不存在的错误
- **影响程度较小**: 如果先删除数据库记录再删除文件,即使文件删除失败,用户也看不到这个记录,只是造成一些存储空间浪费,对用户体验影响更小
- **数据一致性**: 数据库记录是业务逻辑的核心,应该优先保证其一致性
**示例:**
```typescript
// ✅ 推荐:先删除数据库记录,再删除文件
async deleteGeneration(id: string) {
// 1. 先删除数据库记录
const deletedGeneration = await generationModel.delete(id);
// 2. 再删除相关文件
if (deletedGeneration.asset?.thumbnailUrl) {
await fileService.deleteFile(deletedGeneration.asset.thumbnailUrl);
}
}
// ❌ 不推荐:先删除文件
async deleteGeneration(id: string) {
const generation = await generationModel.findById(id);
// 如果这里删除成功,但后面数据库删除失败,用户会遇到访问错误
await fileService.deleteFile(generation.asset.thumbnailUrl);
await generationModel.delete(id); // 可能失败
}
```
**创建操作原则:数据库创建在前,文件操作在后**
创建操作同样应该优先处理数据库记录,确保数据的一致性和完整性。
+40 -34
View File
@@ -1,13 +1,14 @@
---
description: How to code review
globs:
globs:
alwaysApply: false
---
# Role Description
- You are a senior full-stack engineer skilled in performance optimization, security, and design systems.
- You excel at reviewing code and providing constructive feedback.
- Your task is to review submitted Git diffs **in Chinese** and return a structured review report.
- You are a senior full-stack engineer skilled in performance optimization, security, and design systems.
- You excel at reviewing code and providing constructive feedback.
- Your task is to review submitted Git diffs **in Chinese** and return a structured review report.
- Review style: concise, direct, focused on what matters most, with actionable suggestions.
## Before the Review
@@ -16,54 +17,59 @@ Gather the modified code and context. Please strictly follow the process below:
1. Use `read_file` to read [package.json](mdc:package.json)
2. Use terminal to run command `git diff HEAD | cat` to obtain the diff and list the changed files. If you recieived empty result, run the same command once more.
3. Use `read_file` to open each changed file.
4. Use `read_file` to read [rules-attach.mdc](mdc:.cursor/rules/rules-attach.mdc). Even if you think it's unnecessary, you must read it.
5. combine changed files, step3 and `agent_requestable_workspace_rules`, list the rules which need to read
3. Use `read_file` to open each changed file.
4. Use `read_file` to read [rules-attach.mdc](mdc:.cursor/rules/rules-attach.mdc). Even if you think it's unnecessary, you must read it.
5. combine changed files, step3 and `agent_requestable_workspace_rules`, list the rules which need to read
6. Use `read_file` to read the rules list in step 5
## Review
### Code Style
- Ensure JSDoc comments accurately reflect the implementation; update them when needed.
- Look for opportunities to simplify or modernize code with the latest JavaScript/TypeScript features.
- Prefer `async`/`await` over callbacks or chained `.then` promises.
- Use consistent, descriptive naming—avoid obscure abbreviations.
- Replace magic numbers or strings with well-named constants.
read [typescript.mdc](mdc:.cursor/rules/typescript.mdc) to learn the project's code style.
- Ensure JSDoc comments accurately reflect the implementation; update them when needed.
- Look for opportunities to simplify or modernize code with the latest JavaScript/TypeScript features.
- Prefer `async`/`await` over callbacks or chained `.then` promises.
- Use consistent, descriptive naming—avoid obscure abbreviations.
- Replace magic numbers or strings with well-named constants.
- Use semantically meaningful variable, function, and class names.
- Ignore purely formatting issues and other autofixable lint problems.
### Code Optimization
- Prefer `for…of` loops to index-based `for` loops when feasible.
- Decide whether callbacks should be **debounced** or **throttled**.
- Use components from `@lobehub/ui`, Ant Design, or the existing design system instead of raw HTML tags (e.g., `Button` vs. `button`).
- reuse npm packages already installed (e.g., `lodash/omit`) rather than reinventing the wheel.
- Design for dark mode and mobile responsiveness:
- Use the `antd-style` token system instead of hard-coded colors.
- Select the proper component variants.
- Performance considerations:
- Where safe, convert sequential async flows to concurrent ones with `Promise.all`, `Promise.race`, etc.
- Prefer `for…of` loops to index-based `for` loops when feasible.
- Decide whether callbacks should be **debounced** or **throttled**.
- Use components from `@lobehub/ui`, Ant Design, or the existing design system instead of raw HTML tags (e.g., `Button` vs. `button`).
- reuse npm packages already installed (e.g., `lodash/omit`) rather than reinventing the wheel.
- Design for dark mode and mobile responsiveness:
- Use the `antd-style` token system instead of hard-coded colors.
- Select the proper component variants.
- Performance considerations:
- Where safe, convert sequential async flows to concurrent ones with `Promise.all`, `Promise.race`, etc.
- Query only the required columns from a database rather than selecting entire rows.
### Obvious Bugs
- Do not silently swallow errors in `catch` blocks; at minimum, log them.
- Revert temporary code used only for testing (e.g., debug logs, temporary configs).
- Remove empty handlers (e.g., an empty `onClick`).
- Do not silently swallow errors in `catch` blocks; at minimum, log them.
- Revert temporary code used only for testing (e.g., debug logs, temporary configs).
- Remove empty handlers (e.g., an empty `onClick`).
- Confirm the UI degrades gracefully for unauthenticated users.
- Don't leave any debug logs in the code (except when using the `debug` module properly).
- When using the `debug` module, avoid `import { log } from 'debug'` as it logs directly to console. Use proper debug namespaces instead.
- Check logs for sensitive information like api key, etc
## After the Review: output
1. Summary
- Start with a brief explanation of what the change set does.
- Summarize the changes for each modified file (or logical group).
- Start with a brief explanation of what the change set does.
- Summarize the changes for each modified file (or logical group).
2. Comments Issues
- List the most critical issues first.
- Use an ordered list, which will be convenient for me to reference later.
- For each issue:
- Mark severity tag (`❌ Must fix`, `⚠️ Should fix`, `💅 Nitpick`)
- Provode file path to the relevant file.
- Provide recommended fix
- End with a **git commit** command, instruct the author to run it.
- We use gitmoji to label commit messages, format: [emoji] <type>(<scope>): <subject>
- List the most critical issues first.
- Use an ordered list, which will be convenient for me to reference later.
- For each issue:
- Mark severity tag (`❌ Must fix`, `⚠️ Should fix`, `💅 Nitpick`)
- Provode file path to the relevant file.
- Provide recommended fix
- End with a **git commit** command, instruct the author to run it.
- We use gitmoji to label commit messages, format: [emoji] <type>(<scope>): <subject>
+45 -35
View File
@@ -1,21 +1,25 @@
---
description:
globs:
description:
globs:
alwaysApply: true
---
# Guide to Optimize Output(Response) Rendering
## File Path and Code Symbol Rendering
- When rendering file paths, use backtick wrapping instead of markdown links so they can be parsed as clickable links in Cursor IDE.
- Good: `src/components/Button.tsx`
- Bad: [src/components/Button.tsx](src/components/Button.tsx)
- Good: `src/components/Button.tsx`
- Bad: [src/components/Button.tsx](mdc:src/components/Button.tsx)
- Don't use line and column number in file path, this will make file path not clickable in Cursor IDE.
- Good: `src/components/Button.tsx` `10:20` (add a space between the file path and the line and column number)
- Bad: `src/components/Button.tsx:10:20`
- When rendering functions, variables, or other code symbols, use backtick wrapping so they can be parsed as navigable links in Cursor IDE
- Good: The `useState` hook in `MyComponent`
- Bad: The useState hook in MyComponent
- Good: The `useState` hook in `MyComponent`
- Bad: The useState hook in MyComponent
## Markdown Render
- don't use br tag to wrap in table cell
@@ -23,9 +27,9 @@ alwaysApply: true
## Terminal Command Output
- If terminal commands don't produce output, it's likely due to paging issues. Try piping the command to `cat` to ensure full output is displayed.
- Good: `git show commit_hash -- file.txt | cat`
- Good: `git log --oneline | cat`
- Reason: Some git commands use pagers by default, which may prevent output from being captured properly
- Good: `git show commit_hash -- file.txt | cat`
- Good: `git log --oneline | cat`
- Reason: Some git commands use pagers by default, which may prevent output from being captured properly
## Mermaid Diagram Generation: Strict Syntax Validation Checklist
@@ -44,50 +48,56 @@ Before producing any Mermaid diagram, you **must** compare your final code line-
### Checklist Details
#### Rule 1: Edge Labels Must Be Plain Text Only
#### Rule 1: Edge Labels Must Be Plain Text Only
> **Essence:** Anything inside `|...|` must contain pure, unformatted text. Absolutely NO Markdown, list markers, or parentheses/brackets allowed—these often cause rendering failures.
- **✅ Do:** `A -->|Process plain text data| B`
- **❌ Don't:** `A -->|1. Ordered list item| B` (No numbered lists)
- **❌ Don't:** `CC --"1. fetch('/api/...')"--> API` (No square brackets)
- **❌ Don't:** `A -->|- Unordered list item| B` (No hyphen lists)
- **❌ Don't:** `A -->|Transform (important)| B` (No parentheses)
- **❌ Don't:** `A -->|Transform [important]| B` (No square brackets)
- **✅ Do:** `A -->|Process plain text data| B`
- **❌ Don't:** `A -->|1. Ordered list item| B` (No numbered lists)
- **❌ Don't:** `CC --"1. fetch('/api/...')"--> API` (No square brackets)
- **❌ Don't:** `A -->|- Unordered list item| B` (No hyphen lists)
- **❌ Don't:** `A -->|Transform (important)| B` (No parentheses)
- **❌ Don't:** `A -->|Transform [important]| B` (No square brackets)
#### Rule 2: Node Definition Handle Special Characters with Care
#### Rule 2: Node Definition Handle Special Characters with Care
> **Essence:** When node text or subgraph titles contain special characters like `()` or `[]`, wrap the text in quotes to avoid conflicts with Mermaid shape syntax.
- **When your node text includes parentheses (e.g., 'React (JSX)'):**
- **✅ Do:** `I_REACT["<b>React component (JSX)</b>"]` (Quotes wrap all text)
- **❌ Don't:** `I_REACT(<b>React component (JSX)</b>)` (Wrong, Mermaid parses this as a shape)
- **❌ Don't:** `subgraph Plugin Features (Plugins)` (Wrong, subgraph titles with parentheses must also be wrapped in quotes)
- **When your node text includes parentheses (e.g., 'React (JSX)'):**
- **✅ Do:** `I_REACT["<b>React component (JSX)</b>"]` (Quotes wrap all text)
- **❌ Don't:** `I_REACT(<b>React component (JSX)</b>)` (Wrong, Mermaid parses this as a shape)
- **❌ Don't:** `subgraph Plugin Features (Plugins)` (Wrong, subgraph titles with parentheses must also be wrapped in quotes)
#### Rule 3: Double Quotes in Text Must Be Escaped
#### Rule 3: Double Quotes in Text Must Be Escaped
> **Essence:** Use `&quot;` for double quotes **inside node text**.
- **✅ Do:** `A[This node contains &quot;quotes&quot;]`
- **❌ Don't:** `A[This node contains "quotes"]`
- **✅ Do:** `A[This node contains &quot;quotes&quot;]`
- **❌ Don't:** `A[This node contains "quotes"]`
#### Rule 4: All Formatting Must Use HTML Tags (NOT Markdown!)
#### Rule 4: All Formatting Must Use HTML Tags (NOT Markdown!)
> **Essence:** For newlines, bold, and other text formatting in nodes, use HTML tags only. Markdown is not supported.
- **✅ Do (robust):** `A["<b>Bold</b> and <code>code</code><br>This is a new line"]`
- **❌ Don't (not rendered):** `C["# This is a heading"]`
- **❌ Don't (not rendered):** ``C["`const` means constant"]``
- **⚠️ Warning (unreliable):** `B["Markdown **bold** might sometimes work but DON'T rely on it"]`
- **✅ Do (robust):** `A["<b>Bold</b> and <code>code</code><br>This is a new line"]`
- **❌ Don't (not rendered):** `C["# This is a heading"]`
- **❌ Don't (not rendered):** ``C["`const` means constant"]``
- **⚠️ Warning (unreliable):** `B["Markdown **bold** might sometimes work but DON'T rely on it"]`
#### Rule 5: No HTML Tags for Participants and Message Labels (Sequence Diagrams)
#### Rule 5: No HTML Tags for Participants and Message Labels (Sequence Diagrams)
> **Important Addition:**
> In Mermaid sequence diagrams, you MUST NOT use any HTML tags (such as `<b>`, `<code>`, etc.) in:
>
> - `participant` display names (`as` part)
> - Message labels (the text after `:` in diagram flows)
>
> These tags are generally not rendered—they may appear as-is or cause compatibility issues.
- **✅ Do:** `participant A as Client`
- **❌ Don't:** `participant A as <b>Client</b>`
- **✅ Do:** `A->>B: 1. Establish connection`
- **❌ Don't:** `A->>B: 1. <code>Establish connection</code>`
- **✅ Do:** `participant A as Client`
- **❌ Don't:** `participant A as <b>Client</b>`
- **✅ Do:** `A->>B: 1. Establish connection`
- **❌ Don't:** `A->>B: 1. <code>Establish connection</code>`
---
@@ -1,6 +0,0 @@
---
description:
globs: src/locales/**/*
alwaysApply: false
---
read [i18n.mdc](mdc:.cursor/rules/i18n/i18n.mdc)
+18 -7
View File
@@ -1,19 +1,16 @@
---
description:
globs:
alwaysApply: true
---
## Project Description
You are developing an open-source, modern-design AI chat framework: lobe chat.
Emoji logo: 🤯
You are developing an open-source, modern-design AI chat framework: lobe chat.
Emoji logo: 🤯
## Project Technologies Stack
read [package.json](mdc:package.json) to know all npm packages you can use.
read [folder-structure.mdx](mdc:docs/development/basic/folder-structure.mdx) to learn project structure.
read [package.json](mdc:package.json) to know all npm packages you can use. read [folder-structure.mdx](mdc:docs/development/basic/folder-structure.mdx) to learn project structure.
The project uses the following technologies:
@@ -45,3 +42,17 @@ The project uses the following technologies:
- Cursor AI for code editing and AI coding assistance
Note: All tools and libraries used are the latest versions. The application only needs to be compatible with the latest browsers;
## Often used npm scripts
```bash
# type check
bun type-check
# install dependencies
pnpm install
# !: don't any build script to check weather code can work after modify
```
check [testing guide](./testing-guide/testing-guide.mdc) to learn test scripts.
+16 -7
View File
@@ -1,8 +1,9 @@
---
description:
globs:
description:
globs:
alwaysApply: true
---
# LobeChat Cursor Rules System Guide
This document explains how the LobeChat project's Cursor rules system works and serves as an index for manually accessible rules.
@@ -14,22 +15,26 @@ This document explains how the LobeChat project's Cursor rules system works and
## 📚 Four Ways to Access Rules
### 1. **Always Applied Rules** - `always_applied_workspace_rules`
- **What**: Core project guidelines that are always active
- **Content**: Project tech stack, basic coding standards, output formatting rules
- **Access**: No tools needed - automatically provided in every conversation
### 2. **Dynamic Context Rules** - `cursor_rules_context`
- **What**: Rules automatically matched based on files referenced in the conversation
- **Trigger**: Only when user **explicitly @ mentions files** or **opens files in Cursor**
- **Content**: May include brief descriptions or full rule content, depending on relevance
- **Access**: No tools needed - automatically updated when files are referenced
### 3. **Agent Requestable Rules** - `agent_requestable_workspace_rules`
- **What**: Detailed operational guides that can be requested on-demand
- **Access**: Use `fetch_rules` tool with rule names
- **Examples**: `debug`, `i18n/i18n`, `code-review`
### 4. **Manual Rules Index** - This file + `read_file`
- **What**: Additional rules not covered by the above mechanisms
- **Why needed**: Cursor's rule system only supports "agent request" or "auto attach" modes
- **Access**: Use `read_file` tool to read specific `.mdc` files
@@ -47,10 +52,13 @@ Use `read_file` to access rules from the index below when:
The following rules are available via `read_file` from the `.cursor/rules/` directory:
- `backend-architecture.mdc` Backend layer architecture and design guidelines
- `zustand-action-patterns.mdc` Recommended patterns for organizing Zustand actions
- `zustand-slice-organization.mdc` Best practices for structuring Zustand slices
- `define-database-model.mdc` Database model definition guidelines
- `drizzle-schema-style-guide.mdc` Style guide for defining Drizzle ORM schemas
- `react-component.mdc` React component style guide and conventions
- `testing-guide.mdc` Comprehensive testing guide for Vitest environment
- `typescript.mdc` TypeScript code style guide
- `zustand-action-patterns.mdc` Recommended patterns for organizing Zustand actions
- `zustand-slice-organization.mdc` Best practices for structuring Zustand slices
## ❌ Common Misunderstandings to Avoid
@@ -62,7 +70,7 @@ The following rules are available via `read_file` from the `.cursor/rules/` dire
```
1. Start with always_applied_workspace_rules (automatic)
2. Check cursor_rules_context for auto-matched rules (automatic)
2. Check cursor_rules_context for auto-matched rules (automatic)
3. If you need specific guides: fetch_rules (manual)
4. If you identify gaps: consult this index → read_file (manual)
```
@@ -70,7 +78,8 @@ The following rules are available via `read_file` from the `.cursor/rules/` dire
## Example Decision Flow
**Scenario**: Working on a new Zustand store slice
1. Follow always_applied_workspace_rules ✅
2. If store files were @ mentioned → use cursor_rules_context rules ✅
2. If store files were @ mentioned → use cursor_rules_context rules ✅
3. Need detailed Zustand guidance → `read_file('.cursor/rules/zustand-slice-organization.mdc')` ✅
4. All rules apply simultaneously - no conflicts ✅
4. All rules apply simultaneously - no conflicts ✅
+8 -3
View File
@@ -1,8 +1,9 @@
---
description:
globs:
description:
globs:
alwaysApply: true
---
## System Role
You are an expert in full-stack Web development, proficient in JavaScript, TypeScript, CSS, React, Node.js, Next.js, Postgresql, all kinds of network protocols.
@@ -11,7 +12,6 @@ You are an expert in LLM and Ai art. In Ai image generation, you are proficient
You are an expert in UI/UX design, proficient in web interaction patterns, responsive design, accessibility, and user behavior optimization. You excel at improving user retention and paid conversion rates through various interaction details.
## Problem Solving
- Before formulating any response, you must first gather context by using tools like codebase_search, grep_search, file_search, web_search, fetch_rules, context7, and read_file to avoid making assumptions.
@@ -36,3 +36,8 @@ You are an expert in UI/UX design, proficient in web interaction patterns, respo
- If you're unable to access or retrieve content from websites, please inform me immediately and request the specific information needed rather than making assumptions
- You can use emojis, npm packages like `chalk`/`chalk-animation`/`terminal-link`/`gradient-string`/`log-symbols`/`boxen`/`consola`/`@clack/prompts` to create beautiful terminal output
- Don't run `tsc --noEmit` to check ts syntax error, because our project is very large and the validate very slow
## Some logging rules
- Never log user private information like api key, etc
- Don't use `import { log } from 'debug'` to log messages, because it will directly log the message to the console.
-881
View File
@@ -1,881 +0,0 @@
---
description:
globs: *.test.ts,*.test.tsx
alwaysApply: false
---
---
type: agent-requested
title: 测试指南 - LobeChat Testing Guide
description: LobeChat 项目的 Vitest 测试环境配置、运行方式、修复原则指南
---
# 测试指南 - LobeChat Testing Guide
## 🧪 测试环境概览
LobeChat 项目使用 Vitest 测试库,配置了两种不同的测试环境:
### 客户端测试环境 (DOM Environment)
- **配置文件**: [vitest.config.ts](mdc:vitest.config.ts)
- **环境**: Happy DOM (浏览器环境模拟)
- **数据库**: PGLite (浏览器环境的 PostgreSQL)
- **用途**: 测试前端组件、客户端逻辑、React 组件等
- **设置文件**: [tests/setup.ts](mdc:tests/setup.ts)
### 服务端测试环境 (Node Environment)
- **配置文件**: [vitest.config.server.ts](mdc:vitest.config.server.ts)
- **环境**: Node.js
- **数据库**: 真实的 PostgreSQL 数据库
- **并发限制**: 单线程运行 (`singleFork: true`)
- **用途**: 测试数据库模型、服务端逻辑、API 端点等
- **设置文件**: [tests/setup-db.ts](mdc:tests/setup-db.ts)
## 🚀 测试运行命令
### package.json 脚本说明
查看 [package.json](mdc:package.json) 中的测试相关脚本:
```json
{
"test": "npm run test-app && npm run test-server",
"test-app": "vitest run --config vitest.config.ts",
"test-app:coverage": "vitest run --config vitest.config.ts --coverage",
"test-server": "vitest run --config vitest.config.server.ts",
"test-server:coverage": "vitest run --config vitest.config.server.ts --coverage"
}
```
### 推荐的测试运行方式
#### ✅ 正确的命令格式
```bash
# 运行所有客户端测试
npx vitest run --config vitest.config.ts
# 运行所有服务端测试
npx vitest run --config vitest.config.server.ts
# 运行特定测试文件 (支持模糊匹配)
npx vitest run --config vitest.config.ts basic
npx vitest run --config vitest.config.ts user.test.ts
# 运行特定文件的特定行号
npx vitest run --config vitest.config.ts src/utils/helper.test.ts:25
npx vitest run --config vitest.config.ts basic/foo.test.ts:10,basic/foo.test.ts:25
# 过滤特定测试用例名称
npx vitest -t "test case name" --config vitest.config.ts
# 组合使用文件和测试名称过滤
npx vitest run --config vitest.config.ts filename.test.ts -t "specific test"
```
#### ❌ 避免的命令格式
```bash
# ❌ 不要使用 pnpm test xxx (这不是有效的 vitest 命令)
pnpm test some-file
# ❌ 不要使用裸 vitest (会进入 watch 模式)
vitest test-file.test.ts
# ❌ 不要混淆测试环境
npx vitest run --config vitest.config.server.ts client-component.test.ts
```
### 关键运行参数说明
- **`vitest run`**: 运行一次测试然后退出 (避免 watch 模式)
- **`vitest`**: 默认进入 watch 模式,持续监听文件变化
- **`--config`**: 指定配置文件,选择正确的测试环境
- **`-t`**: 过滤测试用例名称,支持正则表达式
- **`--coverage`**: 生成测试覆盖率报告
## 🔧 测试修复原则
### 核心原则 ⚠️
1. **充分阅读测试代码**: 在修复测试之前,必须完整理解测试的意图和实现
2. **测试优先修复**: 如果是测试本身写错了,修改测试而不是实现代码
3. **专注单一问题**: 只修复指定的测试,不要添加额外测试或功能
4. **不自作主张**: 不要因为发现其他问题就直接修改,先提出再讨论
### 测试修复流程
```mermaid
flowchart TD
subgraph "阶段一:分析与复现"
A[开始:收到测试失败报告] --> B[定位并运行失败的测试];
B --> C{是否能在本地复现?};
C -->|否| D[检查测试环境/配置/依赖];
C -->|是| E[分析:阅读测试代码、错误日志、Git 历史];
end
subgraph "阶段二:诊断与调试"
E --> F[建立假设:问题出在测试、代码还是环境?];
F --> G["调试:使用 console.log 或 debugger 深入检查"];
G --> H{假设是否被证实?};
H -->|否, 重新假设| F;
end
subgraph "阶段三:修复与验证"
H -->|是| I{确定根本原因};
I -->|测试逻辑错误| J[修复测试代码];
I -->|实现代码 Bug| K[修复实现代码];
I -->|环境/配置问题| L[修复配置或依赖];
J --> M[验证修复:重新运行失败的测试];
K --> M;
L --> M;
M --> N{测试是否通过?};
N -->|否, 修复无效| F;
N -->|是| O[扩大验证:运行当前文件内所有测试];
O --> P{是否全部通过?};
P -->|否, 引入新问题| F;
end
subgraph "阶段四:总结"
P -->|是| Q[完成:撰写修复总结];
end
D --> F;
```
### 修复完成后的总结
测试修复完成后,应该提供简要说明,包括:
1. **错误原因分析**: 说明测试失败的根本原因
- 测试逻辑错误
- 实现代码bug
- 环境配置问题
- 依赖变更导致的问题
2. **修复方法说明**: 简述采用的修复方式
- 修改了哪些文件
- 采用了什么解决方案
- 为什么选择这种修复方式
**示例格式**:
```markdown
## 测试修复总结
**错误原因**: 测试中的 mock 数据格式与实际 API 返回格式不匹配,导致断言失败。
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
```
## 📂 测试文件组织
### 文件命名约定
- **客户端测试**: `*.test.ts`, `*.test.tsx` (任意位置)
- **服务端测试**: `src/database/models/**/*.test.ts`, `src/database/server/**/*.test.ts` (限定路径)
### 测试文件组织风格
项目采用 **测试文件与源文件同目录** 的组织风格:
- 测试文件放在对应源文件的同一目录下
- 命名格式:`原文件名.test.ts` 或 `原文件名.test.tsx`
例如:
```
src/components/Button/
├── index.tsx # 源文件
└── index.test.tsx # 测试文件
```
## 🛠️ 测试调试技巧
### 运行失败测试的步骤
1. **确定测试类型**: 查看文件路径确定使用哪个配置
2. **运行单个测试**: 使用 `-t` 参数隔离问题
3. **检查错误日志**: 仔细阅读错误信息和堆栈跟踪
4. **查看最近修改记录**: 检查相关文件的最近变更情况
5. **添加调试日志**: 在测试中添加 `console.log` 了解执行流程
### Electron IPC 接口测试策略 🖥️
对于涉及 Electron IPC 接口的测试,由于提供真实的 Electron 环境比较复杂,采用 **Mock 返回值** 的方式进行测试。
#### 基本 Mock 设置
```typescript
import { vi } from "vitest";
import { electronIpcClient } from "@/server/modules/ElectronIPCClient";
// Mock Electron IPC 客户端
vi.mock("@/server/modules/ElectronIPCClient", () => ({
electronIpcClient: {
getFilePathById: vi.fn(),
deleteFiles: vi.fn(),
// 根据需要添加其他 IPC 方法
},
}));
```
#### 在测试中设置 Mock 行为
```typescript
beforeEach(() => {
// 重置所有 Mock
vi.resetAllMocks();
// 设置默认的 Mock 返回值
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue(
"/path/to/file.txt"
);
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
success: true,
});
});
```
#### 测试不同场景的示例
```typescript
it("应该处理文件删除成功的情况", async () => {
// 设置成功场景的 Mock
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
success: true,
});
const result = await service.deleteFiles(["desktop://file1.txt"]);
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith([
"desktop://file1.txt",
]);
expect(result.success).toBe(true);
});
it("应该处理文件删除失败的情况", async () => {
// 设置失败场景的 Mock
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValue(
new Error("删除失败")
);
const result = await service.deleteFiles(["desktop://file1.txt"]);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
```
#### Mock 策略的优势
1. **环境简化**: 避免了复杂的 Electron 环境搭建
2. **测试可控**: 可以精确控制 IPC 调用的返回值和行为
3. **场景覆盖**: 容易测试各种成功/失败场景
4. **执行速度**: Mock 调用比真实 IPC 调用更快
#### 注意事项
- **Mock 准确性**: 确保 Mock 的行为与真实 IPC 接口行为一致
- **类型安全**: 使用 `vi.mocked()` 确保类型安全
- **Mock 重置**: 在 `beforeEach` 中重置 Mock 状态,避免测试间干扰
- **调用验证**: 不仅要验证返回值,还要验证 IPC 方法是否被正确调用
### 检查最近修改记录 🔍
为了更好地判断测试失败的根本原因,需要**系统性地检查相关文件的修改历史**。这是问题定位的关键步骤。
#### 第一步:确定需要检查的文件范围
1. **测试文件本身**: `path/to/component.test.ts`
2. **对应的实现文件**: `path/to/component.ts` 或 `path/to/component/index.ts`
3. **相关依赖文件**: 测试或实现中导入的其他模块
#### 第二步:检查当前工作目录状态
```bash
# 查看所有未提交的修改状态
git status
# 重点关注测试文件和实现文件是否有未提交的修改
git status | grep -E "(test|spec)"
```
#### 第三步:检查未提交的修改内容
```bash
# 查看测试文件的未提交修改 (工作区 vs 暂存区)
git diff path/to/component.test.ts | cat
# 查看对应实现文件的未提交修改
git diff path/to/component.ts | cat
# 查看已暂存但未提交的修改
git diff --cached path/to/component.test.ts | cat
git diff --cached path/to/component.ts | cat
```
#### 第四步:检查提交历史和时间相关性
**首先查看提交时间,判断修改的时效性**:
```bash
# 查看测试文件的最近提交历史,包含提交时间
git log --pretty=format:"%h %ad %s" --date=relative -5 path/to/component.test.ts | cat
# 查看实现文件的最近提交历史,包含提交时间
git log --pretty=format:"%h %ad %s" --date=relative -5 path/to/component.ts | cat
# 查看详细的提交时间(ISO格式,便于精确判断)
git log --pretty=format:"%h %ad %an %s" --date=iso -3 path/to/component.ts | cat
git log --pretty=format:"%h %ad %an %s" --date=iso -3 path/to/component.test.ts | cat
```
**判断提交的参考价值**
1. **最近提交(24小时内)**: 🔴 **高度相关** - 很可能是导致测试失败的直接原因
2. **近期提交(1-7天内)**: 🟡 **中等相关** - 可能相关,需要仔细分析修改内容
3. **较早提交(超过1周)**: ⚪ **低相关性** - 除非是重大重构,否则不太可能是直接原因
#### 第五步:基于时间相关性查看具体修改内容
**根据提交时间的远近,优先查看最近的修改**:
```bash
# 如果有24小时内的提交,重点查看这些修改
git show HEAD -- path/to/component.test.ts | cat
git show HEAD -- path/to/component.ts | cat
# 查看次新的提交(如果最新提交时间较远)
git show HEAD~1 -- path/to/component.ts | cat
git show <recent-commit-hash> -- path/to/component.ts | cat
# 对比最近两次提交的差异
git diff HEAD~1 HEAD -- path/to/component.ts | cat
```
#### 第六步:分析修改与测试失败的关系
基于修改记录和时间相关性判断:
1. **最近修改了实现代码**:
```bash
# 重点检查实现逻辑的变化
git diff HEAD~1 path/to/component.ts | cat
```
- 很可能是实现代码的变更导致测试失败
- 检查实现逻辑是否正确
- 确认测试是否需要相应更新
2. **最近修改了测试代码**:
```bash
# 重点检查测试逻辑的变化
git diff HEAD~1 path/to/component.test.ts | cat
```
- 可能是测试本身写错了
- 检查测试逻辑和断言是否正确
- 确认测试是否符合实现的预期行为
3. **两者都有最近修改**:
```bash
# 对比两个文件的修改时间
git log --pretty=format:"%ad %f" --date=iso -1 path/to/component.ts | cat
git log --pretty=format:"%ad %f" --date=iso -1 path/to/component.test.ts | cat
```
- 需要综合分析两者的修改
- 确定哪个修改更可能导致问题
- 优先检查时间更近的修改
4. **都没有最近修改**:
- 可能是依赖变更或环境问题
- 检查 `package.json`、配置文件等的修改
- 查看是否有全局性的代码重构
#### 修改记录检查示例
```bash
# 完整的检查流程示例
echo "=== 检查文件修改状态 ==="
git status | grep component
echo "=== 检查未提交修改 ==="
git diff src/components/Button/index.test.tsx | cat
git diff src/components/Button/index.tsx | cat
echo "=== 检查提交历史和时间 ==="
git log --pretty=format:"%h %ad %s" --date=relative -3 src/components/Button/index.test.tsx | cat
git log --pretty=format:"%h %ad %s" --date=relative -3 src/components/Button/index.tsx | cat
echo "=== 根据时间优先级查看修改内容 ==="
# 如果有24小时内的提交,重点查看
git show HEAD -- src/components/Button/index.tsx | cat
```
## 🗃️ 数据库 Model 测试指南
### 测试环境选择 💡
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
### ⚠️ 双环境验证要求
**对于所有 Model 测试,必须在两个环境下都验证通过**:
#### 完整验证流程
```bash
# 1. 先在客户端环境测试(快速验证)
npx vitest run --config vitest.config.ts src/database/models/__tests__/myModel.test.ts
# 2. 再在服务端环境测试(兼容性验证)
npx vitest run --config vitest.config.server.ts src/database/models/__tests__/myModel.test.ts
```
### 创建新 Model 测试的最佳实践 📋
#### 1. 参考现有实现和测试模板
创建新 Model 测试前,**必须先参考现有的实现模式**:
- **Model 实现参考**:
- **测试模板参考**:
- **复杂示例参考**:
#### 2. 用户权限检查 - 安全第一 🔒
这是**最关键的安全要求**。所有涉及用户数据的操作都必须包含用户权限检查:
**❌ 错误示例 - 存在安全漏洞**:
```typescript
// 危险:缺少用户权限检查,任何用户都能操作任何数据
update = async (id: string, data: Partial<MyModel>) => {
return this.db
.update(myTable)
.set(data)
.where(eq(myTable.id, id)) // ❌ 只检查 ID,没有检查 userId
.returning();
};
```
**✅ 正确示例 - 安全的实现**:
```typescript
// 安全:必须同时匹配 ID 和 userId
update = async (id: string, data: Partial<MyModel>) => {
return this.db
.update(myTable)
.set(data)
.where(
and(
eq(myTable.id, id),
eq(myTable.userId, this.userId) // ✅ 用户权限检查
)
)
.returning();
};
```
**必须进行用户权限检查的方法**
- `update()` - 更新操作
- `delete()` - 删除操作
- `findById()` - 查找特定记录
- 任何涉及特定记录的查询或修改操作
#### 3. 测试文件结构和必测场景
**基本测试结构**:
```typescript
// @vitest-environment node
describe("MyModel", () => {
describe("create", () => {
it("should create a new record");
it("should handle edge cases");
});
describe("queryAll", () => {
it("should return records for current user only");
it("should handle empty results");
});
describe("update", () => {
it("should update own records");
it("should NOT update other users records"); // 🔒 安全测试
});
describe("delete", () => {
it("should delete own records");
it("should NOT delete other users records"); // 🔒 安全测试
});
describe("user isolation", () => {
it("should enforce user data isolation"); // 🔒 核心安全测试
});
});
```
**必须测试的安全场景** 🔒:
```typescript
it("should not update records of other users", async () => {
// 创建其他用户的记录
const [otherUserRecord] = await serverDB
.insert(myTable)
.values({ userId: "other-user", data: "original" })
.returning();
// 尝试更新其他用户的记录
const result = await myModel.update(otherUserRecord.id, { data: "hacked" });
// 应该返回 undefined 或空数组(因为权限检查失败)
expect(result).toBeUndefined();
// 验证原始数据未被修改
const unchanged = await serverDB.query.myTable.findFirst({
where: eq(myTable.id, otherUserRecord.id),
});
expect(unchanged?.data).toBe("original"); // 数据应该保持不变
});
```
#### 4. Mock 外部依赖服务
如果 Model 依赖外部服务(如 FileService),需要正确 Mock
**设置 Mock**:
```typescript
// 在文件顶部设置 Mock
const mockGetFullFileUrl = vi.fn();
vi.mock("@/server/services/file", () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: mockGetFullFileUrl,
})),
}));
// 在 beforeEach 中重置和配置 Mock
beforeEach(async () => {
vi.clearAllMocks();
mockGetFullFileUrl.mockImplementation(
(url: string) => `https://example.com/${url}`
);
});
```
**验证 Mock 调用**:
```typescript
it("should process URLs through FileService", async () => {
// ... 测试逻辑
// 验证 Mock 被正确调用
expect(mockGetFullFileUrl).toHaveBeenCalledWith("expected-url");
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
});
```
#### 5. 数据库状态管理
**正确的数据清理模式**:
```typescript
const userId = "test-user";
const otherUserId = "other-user";
beforeEach(async () => {
// 清理用户表(级联删除相关数据)
await serverDB.delete(users);
// 创建测试用户
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
});
afterEach(async () => {
// 清理测试数据
await serverDB.delete(users);
});
```
#### 6. 测试数据类型和外键约束处理 ⚠️
**必须使用 Schema 导出的类型**:
```typescript
// ✅ 正确:使用 schema 导出的类型
import { NewGenerationBatch, NewGeneration } from '../../schemas';
const testBatch: NewGenerationBatch = {
userId,
generationTopicId: 'test-topic-id',
provider: 'test-provider',
model: 'test-model',
prompt: 'Test prompt for image generation',
width: 1024,
height: 1024,
config: { /* ... */ },
};
const testGeneration: NewGeneration = {
id: 'test-gen-id',
generationBatchId: 'test-batch-id',
asyncTaskId: null, // 处理外键约束
fileId: null, // 处理外键约束
seed: 12345,
userId,
};
```
```typescript
// ❌ 错误:没有类型声明或使用错误类型
const testBatch = { // 缺少类型声明
generationTopicId: 'test-topic-id',
// ...
};
const testGeneration = { // 缺少类型声明
asyncTaskId: 'invalid-uuid', // 外键约束错误
fileId: 'non-existent-file', // 外键约束错误
// ...
};
```
**外键约束处理策略**:
1. **使用 null 值**: 对于可选的外键字段,使用 null 避免约束错误
2. **创建关联记录**: 如果需要测试关联关系,先创建被引用的记录
3. **理解约束关系**: 了解哪些字段有外键约束,避免引用不存在的记录
```typescript
// 外键约束处理示例
beforeEach(async () => {
// 清理数据库
await serverDB.delete(users);
// 创建测试用户
await serverDB.insert(users).values([{ id: userId }]);
// 如果需要测试文件关联,创建文件记录
if (needsFileAssociation) {
await serverDB.insert(files).values({
id: 'test-file-id',
userId,
name: 'test.jpg',
url: 'test-url',
size: 1024,
fileType: 'image/jpeg',
});
}
});
```
**排序测试的可预测性**:
```typescript
// ✅ 正确:使用明确的时间戳确保排序结果可预测
it('should find batches by topic id in correct order', async () => {
const oldDate = new Date('2024-01-01T10:00:00Z');
const newDate = new Date('2024-01-02T10:00:00Z');
const batch1 = { ...testBatch, prompt: 'First batch', userId, createdAt: oldDate };
const batch2 = { ...testBatch, prompt: 'Second batch', userId, createdAt: newDate };
await serverDB.insert(generationBatches).values([batch1, batch2]);
const results = await generationBatchModel.findByTopicId(testTopic.id);
expect(results[0].prompt).toBe('Second batch'); // 最新优先 (desc order)
expect(results[1].prompt).toBe('First batch');
});
```
```typescript
// ❌ 错误:依赖数据库的默认时间戳,结果不可预测
it('should find batches by topic id', async () => {
const batch1 = { ...testBatch, prompt: 'First batch', userId };
const batch2 = { ...testBatch, prompt: 'Second batch', userId };
await serverDB.insert(generationBatches).values([batch1, batch2]);
// 插入顺序和数据库时间戳可能不一致,导致测试不稳定
const results = await generationBatchModel.findByTopicId(testTopic.id);
expect(results[0].prompt).toBe('Second batch'); // 可能失败
});
```
### 常见问题和解决方案 💡
#### 问题 1:权限检查缺失导致安全漏洞
**现象**: 测试失败,用户能修改其他用户的数据
**解决**: 在 Model 的 `update` 和 `delete` 方法中添加 `and(eq(table.id, id), eq(table.userId, this.userId))`
#### 问题 2:Mock 未生效或验证失败
**现象**: `undefined is not a spy` 错误
**解决**: 检查 Mock 设置位置和方式,确保在测试文件顶部设置,在 `beforeEach` 中重置
#### 问题 3:测试数据污染
**现象**: 测试间相互影响,结果不稳定
**解决**: 在 `beforeEach` 和 `afterEach` 中正确清理数据库状态
#### 问题 4:外部依赖导致测试失败
**现象**: 因为真实的外部服务调用导致测试不稳定
**解决**: Mock 所有外部依赖,使测试更可控和快速
#### 问题 5:外键约束违反导致测试失败
**现象**: `insert or update on table "xxx" violates foreign key constraint`
**解决**:
- 将可选外键字段设为 `null` 而不是无效的字符串值
- 或者先创建被引用的记录,再创建当前记录
```typescript
// ❌ 错误:无效的外键值
const testData = {
asyncTaskId: 'invalid-uuid', // 表中不存在此记录
fileId: 'non-existent-file', // 表中不存在此记录
};
// ✅ 正确:使用 null 值
const testData = {
asyncTaskId: null, // 避免外键约束
fileId: null, // 避免外键约束
};
// ✅ 或者:先创建被引用的记录
beforeEach(async () => {
const [asyncTask] = await serverDB.insert(asyncTasks).values({
id: 'valid-task-id',
status: 'pending',
type: 'generation',
}).returning();
const testData = {
asyncTaskId: asyncTask.id, // 使用有效的外键值
};
});
```
#### 问题 6:排序测试结果不一致
**现象**: 相同的测试有时通过,有时失败,特别是涉及排序的测试
**解决**: 使用明确的时间戳,不要依赖数据库的默认时间戳
```typescript
// ❌ 错误:依赖插入顺序和默认时间戳
await serverDB.insert(table).values([data1, data2]); // 时间戳不可预测
// ✅ 正确:明确指定时间戳
const oldDate = new Date('2024-01-01T10:00:00Z');
const newDate = new Date('2024-01-02T10:00:00Z');
await serverDB.insert(table).values([
{ ...data1, createdAt: oldDate },
{ ...data2, createdAt: newDate },
]);
```
#### 问题 7:Mock 验证失败或调用次数不匹配
**现象**: `expect(mockFunction).toHaveBeenCalledWith(...)` 失败
**解决**:
- 检查 Mock 函数的实际调用参数和期望参数是否完全匹配
- 确认 Mock 在正确的时机被重置和配置
- 使用 `toHaveBeenCalledTimes()` 验证调用次数
```typescript
// 在 beforeEach 中正确配置 Mock
beforeEach(() => {
vi.clearAllMocks(); // 重置所有 Mock
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
mockTransformGeneration.mockResolvedValue({
id: 'test-id',
// ... 其他字段
});
});
// 测试中验证 Mock 调用
it('should call FileService with correct parameters', async () => {
await model.someMethod();
// 验证调用参数
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
// 验证调用次数
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
});
```
### Model 测试检查清单 ✅
创建 Model 测试时,请确保以下各项都已完成:
#### 🔧 基础配置
- [ ] **双环境验证** - 在客户端环境 (vitest.config.ts) 和服务端环境 (vitest.config.server.ts) 下都测试通过
- [ ] 参考了 `_template.ts` 和现有 Model 的实现模式
- [ ] **使用正确的 Schema 类型** - 测试数据使用 `NewXxx` 类型声明,如 `NewGenerationBatch`、`NewGeneration`
#### 🔒 安全测试
- [ ] **所有涉及用户数据的操作都包含用户权限检查**
- [ ] 包含了用户权限隔离的安全测试
- [ ] 测试了用户无法访问其他用户数据的场景
#### 🗃️ 数据处理
- [ ] **正确处理外键约束** - 使用 `null` 值或先创建被引用记录
- [ ] **排序测试使用明确时间戳** - 不依赖数据库默认时间,确保结果可预测
- [ ] 在 `beforeEach` 和 `afterEach` 中正确管理数据库状态
- [ ] 所有测试都能独立运行且互不干扰
#### 🎭 Mock 和外部依赖
- [ ] 正确 Mock 了外部依赖服务 (如 FileService、GenerationModel)
- [ ] 在 `beforeEach` 中重置和配置 Mock
- [ ] 验证了 Mock 服务的调用参数和次数
- [ ] 测试了外部服务错误场景的处理
#### 📋 测试覆盖
- [ ] 测试覆盖了所有主要方法 (create, query, update, delete)
- [ ] 测试了边界条件和错误场景
- [ ] 包含了空结果处理的测试
- [ ] **确认两个环境下的测试结果一致**
#### 🚨 常见问题检查
- [ ] 没有外键约束违反错误
- [ ] 排序测试结果稳定可预测
- [ ] Mock 验证无失败
- [ ] 无测试数据污染问题
### 安全警告 ⚠️
**数据库 Model 层是安全的第一道防线**。如果 Model 层缺少用户权限检查:
1. **任何用户都能访问和修改其他用户的数据**
2. **即使上层有权限检查,也可能被绕过**
3. **可能导致严重的数据泄露和安全事故**
因此,**每个涉及用户数据的 Model 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
## 🎯 总结
修复测试时,记住以下关键点:
- **使用正确的命令**: `npx vitest run --config [config-file]`
- **理解测试意图**: 先读懂测试再修复
- **查看最近修改**: 检查相关文件的 git 修改记录,判断问题根源
- **选择正确环境**: 客户端测试用 `vitest.config.ts`,服务端用 `vitest.config.server.ts`
- **专注单一问题**: 只修复当前的测试失败
- **验证修复结果**: 确保修复后测试通过且无副作用
- **提供修复总结**: 说明错误原因和修复方法
- **Model 测试安全第一**: 必须包含用户权限检查和对应的安全测试
- **Model 双环境验证**: 必须在 PGLite 和 PostgreSQL 两个环境下都验证通过
@@ -0,0 +1,453 @@
---
globs: src/database/**/*.test.ts
alwaysApply: false
---
## 🗃️ 数据库 Model 测试指南
### 测试环境选择 💡
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
### ⚠️ 双环境验证要求
**对于所有 Model 测试,必须在两个环境下都验证通过**:
#### 完整验证流程
```bash
# 1. 先在客户端环境测试(快速验证)
npx vitest run --config vitest.config.ts src/database/models/__tests__/myModel.test.ts
# 2. 再在服务端环境测试(兼容性验证)
npx vitest run --config vitest.config.server.ts src/database/models/__tests__/myModel.test.ts
```
### 创建新 Model 测试的最佳实践 📋
#### 1. 参考现有实现和测试模板
创建新 Model 测试前,**必须先参考现有的实现模式**:
- **Model 实现参考**:
- **测试模板参考**:
- **复杂示例参考**:
#### 2. 用户权限检查 - 安全第一 🔒
这是**最关键的安全要求**。所有涉及用户数据的操作都必须包含用户权限检查:
**❌ 错误示例 - 存在安全漏洞**:
```typescript
// 危险:缺少用户权限检查,任何用户都能操作任何数据
update = async (id: string, data: Partial<MyModel>) => {
return this.db
.update(myTable)
.set(data)
.where(eq(myTable.id, id)) // ❌ 只检查 ID,没有检查 userId
.returning();
};
```
**✅ 正确示例 - 安全的实现**:
```typescript
// 安全:必须同时匹配 ID 和 userId
update = async (id: string, data: Partial<MyModel>) => {
return this.db
.update(myTable)
.set(data)
.where(
and(
eq(myTable.id, id),
eq(myTable.userId, this.userId), // ✅ 用户权限检查
),
)
.returning();
};
```
**必须进行用户权限检查的方法**
- `update()` - 更新操作
- `delete()` - 删除操作
- `findById()` - 查找特定记录
- 任何涉及特定记录的查询或修改操作
#### 3. 测试文件结构和必测场景
**基本测试结构**:
```typescript
// @vitest-environment node
describe('MyModel', () => {
describe('create', () => {
it('should create a new record');
it('should handle edge cases');
});
describe('queryAll', () => {
it('should return records for current user only');
it('should handle empty results');
});
describe('update', () => {
it('should update own records');
it('should NOT update other users records'); // 🔒 安全测试
});
describe('delete', () => {
it('should delete own records');
it('should NOT delete other users records'); // 🔒 安全测试
});
describe('user isolation', () => {
it('should enforce user data isolation'); // 🔒 核心安全测试
});
});
```
**必须测试的安全场景** 🔒:
```typescript
it('should not update records of other users', async () => {
// 创建其他用户的记录
const [otherUserRecord] = await serverDB
.insert(myTable)
.values({ userId: 'other-user', data: 'original' })
.returning();
// 尝试更新其他用户的记录
const result = await myModel.update(otherUserRecord.id, { data: 'hacked' });
// 应该返回 undefined 或空数组(因为权限检查失败)
expect(result).toBeUndefined();
// 验证原始数据未被修改
const unchanged = await serverDB.query.myTable.findFirst({
where: eq(myTable.id, otherUserRecord.id),
});
expect(unchanged?.data).toBe('original'); // 数据应该保持不变
});
```
#### 4. Mock 外部依赖服务
如果 Model 依赖外部服务(如 FileService),需要正确 Mock
**设置 Mock**:
```typescript
// 在文件顶部设置 Mock
const mockGetFullFileUrl = vi.fn();
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: mockGetFullFileUrl,
})),
}));
// 在 beforeEach 中重置和配置 Mock
beforeEach(async () => {
vi.clearAllMocks();
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
});
```
**验证 Mock 调用**:
```typescript
it('should process URLs through FileService', async () => {
// ... 测试逻辑
// 验证 Mock 被正确调用
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
});
```
#### 5. 数据库状态管理
**正确的数据清理模式**:
```typescript
const userId = 'test-user';
const otherUserId = 'other-user';
beforeEach(async () => {
// 清理用户表(级联删除相关数据)
await serverDB.delete(users);
// 创建测试用户
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
});
afterEach(async () => {
// 清理测试数据
await serverDB.delete(users);
});
```
#### 6. 测试数据类型和外键约束处理 ⚠️
**必须使用 Schema 导出的类型**:
```typescript
// ✅ 正确:使用 schema 导出的类型
import { NewGeneration, NewGenerationBatch } from '../../schemas';
const testBatch: NewGenerationBatch = {
userId,
generationTopicId: 'test-topic-id',
provider: 'test-provider',
model: 'test-model',
prompt: 'Test prompt for image generation',
width: 1024,
height: 1024,
config: {
/* ... */
},
};
const testGeneration: NewGeneration = {
id: 'test-gen-id',
generationBatchId: 'test-batch-id',
asyncTaskId: null, // 处理外键约束
fileId: null, // 处理外键约束
seed: 12345,
userId,
};
```
```typescript
// ❌ 错误:没有类型声明或使用错误类型
const testBatch = {
// 缺少类型声明
generationTopicId: 'test-topic-id',
// ...
};
const testGeneration = {
// 缺少类型声明
asyncTaskId: 'invalid-uuid', // 外键约束错误
fileId: 'non-existent-file', // 外键约束错误
// ...
};
```
**外键约束处理策略**:
1. **使用 null 值**: 对于可选的外键字段,使用 null 避免约束错误
2. **创建关联记录**: 如果需要测试关联关系,先创建被引用的记录
3. **理解约束关系**: 了解哪些字段有外键约束,避免引用不存在的记录
```typescript
// 外键约束处理示例
beforeEach(async () => {
// 清理数据库
await serverDB.delete(users);
// 创建测试用户
await serverDB.insert(users).values([{ id: userId }]);
// 如果需要测试文件关联,创建文件记录
if (needsFileAssociation) {
await serverDB.insert(files).values({
id: 'test-file-id',
userId,
name: 'test.jpg',
url: 'test-url',
size: 1024,
fileType: 'image/jpeg',
});
}
});
```
**排序测试的可预测性**:
```typescript
// ✅ 正确:使用明确的时间戳确保排序结果可预测
it('should find batches by topic id in correct order', async () => {
const oldDate = new Date('2024-01-01T10:00:00Z');
const newDate = new Date('2024-01-02T10:00:00Z');
const batch1 = { ...testBatch, prompt: 'First batch', userId, createdAt: oldDate };
const batch2 = { ...testBatch, prompt: 'Second batch', userId, createdAt: newDate };
await serverDB.insert(generationBatches).values([batch1, batch2]);
const results = await generationBatchModel.findByTopicId(testTopic.id);
expect(results[0].prompt).toBe('Second batch'); // 最新优先 (desc order)
expect(results[1].prompt).toBe('First batch');
});
```
```typescript
// ❌ 错误:依赖数据库的默认时间戳,结果不可预测
it('should find batches by topic id', async () => {
const batch1 = { ...testBatch, prompt: 'First batch', userId };
const batch2 = { ...testBatch, prompt: 'Second batch', userId };
await serverDB.insert(generationBatches).values([batch1, batch2]);
// 插入顺序和数据库时间戳可能不一致,导致测试不稳定
const results = await generationBatchModel.findByTopicId(testTopic.id);
expect(results[0].prompt).toBe('Second batch'); // 可能失败
});
```
### 常见问题和解决方案 💡
#### 问题 1:权限检查缺失导致安全漏洞
**现象**: 测试失败,用户能修改其他用户的数据 **解决**: 在 Model 的 `update` 和 `delete` 方法中添加 `and(eq(table.id, id), eq(table.userId, this.userId))`
#### 问题 2:Mock 未生效或验证失败
**现象**: `undefined is not a spy` 错误 **解决**: 检查 Mock 设置位置和方式,确保在测试文件顶部设置,在 `beforeEach` 中重置
#### 问题 3:测试数据污染
**现象**: 测试间相互影响,结果不稳定 **解决**: 在 `beforeEach` 和 `afterEach` 中正确清理数据库状态
#### 问题 4:外部依赖导致测试失败
**现象**: 因为真实的外部服务调用导致测试不稳定 **解决**: Mock 所有外部依赖,使测试更可控和快速
#### 问题 5:外键约束违反导致测试失败
**现象**: `insert or update on table "xxx" violates foreign key constraint` **解决**:
- 将可选外键字段设为 `null` 而不是无效的字符串值
- 或者先创建被引用的记录,再创建当前记录
```typescript
// ❌ 错误:无效的外键值
const testData = {
asyncTaskId: 'invalid-uuid', // 表中不存在此记录
fileId: 'non-existent-file', // 表中不存在此记录
};
// ✅ 正确:使用 null 值
const testData = {
asyncTaskId: null, // 避免外键约束
fileId: null, // 避免外键约束
};
// ✅ 或者:先创建被引用的记录
beforeEach(async () => {
const [asyncTask] = await serverDB.insert(asyncTasks).values({
id: 'valid-task-id',
status: 'pending',
type: 'generation',
}).returning();
const testData = {
asyncTaskId: asyncTask.id, // 使用有效的外键值
};
});
```
#### 问题 6:排序测试结果不一致
**现象**: 相同的测试有时通过,有时失败,特别是涉及排序的测试 **解决**: 使用明确的时间戳,不要依赖数据库的默认时间戳
```typescript
// ❌ 错误:依赖插入顺序和默认时间戳
await serverDB.insert(table).values([data1, data2]); // 时间戳不可预测
// ✅ 正确:明确指定时间戳
const oldDate = new Date('2024-01-01T10:00:00Z');
const newDate = new Date('2024-01-02T10:00:00Z');
await serverDB.insert(table).values([
{ ...data1, createdAt: oldDate },
{ ...data2, createdAt: newDate },
]);
```
#### 问题 7:Mock 验证失败或调用次数不匹配
**现象**: `expect(mockFunction).toHaveBeenCalledWith(...)` 失败 **解决**:
- 检查 Mock 函数的实际调用参数和期望参数是否完全匹配
- 确认 Mock 在正确的时机被重置和配置
- 使用 `toHaveBeenCalledTimes()` 验证调用次数
```typescript
// 在 beforeEach 中正确配置 Mock
beforeEach(() => {
vi.clearAllMocks(); // 重置所有 Mock
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
mockTransformGeneration.mockResolvedValue({
id: 'test-id',
// ... 其他字段
});
});
// 测试中验证 Mock 调用
it('should call FileService with correct parameters', async () => {
await model.someMethod();
// 验证调用参数
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
// 验证调用次数
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
});
```
### Model 测试检查清单 ✅
创建 Model 测试时,请确保以下各项都已完成:
#### 🔧 基础配置
- [ ] **双环境验证** - 在客户端环境 (vitest.config.ts) 和服务端环境 (vitest.config.server.ts) 下都测试通过
- [ ] 参考了 `_template.ts` 和现有 Model 的实现模式
- [ ] **使用正确的 Schema 类型** - 测试数据使用 `NewXxx` 类型声明,如 `NewGenerationBatch`、`NewGeneration`
#### 🔒 安全测试
- [ ] **所有涉及用户数据的操作都包含用户权限检查**
- [ ] 包含了用户权限隔离的安全测试
- [ ] 测试了用户无法访问其他用户数据的场景
#### 🗃️ 数据处理
- [ ] **正确处理外键约束** - 使用 `null` 值或先创建被引用记录
- [ ] **排序测试使用明确时间戳** - 不依赖数据库默认时间,确保结果可预测
- [ ] 在 `beforeEach` 和 `afterEach` 中正确管理数据库状态
- [ ] 所有测试都能独立运行且互不干扰
#### 🎭 Mock 和外部依赖
- [ ] 正确 Mock 了外部依赖服务 (如 FileService、GenerationModel)
- [ ] 在 `beforeEach` 中重置和配置 Mock
- [ ] 验证了 Mock 服务的调用参数和次数
- [ ] 测试了外部服务错误场景的处理
#### 📋 测试覆盖
- [ ] 测试覆盖了所有主要方法 (create, query, update, delete)
- [ ] 测试了边界条件和错误场景
- [ ] 包含了空结果处理的测试
- [ ] **确认两个环境下的测试结果一致**
#### 🚨 常见问题检查
- [ ] 没有外键约束违反错误
- [ ] 排序测试结果稳定可预测
- [ ] Mock 验证无失败
- [ ] 无测试数据污染问题
### 安全警告 ⚠️
**数据库 Model 层是安全的第一道防线**。如果 Model 层缺少用户权限检查:
1. **任何用户都能访问和修改其他用户的数据**
2. **即使上层有权限检查,也可能被绕过**
3. **可能导致严重的数据泄露和安全事故**
因此,**每个涉及用户数据的 Model 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
@@ -0,0 +1,80 @@
---
description: Electron IPC 接口测试策略
alwaysApply: false
---
### Electron IPC 接口测试策略 🖥️
对于涉及 Electron IPC 接口的测试,由于提供真实的 Electron 环境比较复杂,采用 **Mock 返回值** 的方式进行测试。
#### 基本 Mock 设置
```typescript
import { vi } from 'vitest';
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
// Mock Electron IPC 客户端
vi.mock('@/server/modules/ElectronIPCClient', () => ({
electronIpcClient: {
getFilePathById: vi.fn(),
deleteFiles: vi.fn(),
// 根据需要添加其他 IPC 方法
},
}));
```
#### 在测试中设置 Mock 行为
```typescript
beforeEach(() => {
// 重置所有 Mock
vi.resetAllMocks();
// 设置默认的 Mock 返回值
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue('/path/to/file.txt');
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
success: true,
});
});
```
#### 测试不同场景的示例
```typescript
it('应该处理文件删除成功的情况', async () => {
// 设置成功场景的 Mock
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
success: true,
});
const result = await service.deleteFiles(['desktop://file1.txt']);
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith(['desktop://file1.txt']);
expect(result.success).toBe(true);
});
it('应该处理文件删除失败的情况', async () => {
// 设置失败场景的 Mock
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValue(new Error('删除失败'));
const result = await service.deleteFiles(['desktop://file1.txt']);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
```
#### Mock 策略的优势
1. **环境简化**: 避免了复杂的 Electron 环境搭建
2. **测试可控**: 可以精确控制 IPC 调用的返回值和行为
3. **场景覆盖**: 容易测试各种成功/失败场景
4. **执行速度**: Mock 调用比真实 IPC 调用更快
#### 注意事项
- **Mock 准确性**: 确保 Mock 的行为与真实 IPC 接口行为一致
- **类型安全**: 使用 `vi.mocked()` 确保类型安全
- **Mock 重置**: 在 `beforeEach` 中重置 Mock 状态,避免测试间干扰
- **调用验证**: 不仅要验证返回值,还要验证 IPC 方法是否被正确调用
@@ -0,0 +1,496 @@
---
globs: *.test.ts,*.test.tsx
alwaysApply: false
---
# 测试指南 - LobeChat Testing Guide
## 🧪 测试环境概览
LobeChat 项目使用 Vitest 测试库,配置了两种不同的测试环境:
### 客户端测试环境 (DOM Environment)
- **配置文件**: [vitest.config.ts](mdc:vitest.config.ts)
- **环境**: Happy DOM (浏览器环境模拟)
- **数据库**: PGLite (浏览器环境的 PostgreSQL)
- **用途**: 测试前端组件、客户端逻辑、React 组件等
- **设置文件**: [tests/setup.ts](mdc:tests/setup.ts)
### 服务端测试环境 (Node Environment)
- **配置文件**: [vitest.config.server.ts](mdc:vitest.config.server.ts)
- **环境**: Node.js
- **数据库**: 真实的 PostgreSQL 数据库
- **并发限制**: 单线程运行 (`singleFork: true`)
- **用途**: 测试数据库模型、服务端逻辑、API 端点等
- **设置文件**: [tests/setup-db.ts](mdc:tests/setup-db.ts)
## 🚀 测试运行命令
**🚨 性能警告**: 项目包含 3000+ 测试用例,完整运行需要约 10 分钟。务必使用文件过滤或测试名称过滤。
### ✅ 正确的命令格式
```bash
# 运行所有客户端/服务端测试
npx vitest run --config vitest.config.ts # 客户端测试
npx vitest run --config vitest.config.server.ts # 服务端测试
# 运行特定测试文件 (支持模糊匹配)
npx vitest run --config vitest.config.ts user.test.ts
# 运行特定测试用例名称 (使用 -t 参数)
npx vitest run --config vitest.config.ts -t "test case name"
# 组合使用文件和测试名称过滤
npx vitest run --config vitest.config.ts filename.test.ts -t "specific test"
# 生成覆盖率报告 (使用 --coverage 参数)
npx vitest run --config vitest.config.ts --coverage
```
### ❌ 避免的命令格式
```bash
# ❌ 这些命令会运行所有 3000+ 测试用例,耗时约 10 分钟!
npm test
npm test some-file.test.ts
# ❌ 不要使用裸 vitest (会进入 watch 模式)
vitest test-file.test.ts
```
## 🔧 测试修复原则
### 核心原则 ⚠️
1. **充分阅读测试代码**: 在修复测试之前,必须完整理解测试的意图和实现
2. **测试优先修复**: 如果是测试本身写错了,修改测试而不是实现代码
3. **专注单一问题**: 只修复指定的测试,不要添加额外测试或功能
4. **不自作主张**: 不要因为发现其他问题就直接修改,先提出再讨论
### 测试协作最佳实践 🤝
基于实际开发经验总结的重要协作原则:
#### 1. 失败处理策略
**核心原则**: 避免盲目重试,快速识别问题并寻求帮助。
- **失败阈值**: 当连续尝试修复测试 1-2 次都失败后,应立即停止继续尝试
- **问题总结**: 分析失败原因,整理已尝试的解决方案及其失败原因
- **寻求帮助**: 带着清晰的问题摘要和尝试记录向团队寻求帮助
- **避免陷阱**: 不要陷入"不断尝试相同或类似方法"的循环
```typescript
// ❌ 错误做法:连续失败后继续盲目尝试
// 第3次、第4次仍在用相似的方法修复同一个问题
// ✅ 正确做法:失败1-2次后总结问题
/*
问题总结:
1. 尝试过的方法:修改 mock 数据结构
2. 失败原因:仍然提示类型不匹配
3. 具体错误:Expected 'UserData' but received 'UserProfile'
4. 需要帮助:不确定最新的 UserData 接口定义
*/
```
#### 2. 测试用例命名规范
**核心原则**: 测试应该关注"行为",而不是"实现细节"。
- **描述业务场景**: `describe` 和 `it` 的标题应该描述具体的业务场景和预期行为
- **避免实现绑定**: 不要在测试名称中提及具体的代码行号、覆盖率目标或实现细节
- **保持稳定性**: 测试名称应该在代码重构后仍然有意义
```typescript
// ❌ 错误的测试命名
describe('User component coverage', () => {
it('covers line 45-50 in getUserData', () => {
// 为了覆盖第45-50行而写的测试
});
it('tests the else branch', () => {
// 仅为了测试某个分支而存在
});
});
// ✅ 正确的测试命名
describe('<UserAvatar />', () => {
it('should render fallback icon when image url is not provided', () => {
// 测试具体的业务场景,自然会覆盖相关代码分支
});
it('should display user initials when avatar image fails to load', () => {
// 描述用户行为和预期结果
});
});
```
**覆盖率提升的正确思路**:
- ✅ 通过设计各种业务场景(正常流程、边缘情况、错误处理)来自然提升覆盖率
- ❌ 不要为了达到覆盖率数字而写测试,更不要在测试中注释"为了覆盖 xxx 行"
#### 3. 测试组织结构
**核心原则**: 维护清晰的测试层次结构,避免冗余的顶级测试块。
- **复用现有结构**: 添加新测试时,优先在现有的 `describe` 块中寻找合适的位置
- **逻辑分组**: 相关的测试用例应该组织在同一个 `describe` 块内
- **避免碎片化**: 不要为了单个测试用例就创建新的顶级 `describe` 块
```typescript
// ❌ 错误的组织方式:创建过多顶级块
describe('<UserProfile />', () => {
it('should render user name', () => {});
});
describe('UserProfile new prop test', () => {
// 不必要的新块
it('should handle email display', () => {});
});
describe('UserProfile edge cases', () => {
// 不必要的新块
it('should handle missing avatar', () => {});
});
// ✅ 正确的组织方式:合并相关测试
describe('<UserProfile />', () => {
it('should render user name', () => {});
it('should handle email display', () => {});
it('should handle missing avatar', () => {});
describe('when user data is incomplete', () => {
// 只有在有多个相关子场景时才创建子组
it('should show placeholder for missing name', () => {});
it('should hide email section when email is undefined', () => {});
});
});
```
**组织决策流程**:
1. 是否存在逻辑相关的现有 `describe` 块? → 如果有,添加到其中
2. 是否有多个(3个以上)相关的测试用例? → 如果有,可以考虑创建新的子 `describe`
3. 是否是独立的、无关联的功能模块? → 如果是,才考虑创建新的顶级 `describe`
### 测试修复流程
1. **复现问题**: 定位并运行失败的测试,确认能在本地复现
2. **分析原因**: 阅读测试代码、错误日志和相关文件的 Git 修改历史
3. **建立假设**: 判断问题出在测试逻辑、实现代码还是环境配置
4. **修复验证**: 根据假设进行修复,重新运行测试确认通过
5. **扩大验证**: 运行当前文件内所有测试,确保没有引入新问题
6. **撰写总结**: 说明错误原因和修复方法
### 修复完成后的总结
测试修复完成后,应该提供简要说明,包括:
1. **错误原因分析**: 说明测试失败的根本原因
- 测试逻辑错误
- 实现代码bug
- 环境配置问题
- 依赖变更导致的问题
2. **修复方法说明**: 简述采用的修复方式
- 修改了哪些文件
- 采用了什么解决方案
- 为什么选择这种修复方式
**示例格式**:
```markdown
## 测试修复总结
**错误原因**: 测试中的 mock 数据格式与实际 API 返回格式不匹配,导致断言失败。
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
```
## 🎯 测试编写最佳实践
### Mock 数据策略:追求"低成本的真实性" 📋
**核心原则**: 测试数据应默认追求真实性,只有在引入"高昂的测试成本"时才进行简化。
#### 什么是"高昂的测试成本"?
"高成本"指的是测试中引入了外部依赖,使测试变慢、不稳定或复杂:
- **文件 I/O 操作**:读写硬盘文件
- **网络请求**:HTTP 调用、数据库连接
- **系统调用**:获取系统时间、环境变量等
#### ✅ 推荐做法:Mock 依赖,保留真实数据
```typescript
// ✅ 好的做法:Mock I/O 操作,但使用真实的文件内容格式
describe('parseContentType', () => {
beforeEach(() => {
// Mock 文件读取操作(避免真实 I/O)
vi.spyOn(fs, 'readFileSync').mockImplementation((path) => {
// 但返回真实的文件内容格式
if (path.includes('.pdf')) return '%PDF-1.4\n%âãÏÓ'; // 真实 PDF 文件头
if (path.includes('.png')) return '\x89PNG\r\n\x1a\n'; // 真实 PNG 文件头
return '';
});
});
it('should detect PDF content type correctly', () => {
const result = parseContentType('/path/to/file.pdf');
expect(result).toBe('application/pdf');
});
});
// ❌ 过度简化:使用不真实的数据
describe('parseContentType', () => {
it('should detect PDF content type correctly', () => {
// 这种简化数据没有测试价值
const result = parseContentType('fake-pdf-content');
expect(result).toBe('application/pdf');
});
});
```
#### 🎯 真实标识符的价值
```typescript
// ✅ 使用真实的提供商标识符
it('should parse OpenAI model list correctly', () => {
const result = parseModelString('openai', '+gpt-4,+gpt-3.5-turbo');
expect(result.add).toHaveLength(2);
expect(result.add[0].id).toBe('gpt-4');
});
// ❌ 使用占位符标识符(价值较低)
it('should parse model list correctly', () => {
const result = parseModelString('test-provider', '+model1,+model2');
expect(result.add).toHaveLength(2);
// 这种测试对理解真实场景帮助不大
});
```
### 错误处理测试:测试"行为"而非"文本" ⚠️
**核心原则**: 测试应该验证程序在错误发生时的行为是可预测的,而不是验证易变的错误信息文本。
#### ✅ 推荐的错误测试方式
```typescript
// ✅ 测试是否抛出错误
it('should throw error when invalid input provided', () => {
expect(() => processInput(null)).toThrow();
});
// ✅ 测试错误类型(最推荐)
it('should throw ValidationError for invalid data', () => {
expect(() => validateUser({})).toThrow(ValidationError);
});
// ✅ 测试错误属性而非消息文本
it('should throw error with correct error code', () => {
expect(() => processPayment({})).toThrow(
expect.objectContaining({
code: 'INVALID_PAYMENT_DATA',
statusCode: 400,
}),
);
});
```
#### ❌ 应避免的做法
```typescript
// ❌ 过度依赖具体错误信息文本
it('should throw specific error message', () => {
expect(() => processUser({})).toThrow('用户数据不能为空,请检查输入参数');
// 这种测试很脆弱,错误文案稍有修改就会失败
});
```
#### 🎯 例外情况:何时可以测试错误信息
```typescript
// ✅ 测试标准 API 错误(这是契约的一部分)
it('should return proper HTTP error for API', () => {
expect(response.statusCode).toBe(400);
expect(response.error).toBe('Bad Request');
});
// ✅ 测试错误信息的关键部分(使用正则)
it('should include field name in validation error', () => {
expect(() => validateField('email', '')).toThrow(/email/i);
});
```
### 疑难解答:警惕模块污染 🚨
**识别信号**: 当你的测试出现以下"灵异"现象时,优先怀疑模块污染:
- 单独运行某个测试通过,但和其他测试一起运行就失败
- 测试的执行顺序影响结果
- Mock 设置看起来正确,但实际使用的是旧的 Mock 版本
#### 典型场景:动态 Mock 同一模块
```typescript
// ❌ 容易出现模块污染的写法
describe('ConfigService', () => {
it('should work in development mode', async () => {
vi.doMock('./config', () => ({ isDev: true }));
const { getSettings } = await import('./configService'); // 第一次加载
expect(getSettings().debugMode).toBe(true);
});
it('should work in production mode', async () => {
vi.doMock('./config', () => ({ isDev: false }));
const { getSettings } = await import('./configService'); // 可能使用缓存的旧版本!
expect(getSettings().debugMode).toBe(false); // ❌ 可能失败
});
});
// ✅ 使用 resetModules 解决模块污染
describe('ConfigService', () => {
beforeEach(() => {
vi.resetModules(); // 清除模块缓存,确保每个测试都是干净的环境
});
it('should work in development mode', async () => {
vi.doMock('./config', () => ({ isDev: true }));
const { getSettings } = await import('./configService');
expect(getSettings().debugMode).toBe(true);
});
it('should work in production mode', async () => {
vi.doMock('./config', () => ({ isDev: false }));
const { getSettings } = await import('./configService');
expect(getSettings().debugMode).toBe(false); // ✅ 测试通过
});
});
```
#### 🔧 排查和解决步骤
1. **识别问题**: 测试失败时,首先问自己:"是否有多个测试在 Mock 同一个模块?"
2. **添加隔离**: 在 `beforeEach` 中添加 `vi.resetModules()`
3. **验证修复**: 重新运行测试,确认问题解决
**记住**: `vi.resetModules()` 是解决测试"灵异"失败的终极武器,当常规调试方法都无效时,它往往能一针见血地解决问题。
## 📂 测试文件组织
### 文件命名约定
- **客户端测试**: `*.test.ts`, `*.test.tsx` (任意位置)
- **服务端测试**: `src/database/models/**/*.test.ts`, `src/database/server/**/*.test.ts` (限定路径)
### 测试文件组织风格
项目采用 **测试文件与源文件同目录** 的组织风格:
- 测试文件放在对应源文件的同一目录下
- 命名格式:`原文件名.test.ts` 或 `原文件名.test.tsx`
例如:
```plaintext
src/components/Button/
├── index.tsx # 源文件
└── index.test.tsx # 测试文件
```
## 🛠️ 测试调试技巧
### 测试调试步骤
1. **确定测试环境**: 根据文件路径选择正确的配置文件
2. **隔离问题**: 使用 `-t` 参数只运行失败的测试用例
3. **分析错误**: 仔细阅读错误信息、堆栈跟踪和最近的文件修改记录
4. **添加调试**: 在测试中添加 `console.log` 了解执行流程
### TypeScript 类型处理 📝
在测试中,为了提高编写效率和可读性,可以适当放宽 TypeScript 类型检测:
#### ✅ 推荐的类型放宽策略
```typescript
// ✅ 使用非空断言访问测试中确定存在的属性
const result = await someFunction();
expect(result!.data).toBeDefined();
expect(result!.status).toBe('success');
// ✅ 使用 any 类型简化复杂的 Mock 设置
const mockStream = new ReadableStream() as any;
mockStream.toReadableStream = () => mockStream;
```
#### 🎯 适用场景
- **Mock 对象**: 对于测试用的 Mock 数据,使用 `as any` 避免复杂的类型定义
- **第三方库**: 处理复杂的第三方库类型时,适当使用 `any` 提高效率
- **测试断言**: 在确定对象存在的测试场景中,使用 `!` 非空断言
- **临时调试**: 快速编写测试时,先用 `any` 保证功能,后续可选择性地优化类型
#### ⚠️ 注意事项
- **适度使用**: 不要过度依赖 `any`,核心业务逻辑的类型仍应保持严格
- **文档说明**: 对于使用 `any` 的复杂场景,添加注释说明原因
- **测试覆盖**: 确保即使使用了 `any`,测试仍能有效验证功能正确性
### 检查最近修改记录 🔍
系统性地检查相关文件的修改历史是问题定位的关键步骤。
#### 三步检查法
**Step 1: 查看当前状态**
```bash
git status # 查看未提交的修改
git diff path/to/component.test.ts | cat # 查看测试文件修改
git diff path/to/component.ts | cat # 查看实现文件修改
```
**Step 2: 查看提交历史**
```bash
git log --pretty=format:"%h %ad %s" --date=relative -3 path/to/component.ts | cat
```
**Step 3: 查看具体修改内容**
```bash
git show HEAD -- path/to/component.ts | cat # 查看最新提交的修改
```
#### 时间相关性判断
- **24小时内的提交**: 🔴 **高度相关** - 很可能是直接原因
- **1-7天内的提交**: 🟡 **中等相关** - 需要仔细分析
- **超过1周的提交**: ⚪ **低相关性** - 除非重大重构
## 特殊场景的测试
针对一些特殊场景的测试,需要阅读相关 rules:
- [Electron IPC 接口测试策略](mdc:./electron-ipc-test.mdc)
- [数据库 Model 测试指南](mdc:./db-model-test.mdc)
## 🎯 核心要点
- **命令格式**: 使用 `npx vitest run --config [config-file]` 并指定文件过滤
- **修复原则**: 失败1-2次后寻求帮助,测试命名关注行为而非实现细节
- **调试流程**: 复现 → 分析 → 假设 → 修复 → 验证 → 总结
- **文件组织**: 优先在现有 `describe` 块中添加测试,避免创建冗余顶级块
- **数据策略**: 默认追求真实性,只有高成本(I/O、网络等)时才简化
- **错误测试**: 测试错误类型和行为,避免依赖具体的错误信息文本
- **模块污染**: 测试"灵异"失败时,优先怀疑模块污染,使用 `vi.resetModules()` 解决
- **安全要求**: Model 测试必须包含权限检查,并在双环境下验证通过
+3 -1
View File
@@ -16,4 +16,6 @@ TypeScript Code Style Guide:
- Always refactor repeated logic into a reusable function
- Don't remove meaningful code comments, be sure to keep original comments when providing applied code
- Update the code comments when needed after you modify the related code
- Please respect my prettier preferences when you provide code
- Please respect my prettier preferences when you provide code
- Prefer object destructuring when accessing and using properties
- Prefer async version api than sync version, eg: use readFile from 'fs/promises' instead of 'fs'
+10
View File
@@ -36,6 +36,16 @@ config.overrides = [
'mdx/code-blocks': false,
},
},
{
files: ['src/store/image/**/*', 'src/types/generation/**/*'],
rules: {
'@typescript-eslint/no-empty-interface': 0,
'sort-keys-fix/sort-keys-fix': 0,
'typescript-sort-keys/interface': 0,
'typescript-sort-keys/string-enum': 0,
},
},
];
module.exports = config;
+3 -3
View File
@@ -155,9 +155,9 @@ jobs:
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
# 将 TEMP 和 TMP 目录设置到 D
TEMP: D:\temp
TMP: D:\temp
# 将 TEMP 和 TMP 目录设置到 C
TEMP: C:\temp
TMP: C:\temp
# Linux 平台构建处理
- name: Build artifact on Linux
+3 -3
View File
@@ -139,9 +139,9 @@ jobs:
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
# 将 TEMP 和 TMP 目录设置到 D
TEMP: D:\temp
TMP: D:\temp
# 将 TEMP 和 TMP 目录设置到 C
TEMP: C:\temp
TMP: C:\temp
# Linux 平台构建处理
- name: Build artifact on Linux
+7 -3
View File
@@ -41,9 +41,6 @@ test-output
# husky
.husky/prepare-commit-msg
# misc
# add other ignore file below
# local env files
.env*.local
@@ -71,3 +68,10 @@ public/swe-worker*
vertex-ai-key.json
.pnpm-store
./packages/lobe-ui
# local use ai coding files
docs/.prd
.claude
.mcp.json
CLAUDE.md
+1038
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -53,6 +53,8 @@ ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
SENTRY_ORG="" \
SENTRY_PROJECT=""
ENV APP_URL="http://app.com"
# Posthog
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
+2
View File
@@ -55,6 +55,8 @@ ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
SENTRY_ORG="" \
SENTRY_PROJECT=""
ENV APP_URL="http://app.com"
# Posthog
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
+3 -3
View File
@@ -383,12 +383,12 @@ In addition, these plugins are not limited to news aggregation, but can also ext
| Recent Submits | Description |
| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-05-27**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-07-21**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
| [Speak](https://lobechat.com/discover/plugin/speak)<br/><sup>By **speak** on **2025-07-18**</sup> | Learn how to say anything in another language with Speak, your AI-powered language tutor.<br/>`education` `language` |
| [Web](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | Smart web search that reads and analyzes pages to deliver comprehensive answers from Google results.<br/>`web` `search` |
| [Bing_websearch](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | Search for information from the internet base BingApi<br/>`bingsearch` |
| [Google CSE](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | Searches Google through their official CSE API.<br/>`web` `search` |
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
> 📊 Total plugins: [<kbd>**43**</kbd>](https://lobechat.com/discover/plugins)
<!-- PLUGIN LIST -->
+3 -3
View File
@@ -376,12 +376,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
| 最近新增 | 描述 |
| -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-05-27**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-07-21**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
| [Speak](https://lobechat.com/discover/plugin/speak)<br/><sup>By **speak** on **2025-07-18**</sup> | 使用 Speak,您的 AI 语言导师,学习如何用另一种语言说任何事情。<br/>`教育` `语言` |
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
| [必应网页搜索](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | 通过 BingApi 搜索互联网上的信息<br/>`bingsearch` |
| [谷歌自定义搜索引擎](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | 通过他们的官方自定义搜索引擎 API 搜索谷歌。<br/>`网络` `搜索` |
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
> 📊 Total plugins: [<kbd>**43**</kbd>](https://lobechat.com/discover/plugins)
<!-- PLUGIN LIST -->
+2
View File
@@ -1,4 +1,6 @@
lockfile=false
shamefully-hoist=true
ignore-workspace-root-check=true
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+326 -33
View File
@@ -1,60 +1,353 @@
# LobeHub Desktop Application
# 🤯 LobeHub Desktop Application
LobeHub Desktop [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平台桌面应用程序,使用 Electron 构建,提供了更加原生的桌面体验和功能。
LobeHub Desktop is a cross-platform desktop application for [LobeChat](https://github.com/lobehub/lobe-chat), built with Electron, providing a more native desktop experience and functionality.
## 功能特点
## ✨ Features
- **跨平台支持**:支持 macOS (Intel/Apple Silicon)Windows Linux 系统
- **自动更新**:内置更新机制,确保您始终使用最新版本
- **多语言支持**:完整的国际化支持,包括中文、英文等多种语言
- **原生集成**:与操作系统深度集成,提供原生菜单、快捷键和通知
- **安全可靠**:macOS 版本经过公证,确保安全性
- **多渠道发布**:提供稳定版、测试版和每日构建版本
- **🌍 Cross-platform Support**: Supports macOS (Intel/Apple Silicon), Windows, and Linux systems
- **🔄 Auto Updates**: Built-in update mechanism ensures you always have the latest version
- **🌐 Multi-language Support**: Complete i18n support for 18+ languages with lazy loading
- **🎨 Native Integration**: Deep OS integration with native menus, shortcuts, and notifications
- **🔒 Secure & Reliable**: macOS notarized, encrypted token storage, secure OAuth flow
- **📦 Multiple Release Channels**: Stable, beta, and nightly build versions
- **⚡ Advanced Window Management**: Multi-window architecture with theme synchronization
- **🔗 Remote Server Sync**: Secure data synchronization with remote LobeChat instances
- **🎯 Developer Tools**: Built-in development panel and comprehensive debugging tools
## 开发环境设置
## 🚀 Development Setup
### 前提条件
### Prerequisites
- Node.js 22+
- pnpm 10+
- **Node.js** 22+
- **pnpm** 10+
- **Electron** compatible development environment
### 安装依赖
### Quick Start
```bash
# Install dependencies
pnpm install-isolated
```
### 开发模式运行
```bash
# Start development server
pnpm electron:dev
# Type checking
pnpm typecheck
# Run tests
pnpm test
```
### 构建应用
### Environment Configuration
构建所有平台:
Copy `.env.desktop` to `.env` and configure as needed:
```bash
pnpm build
cp .env.desktop .env
```
构建特定平台:
> \[!WARNING]
> Backup your `.env` file before making changes to avoid losing configurations.
### Build Commands
| Command | Description |
| ------------------ | --------------------------------------- |
| `pnpm build` | Build for all platforms |
| `pnpm build:mac` | Build for macOS (Intel + Apple Silicon) |
| `pnpm build:win` | Build for Windows |
| `pnpm build:linux` | Build for Linux |
| `pnpm build-local` | Local development build |
### Development Workflow
```bash
# macOS
pnpm build:mac
# 1. Development
pnpm electron:dev # Start with hot reload
# Windows
pnpm build:win
# 2. Code Quality
pnpm lint # ESLint checking
pnpm format # Prettier formatting
pnpm typecheck # TypeScript validation
# Linux
pnpm build:linux
# 3. Testing
pnpm test # Run Vitest tests
# 4. Build & Package
pnpm build # Production build
pnpm build-local # Local testing build
```
## 发布渠道
## 🎯 Release Channels
应用提供三个发布渠道:
| Channel | Description | Stability | Auto-Updates |
| ----------- | -------------------------------- | --------- | ------------ |
| **Stable** | Thoroughly tested releases | 🟢 High | ✅ Yes |
| **Beta** | Pre-release with new features | 🟡 Medium | ✅ Yes |
| **Nightly** | Daily builds with latest changes | 🟠 Low | ✅ Yes |
- **稳定版**:经过充分测试的正式版本
- **测试版 (Beta)**:预发布版本,包含即将发布的新功能
- **每日构建版 (Nightly)**:包含最新开发进展的构建版本
## 🛠 Technology Stack
### Core Framework
- **Electron** `37.1.0` - Cross-platform desktop framework
- **Node.js** `22+` - Backend runtime
- **TypeScript** `5.7+` - Type-safe development
- **Vite** `6.2+` - Build tooling
### Architecture & Patterns
- **Dependency Injection** - IoC container with decorator-based registration
- **Event-Driven Architecture** - IPC communication between processes
- **Module Federation** - Dynamic controller and service loading
- **Observer Pattern** - State management and UI synchronization
### Development Tools
- **Vitest** - Unit testing framework
- **ESLint** - Code linting
- **Prettier** - Code formatting
- **electron-builder** - Application packaging
- **electron-updater** - Auto-update mechanism
### Security & Storage
- **Electron Safe Storage** - Encrypted token storage
- **OAuth 2.0 + PKCE** - Secure authentication flow
- **electron-store** - Persistent configuration
- **Custom Protocol Handler** - Secure callback handling
## 🏗 Architecture
The desktop application uses a sophisticated dependency injection and event-driven architecture:
### 📁 Core Structure
```
src/main/core/
├── App.ts # 🎯 Main application orchestrator
├── IoCContainer.ts # 🔌 Dependency injection container
├── window/ # 🪟 Window management modules
│ ├── WindowThemeManager.ts # 🎨 Theme synchronization
│ ├── WindowPositionManager.ts # 📐 Position persistence
│ ├── WindowErrorHandler.ts # ⚠️ Error boundaries
│ └── WindowConfigBuilder.ts # ⚙️ Configuration builder
├── browser/ # 🌐 Browser management modules
│ ├── Browser.ts # 🪟 Individual window instances
│ └── BrowserManager.ts # 👥 Multi-window coordinator
├── ui/ # 🎨 UI system modules
│ ├── Tray.ts # 📍 System tray integration
│ ├── TrayManager.ts # 🔧 Tray management
│ ├── MenuManager.ts # 📋 Native menu system
│ └── ShortcutManager.ts # ⌨️ Global shortcuts
└── infrastructure/ # 🔧 Infrastructure services
├── StoreManager.ts # 💾 Configuration storage
├── I18nManager.ts # 🌍 Internationalization
├── UpdaterManager.ts # 📦 Auto-update system
└── StaticFileServerManager.ts # 🗂️ Local file serving
```
### 🔄 Application Lifecycle
The `App.ts` class orchestrates the entire application lifecycle through key phases:
#### 1. 🚀 Initialization Phase
- **System Information Logging** - Captures OS, CPU, RAM, and locale details
- **Store Manager Setup** - Initializes persistent configuration storage
- **Dynamic Module Loading** - Auto-discovers controllers and services via glob imports
- **IPC Event Registration** - Sets up inter-process communication channels
#### 2. 🏃 Bootstrap Phase
- **Single Instance Check** - Ensures only one application instance runs
- **IPC Server Launch** - Starts the communication server
- **Core Manager Initialization** - Sequential initialization of all managers:
- 🌍 I18n for internationalization
- 📋 Menu system for native menus
- 🗂️ Static file server for local assets
- ⌨️ Global shortcuts registration
- 🪟 Browser window management
- 📍 System tray (Windows only)
- 📦 Auto-updater system
### 🔧 Core Components Deep Dive
#### 🌐 Browser Management System
- **Multi-Window Architecture** - Supports chat, settings, and devtools windows
- **Window State Management** - Handles positioning, theming, and lifecycle
- **WebContents Mapping** - Bidirectional mapping between WebContents and identifiers
- **Event Broadcasting** - Centralized event distribution to all or specific windows
#### 🔌 Dependency Injection & Event System
- **IoC Container** - WeakMap-based container for decorated controller methods
- **Decorator Registration** - `@ipcClientEvent` and `@ipcServerEvent` decorators
- **Automatic Event Mapping** - Events registered during controller loading
- **Service Locator** - Type-safe service and controller retrieval
#### 🪟 Window Management
- **Theme-Aware Windows** - Automatic adaptation to system dark/light mode
- **Platform-Specific Styling** - Windows title bar and overlay customization
- **Position Persistence** - Save and restore window positions across sessions
- **Error Boundaries** - Centralized error handling for window operations
#### 🔧 Infrastructure Services
##### 🌍 I18n Manager
- **18+ Language Support** with lazy loading and namespace organization
- **System Integration** with Electron's locale detection
- **Dynamic UI Refresh** on language changes
- **Resource Management** with efficient loading strategies
##### 📦 Update Manager
- **Multi-Channel Support** (stable, beta, nightly) with configurable intervals
- **Background Downloads** with progress tracking and user notifications
- **Rollback Protection** with error handling and recovery mechanisms
- **Channel Management** with automatic channel switching
##### 💾 Store Manager
- **Type-Safe Storage** using electron-store with TypeScript interfaces
- **Encrypted Secrets** via Electron's Safe Storage API
- **Configuration Validation** with default value management
- **File System Integration** with automatic directory creation
##### 🗂️ Static File Server
- **Local HTTP Server** for serving application assets and user files
- **Security Controls** with request filtering and access validation
- **File Management** with upload, download, and deletion capabilities
- **Path Resolution** with intelligent routing between storage locations
#### 🎨 UI System Integration
- **Global Shortcuts** - Platform-aware keyboard shortcut registration with conflict detection
- **System Tray** - Native integration with context menus and notifications
- **Native Menus** - Platform-specific application and context menus with i18n
- **Theme Synchronization** - Automatic theme updates across all UI components
### 🏛 Controller & Service Architecture
#### 🎮 Controller Pattern
- **IPC Event Handling** - Processes events from renderer with decorator-based registration
- **Lifecycle Hooks** - `beforeAppReady` and `afterAppReady` for initialization phases
- **Type-Safe Communication** - Strong typing for all IPC events and responses
- **Error Boundaries** - Comprehensive error handling with proper propagation
#### 🔧 Service Pattern
- **Business Logic Encapsulation** - Clean separation of concerns
- **Dependency Management** - Managed through IoC container
- **Cross-Controller Sharing** - Services accessible via service locator pattern
- **Resource Management** - Proper initialization and cleanup
### 🔗 Inter-Process Communication
#### 📡 IPC System Features
- **Bidirectional Communication** - Main↔Renderer and Main↔Next.js server
- **Type-Safe Events** - TypeScript interfaces for all event parameters
- **Context Awareness** - Events include sender context for window-specific operations
- **Error Propagation** - Centralized error handling with proper status codes
#### 🛡️ Security Features
- **OAuth 2.0 + PKCE** - Secure authentication with state parameter validation
- **Encrypted Token Storage** - Using Electron's Safe Storage API when available
- **Custom Protocol Handler** - Secure callback handling for OAuth flows
- **Request Filtering** - Security controls for web requests and external links
## 🧪 Testing
### Test Structure
```bash
apps/desktop/src/main/controllers/__tests__/ # Controller unit tests
tests/ # Integration tests
```
### Running Tests
```bash
pnpm test # Run all tests
pnpm test:watch # Watch mode
pnpm typecheck # Type validation
```
### Test Coverage
- **Controller Tests** - IPC event handling validation
- **Service Tests** - Business logic verification
- **Integration Tests** - End-to-end workflow testing
- **Type Tests** - TypeScript interface validation
## 🔒 Security Features
### Authentication & Authorization
- **OAuth 2.0 Flow** with PKCE for secure token exchange
- **State Parameter Validation** to prevent CSRF attacks
- **Encrypted Token Storage** using platform-native secure storage
- **Automatic Token Refresh** with fallback to re-authentication
### Application Security
- **Code Signing** - macOS notarization for enhanced security
- **Sandboxing** - Controlled access to system resources
- **CSP Controls** - Content Security Policy management
- **Request Filtering** - Security controls for external requests
### Data Protection
- **Encrypted Configuration** - Sensitive data encrypted at rest
- **Secure IPC** - Type-safe communication channels
- **Path Validation** - Secure file system access controls
- **Network Security** - HTTPS enforcement and proxy support
## 🤝 Contribution
Desktop application development involves complex cross-platform considerations and native integrations. We welcome community contributions to improve functionality, performance, and user experience. You can participate in improvements through:
### How to Contribute
1. **Platform Support**: Enhance cross-platform compatibility and native integrations
2. **Performance Optimization**: Improve application startup time, memory usage, and responsiveness
3. **Feature Development**: Add new desktop-specific features and capabilities
4. **Bug Fixes**: Fix platform-specific issues and edge cases
5. **Security Improvements**: Enhance security measures and authentication flows
6. **UI/UX Enhancements**: Improve desktop user interface and experience
### Contribution Process
1. Fork the [LobeChat repository](https://github.com/lobehub/lobe-chat)
2. Set up the desktop development environment following our setup guide
3. Make your changes to the desktop application
4. Submit a Pull Request describing:
- Platform compatibility testing results
- Performance impact analysis
- Security considerations
- User experience improvements
- Breaking changes (if any)
### Development Areas
- **Core Architecture**: Dependency injection, event system, and lifecycle management
- **Window Management**: Multi-window support, theme synchronization, and state persistence
- **IPC Communication**: Type-safe inter-process communication between main and renderer
- **Platform Integration**: Native menus, shortcuts, notifications, and system tray
- **Security Features**: OAuth flows, token encryption, and secure storage
- **Auto-Update System**: Multi-channel updates and rollback mechanisms
## 📚 Additional Resources
- **Development Guide**: [`Development.md`](./Development.md) - Comprehensive development documentation
- **Architecture Docs**: [`/docs`](../../docs/) - Detailed technical specifications
- **Contributing**: [`CONTRIBUTING.md`](../../CONTRIBUTING.md) - Contribution guidelines
- **Issues & Support**: [GitHub Issues](https://github.com/lobehub/lobe-chat/issues)
+353
View File
@@ -0,0 +1,353 @@
# 🤯 LobeHub 桌面应用程序
LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平台桌面应用程序,使用 Electron 构建,提供了更加原生的桌面体验和功能。
## ✨ 功能特点
- **🌍 跨平台支持**:支持 macOS (Intel/Apple Silicon)、Windows 和 Linux 系统
- **🔄 自动更新**:内置更新机制,确保您始终使用最新版本
- **🌐 多语言支持**:完整的 i18n 支持,包含 18+ 种语言的懒加载
- **🎨 原生集成**:与操作系统深度集成,提供原生菜单、快捷键和通知
- **🔒 安全可靠**:macOS 公证认证,加密令牌存储,安全的 OAuth 流程
- **📦 多渠道发布**:提供稳定版、测试版和每日构建版本
- **⚡ 高级窗口管理**:多窗口架构,支持主题同步
- **🔗 远程服务器同步**:与远程 LobeChat 实例的安全数据同步
- **🎯 开发者工具**:内置开发面板和全面的调试工具
## 🚀 开发环境设置
### 前提条件
- **Node.js** 22+
- **pnpm** 10+
- **Electron** 兼容的开发环境
### 快速开始
```bash
# 安装依赖
pnpm install-isolated
# 启动开发服务器
pnpm electron:dev
# 类型检查
pnpm typecheck
# 运行测试
pnpm test
```
### 环境变量配置
复制 `.env.desktop``.env` 并根据需要配置:
```bash
cp .env.desktop .env
```
> \[!WARNING]
> 在进行更改之前请备份您的 `.env` 文件,避免丢失配置。
### 构建命令
| 命令 | 描述 |
| ------------------ | ---------------------------------- |
| `pnpm build` | 构建所有平台 |
| `pnpm build:mac` | 构建 macOS (Intel + Apple Silicon) |
| `pnpm build:win` | 构建 Windows |
| `pnpm build:linux` | 构建 Linux |
| `pnpm build-local` | 本地开发构建 |
### 开发工作流
```bash
# 1. 开发
pnpm electron:dev # 启动热重载开发服务器
# 2. 代码质量
pnpm lint # ESLint 检查
pnpm format # Prettier 格式化
pnpm typecheck # TypeScript 验证
# 3. 测试
pnpm test # 运行 Vitest 测试
# 4. 构建和打包
pnpm build # 生产构建
pnpm build-local # 本地测试构建
```
## 🎯 发布渠道
| 渠道 | 描述 | 稳定性 | 自动更新 |
| ------------------------ | ---------------------- | ------ | -------- |
| **稳定版** | 经过充分测试的正式版本 | 🟢 高 | ✅ 是 |
| **测试版 (Beta)** | 包含新功能的预发布版本 | 🟡 中 | ✅ 是 |
| **每日构建版 (Nightly)** | 包含最新更改的每日构建 | 🟠 低 | ✅ 是 |
## 🛠 技术栈
### 核心框架
- **Electron** `37.1.0` - 跨平台桌面框架
- **Node.js** `22+` - 后端运行时
- **TypeScript** `5.7+` - 类型安全开发
- **Vite** `6.2+` - 构建工具
### 架构和模式
- **依赖注入** - 基于装饰器注册的 IoC 容器
- **事件驱动架构** - 进程间 IPC 通信
- **模块联邦** - 动态控制器和服务加载
- **观察者模式** - 状态管理和 UI 同步
### 开发工具
- **Vitest** - 单元测试框架
- **ESLint** - 代码检查
- **Prettier** - 代码格式化
- **electron-builder** - 应用程序打包
- **electron-updater** - 自动更新机制
### 安全和存储
- **Electron Safe Storage** - 加密令牌存储
- **OAuth 2.0 + PKCE** - 安全认证流程
- **electron-store** - 持久化配置
- **自定义协议处理器** - 安全回调处理
## 🏗 架构设计
桌面应用程序采用了复杂的依赖注入和事件驱动架构:
### 📁 核心结构
```
src/main/core/
├── App.ts # 🎯 主应用程序协调器
├── IoCContainer.ts # 🔌 依赖注入容器
├── window/ # 🪟 窗口管理模块
│ ├── WindowThemeManager.ts # 🎨 主题同步
│ ├── WindowPositionManager.ts # 📐 位置持久化
│ ├── WindowErrorHandler.ts # ⚠️ 错误边界
│ └── WindowConfigBuilder.ts # ⚙️ 配置构建器
├── browser/ # 🌐 浏览器管理模块
│ ├── Browser.ts # 🪟 单个窗口实例
│ └── BrowserManager.ts # 👥 多窗口协调器
├── ui/ # 🎨 UI 系统模块
│ ├── Tray.ts # 📍 系统托盘集成
│ ├── TrayManager.ts # 🔧 托盘管理
│ ├── MenuManager.ts # 📋 原生菜单系统
│ └── ShortcutManager.ts # ⌨️ 全局快捷键
└── infrastructure/ # 🔧 基础设施服务
├── StoreManager.ts # 💾 配置存储
├── I18nManager.ts # 🌍 国际化
├── UpdaterManager.ts # 📦 自动更新系统
└── StaticFileServerManager.ts # 🗂️ 本地文件服务
```
### 🔄 应用程序生命周期
`App.ts` 类通过几个关键阶段协调整个应用程序的生命周期:
#### 1. 🚀 初始化阶段
- **系统信息记录** - 捕获操作系统、CPU、内存和区域设置详细信息
- **存储管理器设置** - 初始化持久配置存储
- **动态模块加载** - 通过 glob 导入自动发现控制器和服务
- **IPC 事件注册** - 设置进程间通信通道
#### 2. 🏃 引导阶段
- **单实例检查** - 确保只运行一个应用程序实例
- **IPC 服务器启动** - 启动通信服务器
- **核心管理器初始化** - 按顺序初始化所有管理器:
- 🌍 国际化管理器
- 📋 原生菜单系统
- 🗂️ 本地资源服务器
- ⌨️ 全局快捷键注册
- 🪟 浏览器窗口管理
- 📍 系统托盘(仅 Windows
- 📦 自动更新系统
### 🔧 核心组件深度解析
#### 🌐 浏览器管理系统
- **多窗口架构** - 支持聊天、设置和开发工具窗口
- **窗口状态管理** - 处理定位、主题和生命周期
- **WebContents 映射** - WebContents 和标识符之间的双向映射
- **事件广播** - 向所有或特定窗口的集中事件分发
#### 🔌 依赖注入和事件系统
- **IoC 容器** - 基于 WeakMap 的装饰控制器方法容器
- **装饰器注册** - `@ipcClientEvent``@ipcServerEvent` 装饰器
- **自动事件映射** - 控制器加载期间注册的事件
- **服务定位器** - 类型安全的服务和控制器检索
#### 🪟 窗口管理
- **主题感知窗口** - 自动适应系统深色 / 浅色模式
- **平台特定样式** - Windows 标题栏和覆盖自定义
- **位置持久化** - 跨会话保存和恢复窗口位置
- **错误边界** - 窗口操作的集中错误处理
#### 🔧 基础设施服务
##### 🌍 国际化管理器
- **18+ 语言支持** 懒加载和命名空间组织
- **系统集成** 与 Electron 的区域检测集成
- **动态 UI 刷新** 语言更改时的 UI 更新
- **资源管理** 高效的加载策略
##### 📦 更新管理器
- **多渠道支持** (稳定版、测试版、每日构建)可配置间隔
- **后台下载** 进度跟踪和用户通知
- **回滚保护** 错误处理和恢复机制
- **渠道管理** 自动渠道切换
##### 💾 存储管理器
- **类型安全存储** 使用带有 TypeScript 接口的 electron-store
- **加密机密** 通过 Electron 的安全存储 API
- **配置验证** 默认值管理
- **文件系统集成** 自动目录创建
##### 🗂️ 静态文件服务器
- **本地 HTTP 服务器** 用于提供应用程序资源和用户文件
- **安全控制** 请求过滤和访问验证
- **文件管理** 上传、下载和删除功能
- **路径解析** 存储位置之间的智能路由
#### 🎨 UI 系统集成
- **全局快捷键** - 平台感知的键盘快捷键注册与冲突检测
- **系统托盘** - 带有上下文菜单和通知的原生集成
- **原生菜单** - 带有 i18n 的平台特定应用程序和上下文菜单
- **主题同步** - 所有 UI 组件的自动主题更新
### 🏛 控制器和服务架构
#### 🎮 控制器模式
- **IPC 事件处理** - 通过基于装饰器的注册处理来自渲染器的事件
- **生命周期钩子** - 初始化阶段的 `beforeAppReady``afterAppReady`
- **类型安全通信** - 所有 IPC 事件和响应的强类型
- **错误边界** - 具有适当传播的全面错误处理
#### 🔧 服务模式
- **业务逻辑封装** - 关注点的清晰分离
- **依赖管理** - 通过 IoC 容器管理
- **跨控制器共享** - 通过服务定位器模式访问的服务
- **资源管理** - 适当的初始化和清理
### 🔗 进程间通信
#### 📡 IPC 系统功能
- **双向通信** - Main↔Renderer 和 Main↔Next.js 服务器
- **类型安全事件** - 所有事件参数的 TypeScript 接口
- **上下文感知** - 事件包含用于窗口特定操作的发送者上下文
- **错误传播** - 具有适当状态码的集中错误处理
#### 🛡️ 安全功能
- **OAuth 2.0 + PKCE** - 具有状态参数验证的安全认证
- **加密令牌存储** - 在可用时使用 Electron 的安全存储 API
- **自定义协议处理器** - OAuth 流程的安全回调处理
- **请求过滤** - 网络请求和外部链接的安全控制
## 🧪 测试
### 测试结构
```bash
apps/desktop/src/main/controllers/__tests__/ # 控制器单元测试
tests/ # 集成测试
```
### 运行测试
```bash
pnpm test # 运行所有测试
pnpm test:watch # 监视模式
pnpm typecheck # 类型验证
```
### 测试覆盖
- **控制器测试** - IPC 事件处理验证
- **服务测试** - 业务逻辑验证
- **集成测试** - 端到端工作流测试
- **类型测试** - TypeScript 接口验证
## 🔒 安全功能
### 认证和授权
- **OAuth 2.0 流程** 使用 PKCE 进行安全令牌交换
- **状态参数验证** 防止 CSRF 攻击
- **加密令牌存储** 使用平台原生安全存储
- **自动令牌刷新** 在失败时回退到重新认证
### 应用程序安全
- **代码签名** - macOS 公证认证以增强安全性
- **沙盒** - 对系统资源的受控访问
- **CSP 控制** - 内容安全策略管理
- **请求过滤** - 外部请求的安全控制
### 数据保护
- **加密配置** - 敏感数据静态加密
- **安全 IPC** - 类型安全的通信通道
- **路径验证** - 安全的文件系统访问控制
- **网络安全** - HTTPS 强制和代理支持
## 🤝 参与贡献
桌面应用程序开发涉及复杂的跨平台考虑和原生集成。我们欢迎社区贡献来改进功能、性能和用户体验。您可以通过以下方式参与改进:
### 如何贡献
1. **平台支持**:增强跨平台兼容性和原生集成
2. **性能优化**:改进应用程序启动时间、内存使用和响应性
3. **功能开发**:添加新的桌面特定功能和能力
4. **错误修复**:修复平台特定问题和边缘情况
5. **安全改进**:增强安全措施和认证流程
6. **UI/UX 增强**:改进桌面用户界面和体验
### 贡献流程
1. Fork [LobeChat 仓库](https://github.com/lobehub/lobe-chat)
2. 按照我们的设置指南建立桌面开发环境
3. 对桌面应用程序进行修改
4. 提交 Pull Request 并描述:
- 平台兼容性测试结果
- 性能影响分析
- 安全考虑
- 用户体验改进
- 破坏性更改(如有)
### 开发领域
- **核心架构**:依赖注入、事件系统和生命周期管理
- **窗口管理**:多窗口支持、主题同步和状态持久化
- **IPC 通信**:主进程和渲染进程之间的类型安全进程间通信
- **平台集成**:原生菜单、快捷键、通知和系统托盘
- **安全功能**:OAuth 流程、令牌加密和安全存储
- **自动更新系统**:多渠道更新和回滚机制
## 📚 其他资源
- **开发指南**[`Development.md`](./Development.md) - 全面的开发文档
- **架构文档**[`/docs`](../../docs/) - 详细的技术规范
- **贡献指南**[`CONTRIBUTING.md`](../../CONTRIBUTING.md) - 贡献指导
- **问题和支持**[GitHub Issues](https://github.com/lobehub/lobe-chat/issues)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 171 KiB

+5
View File
@@ -25,6 +25,11 @@ const config = {
artifactName: '${productName}-${version}.${ext}',
},
asar: true,
asarUnpack: [
// https://github.com/electron-userland/electron-builder/issues/9001#issuecomment-2778802044
'**/node_modules/sharp/**/*',
'**/node_modules/@img/**/*',
],
detectUpdateChannel: true,
directories: {
buildResources: 'build',
+4 -2
View File
@@ -11,8 +11,9 @@ console.log(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}
export default defineConfig({
main: {
build: {
minify: !isDev,
outDir: 'dist/main',
sourcemap: isDev,
sourcemap: isDev ? 'inline' : false,
},
// 这里是关键:在构建时进行文本替换
define: {
@@ -30,8 +31,9 @@ export default defineConfig({
},
preload: {
build: {
minify: !isDev,
outDir: 'dist/preload',
sourcemap: isDev,
sourcemap: isDev ? 'inline' : false,
},
plugins: [externalizeDepsPlugin({})],
resolve: {
+15 -6
View File
@@ -1,6 +1,7 @@
{
"name": "lobehub-desktop-dev",
"version": "0.0.10",
"version": "0.0.0",
"private": true,
"description": "LobeHub Desktop Application",
"homepage": "https://lobehub.com",
"repository": {
@@ -14,6 +15,7 @@
"build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.js --publish never",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.js --publish never",
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.js --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.js --publish never",
"electron:dev": "electron-vite dev",
"electron:run-unpack": "electron .",
@@ -24,10 +26,12 @@
"lint": "eslint --cache ",
"pg-server": "bun run scripts/pglite-server.ts",
"start": "electron-vite preview",
"test": "vitest --run",
"typecheck": "tsgo --noEmit -p tsconfig.json"
},
"dependencies": {
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"get-port-please": "^3.1.2",
"pdfjs-dist": "4.10.38"
},
@@ -45,10 +49,10 @@
"@types/resolve": "^1.20.6",
"@types/semver": "^7.7.0",
"@types/set-cookie-parser": "^2.4.10",
"@typescript/native-preview": "latest",
"@typescript/native-preview": "7.0.0-dev.20250711.1",
"consola": "^3.1.0",
"cookie": "^1.0.2",
"electron": "^37.2.0",
"electron": "~37.1.0",
"electron-builder": "^26.0.12",
"electron-is": "^3.0.0",
"electron-log": "^5.3.3",
@@ -56,19 +60,24 @@
"electron-vite": "^3.0.0",
"execa": "^9.5.2",
"fix-path": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"just-diff": "^6.0.2",
"lodash": "^4.17.21",
"pglite-server": "^0.1.4",
"lodash-es": "^4.17.21",
"resolve": "^1.22.8",
"semver": "^7.5.4",
"set-cookie-parser": "^2.7.1",
"tsx": "^4.19.3",
"typescript": "^5.7.3",
"vite": "^6.2.5"
"undici": "^7.9.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron"
"electron",
"electron-builder"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

-14
View File
@@ -1,14 +0,0 @@
import { PGlite } from "@electric-sql/pglite";
import { createServer } from "pglite-server";
// 创建或连接到您现有的 PGlite 数据库
const db = new PGlite("/Users/arvinxx/Library/Application Support/lobehub-desktop/lobehub-local-db");
await db.waitReady;
// 创建服务器并监听端口
const PORT = 6543;
const pgServer = createServer(db);
pgServer.listen(PORT, () => {
console.log(`PGlite 服务器已启动,监听端口 ${PORT}`);
});
+1 -1
View File
@@ -1,4 +1,4 @@
import type { BrowserWindowOpts } from './core/Browser';
import type { BrowserWindowOpts } from './core/browser/Browser';
export const BrowsersIdentifiers = {
chat: 'chat',
+3
View File
@@ -27,3 +27,6 @@ export const LOCAL_DATABASE_DIR = 'lobehub-local-db';
export const FILE_STORAGE_DIR = 'file-storage';
// Plugin 安装目录
export const INSTALL_PLUGINS_DIR = 'plugins';
// Desktop file service
export const LOCAL_STORAGE_URL_PREFIX = '/lobe-desktop-file';
+27 -1
View File
@@ -1,3 +1,29 @@
export const isDev = process.env.NODE_ENV === 'development';
import { dev, linux, macOS, windows } from 'electron-is';
import os from 'node:os';
export const isDev = dev();
export const OFFICIAL_CLOUD_SERVER = process.env.OFFICIAL_CLOUD_SERVER || 'https://lobechat.com';
export const isMac = macOS();
export const isWindows = windows();
export const isLinux = linux();
function getIsWindows11() {
if (!isWindows) return false;
// 获取操作系统版本(如 "10.0.22621"
const release = os.release();
const parts = release.split('.');
// 主版本和次版本
const majorVersion = parseInt(parts[0], 10);
const minorVersion = parseInt(parts[1], 10);
// 构建号是第三部分
const buildNumber = parseInt(parts[2], 10);
// Windows 11 的构建号从 22000 开始
return majorVersion === 10 && minorVersion === 0 && buildNumber >= 22_000;
}
export const isWindows11 = getIsWindows11();
+13
View File
@@ -1,6 +1,8 @@
/**
* 应用设置存储相关常量
*/
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { appStorageDir } from '@/const/dir';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import { ElectronMainStore } from '@/types/store';
@@ -10,6 +12,15 @@ import { ElectronMainStore } from '@/types/store';
*/
export const STORE_NAME = 'lobehub-settings';
export const defaultProxySettings: NetworkProxySettings = {
enableProxy: false,
proxyBypass: 'localhost, 127.0.0.1, ::1',
proxyPort: '',
proxyRequireAuth: false,
proxyServer: '',
proxyType: 'http',
};
/**
* 存储默认值
*/
@@ -17,6 +28,8 @@ export const STORE_DEFAULTS: ElectronMainStore = {
dataSyncConfig: { storageMode: 'local' },
encryptedTokens: {},
locale: 'auto',
networkProxy: defaultProxySettings,
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
storagePath: appStorageDir,
themeMode: 'auto',
};
+11
View File
@@ -0,0 +1,11 @@
// Theme colors
export const BACKGROUND_DARK = '#000';
export const BACKGROUND_LIGHT = '#f8f8f8';
export const SYMBOL_COLOR_DARK = '#ffffff80';
export const SYMBOL_COLOR_LIGHT = '#00000080';
// Window dimensions and constraints
export const TITLE_BAR_HEIGHT = 29;
// Default window configuration
export const THEME_CHANGE_DELAY = 100;
+310 -111
View File
@@ -1,10 +1,9 @@
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
import { BrowserWindow, app, shell } from 'electron';
import { BrowserWindow, shell } from 'electron';
import crypto from 'node:crypto';
import querystring from 'node:querystring';
import { URL } from 'node:url';
import { name } from '@/../../package.json';
import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -13,10 +12,9 @@ import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:AuthCtr');
const protocolPrefix = `com.lobehub.${name}`;
/**
* Authentication Controller
* Used to implement the OAuth authorization flow
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
*/
export default class AuthCtr extends ControllerModule {
/**
@@ -32,9 +30,29 @@ export default class AuthCtr extends ControllerModule {
private codeVerifier: string | null = null;
private authRequestState: string | null = null;
beforeAppReady = () => {
this.registerProtocolHandler();
};
/**
* 轮询相关参数
*/
// eslint-disable-next-line no-undef
private pollingInterval: NodeJS.Timeout | null = null;
private cachedRemoteUrl: string | null = null;
/**
* 自动刷新定时器
*/
// eslint-disable-next-line no-undef
private autoRefreshTimer: NodeJS.Timeout | null = null;
/**
* 构造 redirect_uri,确保授权和令牌交换时使用相同的 URI
* @param remoteUrl 远程服务器 URL
* @param includeHandoffId 是否包含 handoff ID(仅在授权时需要)
*/
private constructRedirectUri(remoteUrl: string): string {
const callbackUrl = new URL('/oidc/callback/desktop', remoteUrl);
return callbackUrl.toString();
}
/**
* Request OAuth authorization
@@ -43,6 +61,9 @@ export default class AuthCtr extends ControllerModule {
async requestAuthorization(config: DataSyncConfig) {
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
// 缓存远程服务器 URL 用于后续轮询
this.cachedRemoteUrl = remoteUrl;
logger.info(
`Requesting OAuth authorization, storageMode:${config.storageMode} server URL: ${remoteUrl}`,
);
@@ -57,8 +78,11 @@ export default class AuthCtr extends ControllerModule {
this.authRequestState = crypto.randomBytes(16).toString('hex');
logger.debug(`Generated state parameter: ${this.authRequestState}`);
// Construct authorization URL
// Construct authorization URL with new redirect_uri
const authUrl = new URL('/oidc/auth', remoteUrl);
const redirectUri = this.constructRedirectUri(remoteUrl);
logger.info('redirectUri', redirectUri);
// Add query parameters
authUrl.search = querystring.stringify({
@@ -66,7 +90,9 @@ export default class AuthCtr extends ControllerModule {
code_challenge: codeChallenge,
code_challenge_method: 'S256',
prompt: 'consent',
redirect_uri: `${protocolPrefix}://auth/callback`,
redirect_uri: redirectUri,
// https://github.com/lobehub/lobe-chat/pull/8450
resource: 'urn:lobehub:chat',
response_type: 'code',
scope: 'profile email offline_access',
state: this.authRequestState,
@@ -78,6 +104,9 @@ export default class AuthCtr extends ControllerModule {
await shell.openExternal(authUrl.toString());
logger.debug('Opening authorization URL in default browser');
// Start polling for credentials
this.startPolling();
return { success: true };
} catch (error) {
logger.error('Authorization request failed:', error);
@@ -86,85 +115,188 @@ export default class AuthCtr extends ControllerModule {
}
/**
* Handle authorization callback
* This method is called when the browser redirects to our custom protocol
* 启动轮询机制获取凭证
*/
async handleAuthCallback(callbackUrl: string) {
logger.info(`Handling authorization callback: ${callbackUrl}`);
private startPolling() {
if (!this.authRequestState) {
logger.error('No handoff ID available for polling');
return;
}
logger.info('Starting credential polling');
const pollInterval = 3000; // 3 seconds
const maxPollTime = 5 * 60 * 1000; // 5 minutes
const startTime = Date.now();
this.pollingInterval = setInterval(async () => {
try {
// Check if polling has timed out
if (Date.now() - startTime > maxPollTime) {
logger.warn('Credential polling timed out');
this.stopPolling();
this.broadcastAuthorizationFailed('Authorization timed out');
return;
}
// Poll for credentials
const result = await this.pollForCredentials();
if (result) {
logger.info('Successfully received credentials from polling');
this.stopPolling();
// Validate state parameter
if (result.state !== this.authRequestState) {
logger.error(
`Invalid state parameter: expected ${this.authRequestState}, received ${result.state}`,
);
this.broadcastAuthorizationFailed('Invalid state parameter');
return;
}
// Exchange code for tokens
const exchangeResult = await this.exchangeCodeForToken(result.code, this.codeVerifier!);
if (exchangeResult.success) {
logger.info('Authorization successful');
this.broadcastAuthorizationSuccessful();
} else {
logger.warn(`Authorization failed: ${exchangeResult.error || 'Unknown error'}`);
this.broadcastAuthorizationFailed(exchangeResult.error || 'Unknown error');
}
}
} catch (error) {
logger.error('Error during credential polling:', error);
this.stopPolling();
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
}
}, pollInterval);
}
/**
* 停止轮询
*/
private stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* 启动自动刷新定时器
*/
private startAutoRefresh() {
// 先停止现有的定时器
this.stopAutoRefresh();
const checkInterval = 2 * 60 * 1000; // 每 2 分钟检查一次
logger.debug('Starting auto-refresh timer');
this.autoRefreshTimer = setInterval(async () => {
try {
// 检查 token 是否即将过期 (提前 5 分钟刷新)
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
logger.info(
`Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`,
);
const result = await this.remoteServerConfigCtr.refreshAccessToken();
if (result.success) {
logger.info('Auto-refresh successful');
this.broadcastTokenRefreshed();
} else {
logger.error(`Auto-refresh failed: ${result.error}`);
// 如果自动刷新失败,停止定时器并清除 token
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired();
}
}
} catch (error) {
logger.error('Error during auto-refresh check:', error);
}
}, checkInterval);
}
/**
* 停止自动刷新定时器
*/
private stopAutoRefresh() {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
logger.debug('Stopped auto-refresh timer');
}
}
/**
* 轮询获取凭证
* 直接发送 HTTP 请求到远程服务器
*/
private async pollForCredentials(): Promise<{ code: string; state: string } | null> {
if (!this.authRequestState || !this.cachedRemoteUrl) {
return null;
}
try {
const url = new URL(callbackUrl);
const params = new URLSearchParams(url.search);
// 使用缓存的远程服务器 URL
const remoteUrl = this.cachedRemoteUrl;
// Get authorization code
const code = params.get('code');
const state = params.get('state');
logger.debug(`Got parameters from callback URL: code=${code}, state=${state}`);
// 构造请求 URL
const url = new URL('/oidc/handoff', remoteUrl);
url.searchParams.set('id', this.authRequestState);
url.searchParams.set('client', 'desktop');
// Validate state parameter to prevent CSRF attacks
if (state !== this.authRequestState) {
logger.error(
`Invalid state parameter: expected ${this.authRequestState}, received ${state}`,
);
throw new Error('Invalid state parameter');
}
logger.debug('State parameter validation passed');
logger.debug(`Polling for credentials: ${url.toString()}`);
if (!code) {
logger.error('No authorization code received');
throw new Error('No authorization code received');
// 直接发送 HTTP 请求
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
});
// 检查响应状态
if (response.status === 404) {
// 凭证还未准备好,这是正常情况
return null;
}
// Get configuration information
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
logger.debug(`Getting remote server configuration: url=${config.remoteServerUrl}`);
if (config.storageMode === 'selfHost' && !config.remoteServerUrl) {
logger.error('Server URL not configured');
throw new Error('No server URL configured');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Get the previously saved code_verifier
const codeVerifier = this.codeVerifier;
if (!codeVerifier) {
logger.error('Code verifier not found');
throw new Error('No code verifier found');
}
logger.debug('Found code verifier');
// 解析响应数据
const data = (await response.json()) as {
data: {
id: string;
payload: { code: string; state: string };
};
success: boolean;
};
// Exchange authorization code for token
logger.debug('Starting to exchange authorization code for token');
const result = await this.exchangeCodeForToken(code, codeVerifier);
if (result.success) {
logger.info('Authorization successful');
// Notify render process of successful authorization
this.broadcastAuthorizationSuccessful();
} else {
logger.warn(`Authorization failed: ${result.error || 'Unknown error'}`);
// Notify render process of failed authorization
this.broadcastAuthorizationFailed(result.error || 'Unknown error');
if (data.success && data.data?.payload) {
logger.debug('Successfully retrieved credentials from handoff');
return {
code: data.data.payload.code,
state: data.data.payload.state,
};
}
return result;
return null;
} catch (error) {
logger.error('Handling authorization callback failed:', error);
// Notify render process of failed authorization
this.broadcastAuthorizationFailed(error.message);
return { error: error.message, success: false };
} finally {
// Clear authorization request state
logger.debug('Clearing authorization request state');
this.authRequestState = null;
this.codeVerifier = null;
logger.debug('Polling attempt failed (this is normal):', error.message);
return null;
}
}
/**
* Refresh access token
*/
@ipcClientEvent('refreshAccessToken')
async refreshAccessToken() {
logger.info('Starting to refresh access token');
try {
@@ -175,6 +307,8 @@ export default class AuthCtr extends ControllerModule {
logger.info('Token refresh successful via AuthCtr call.');
// Notify render process that token has been refreshed
this.broadcastTokenRefreshed();
// Restart auto-refresh timer with new expiration time
this.startAutoRefresh();
return { success: true };
} else {
// Throw an error to be caught by the catch block below
@@ -188,6 +322,7 @@ export default class AuthCtr extends ControllerModule {
// Refresh failed, clear tokens and disable remote server
logger.warn('Refresh failed, clearing tokens and disabling remote server');
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
@@ -198,48 +333,15 @@ export default class AuthCtr extends ControllerModule {
}
}
/**
* Register custom protocol handler
*/
private registerProtocolHandler() {
logger.info(`Registering custom protocol handler ${protocolPrefix}://`);
app.setAsDefaultProtocolClient(protocolPrefix);
// Register custom protocol handler
if (process.platform === 'darwin') {
// Handle open-url event on macOS
logger.debug('Registering open-url event handler for macOS');
app.on('open-url', (event, url) => {
event.preventDefault();
logger.info(`Received open-url event: ${url}`);
this.handleAuthCallback(url);
});
} else {
// Handle protocol callback via second-instance event on Windows and Linux
logger.debug('Registering second-instance event handler for Windows/Linux');
app.on('second-instance', async (event, commandLine) => {
// Find the URL from command line arguments
const url = commandLine.find((arg) => arg.startsWith(`${protocolPrefix}://`));
if (url) {
logger.info(`Found URL from second-instance command line arguments: ${url}`);
const { success } = await this.handleAuthCallback(url);
if (success) {
this.app.browserManager.getMainWindow().show();
}
} else {
logger.warn('Protocol URL not found in second-instance command line arguments');
}
});
}
logger.info(`Registered ${protocolPrefix}:// custom protocol handler`);
}
/**
* Exchange authorization code for token
*/
private async exchangeCodeForToken(code: string, codeVerifier: string) {
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
if (!this.cachedRemoteUrl) {
throw new Error('No cached remote URL available for token exchange');
}
const remoteUrl = this.cachedRemoteUrl;
logger.info('Starting to exchange authorization code for token');
try {
const tokenUrl = new URL('/oidc/token', remoteUrl);
@@ -251,7 +353,7 @@ export default class AuthCtr extends ControllerModule {
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: `${protocolPrefix}://auth/callback`,
redirect_uri: this.constructRedirectUri(remoteUrl),
});
logger.debug('Sending token exchange request');
@@ -272,10 +374,20 @@ export default class AuthCtr extends ControllerModule {
throw new Error(errorMessage);
}
let data;
// Parse response
const data = await response.json();
try {
data = await response.clone().json();
} catch {
const status = response.status;
throw new Error(
`Parse JSON failed, please check your server, response status: ${status}, detail:\n\n ${await response.text()} `,
);
}
logger.debug('Successfully received token exchange response');
// console.log(data); // Keep original log for debugging, or remove/change to logger.debug as needed
// Ensure response contains necessary fields
if (!data.access_token || !data.refresh_token) {
@@ -285,13 +397,20 @@ export default class AuthCtr extends ControllerModule {
// Save tokens
logger.debug('Starting to save exchanged tokens');
await this.remoteServerConfigCtr.saveTokens(data.access_token, data.refresh_token);
await this.remoteServerConfigCtr.saveTokens(
data.access_token,
data.refresh_token,
data.expires_in,
);
logger.info('Successfully saved exchanged tokens');
// Set server to active state
logger.debug(`Setting remote server to active state: ${remoteUrl}`);
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: true });
// Start auto-refresh timer
this.startAutoRefresh();
return { success: true };
} catch (error) {
logger.error('Exchanging authorization code failed:', error);
@@ -390,4 +509,84 @@ export default class AuthCtr extends ControllerModule {
logger.debug('Generated code challenge (partial): ' + challenge.slice(0, 10) + '...'); // Avoid logging full sensitive info
return challenge;
}
/**
* 应用启动后初始化
*/
afterAppReady() {
logger.debug('AuthCtr initialized, checking for existing tokens');
this.initializeAutoRefresh();
}
/**
* 清理所有定时器
*/
cleanup() {
logger.debug('Cleaning up AuthCtr timers');
this.stopPolling();
this.stopAutoRefresh();
}
/**
* 初始化自动刷新功能
* 在应用启动时检查是否有有效的 token,如果有就启动自动刷新定时器
*/
private async initializeAutoRefresh() {
try {
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
// 检查是否配置了远程服务器且处于活动状态
if (!config.active || !config.remoteServerUrl) {
logger.debug(
'Remote server not active or configured, skipping auto-refresh initialization',
);
return;
}
// 检查是否有有效的访问令牌
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
if (!accessToken) {
logger.debug('No access token found, skipping auto-refresh initialization');
return;
}
// 检查是否有过期时间信息
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
if (!expiresAt) {
logger.debug('No token expiration time found, skipping auto-refresh initialization');
return;
}
// 检查 token 是否已经过期
const currentTime = Date.now();
if (currentTime >= expiresAt) {
logger.info('Token has expired, attempting to refresh it');
// 尝试刷新 token
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
if (refreshResult.success) {
logger.info('Token refresh successful during initialization');
this.broadcastTokenRefreshed();
// 重新启动自动刷新定时器
this.startAutoRefresh();
return;
} else {
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
// 只有在刷新失败时才清除 token 并要求重新授权
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired();
return;
}
}
// 启动自动刷新定时器
logger.info(
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
);
this.startAutoRefresh();
} catch (error) {
logger.error('Error during auto-refresh initialization:', error);
}
}
}
@@ -7,7 +7,7 @@ import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { ControllerModule, ipcClientEvent, shortcut } from './index';
export default class BrowserWindowsCtr extends ControllerModule {
@shortcut('toggleMainWindow')
@shortcut('showApp')
async toggleMainWindow() {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.toggleVisible();
@@ -0,0 +1,172 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { merge } from 'lodash';
import { isEqual } from 'lodash-es';
import { defaultProxySettings } from '@/const/store';
import { createLogger } from '@/utils/logger';
import {
ProxyConfigValidator,
ProxyConnectionTester,
ProxyDispatcherManager,
ProxyTestResult,
} from '../modules/networkProxy';
import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:NetworkProxyCtr');
/**
* 网络代理控制器
* 处理桌面应用的网络代理相关功能
*/
export default class NetworkProxyCtr extends ControllerModule {
/**
* 获取代理设置
*/
@ipcClientEvent('getProxySettings')
async getDesktopSettings(): Promise<NetworkProxySettings> {
try {
const settings = this.app.storeManager.get(
'networkProxy',
defaultProxySettings,
) as NetworkProxySettings;
logger.debug('Retrieved proxy settings:', {
enableProxy: settings.enableProxy,
proxyType: settings.proxyType,
});
return settings;
} catch (error) {
logger.error('Failed to get proxy settings:', error);
return defaultProxySettings;
}
}
/**
* 设置代理配置
*/
@ipcClientEvent('setProxySettings')
async setProxySettings(config: NetworkProxySettings): Promise<void> {
try {
// 验证配置
const validation = ProxyConfigValidator.validate(config);
if (!validation.isValid) {
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
// 获取当前配置
const currentConfig = this.app.storeManager.get(
'networkProxy',
defaultProxySettings,
) as NetworkProxySettings;
// 检查是否有变化
if (isEqual(currentConfig, config)) {
logger.debug('Proxy settings unchanged, skipping update');
return;
}
// 合并配置
const newConfig = merge({}, currentConfig, config);
// 应用代理设置
await ProxyDispatcherManager.applyProxySettings(newConfig);
// 保存到存储
this.app.storeManager.set('networkProxy', newConfig);
logger.info('Proxy settings updated successfully', {
enableProxy: newConfig.enableProxy,
proxyPort: newConfig.proxyPort,
proxyServer: newConfig.proxyServer,
proxyType: newConfig.proxyType,
});
} catch (error) {
logger.error('Failed to update proxy settings:', error);
throw error;
}
}
/**
* 测试代理连接
*/
@ipcClientEvent('testProxyConnection')
async testProxyConnection(url: string): Promise<{ message?: string; success: boolean }> {
try {
const result = await ProxyConnectionTester.testConnection(url);
if (result.success) {
return { success: true };
} else {
throw new Error(result.message || 'Connection test failed');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Proxy connection test failed:', errorMessage);
throw new Error(`Connection failed: ${errorMessage}`);
}
}
/**
* 测试指定代理配置
*/
@ipcClientEvent('testProxyConfig')
async testProxyConfig({
config,
testUrl,
}: {
config: NetworkProxySettings;
testUrl?: string;
}): Promise<ProxyTestResult> {
try {
return await ProxyConnectionTester.testProxyConfig(config, testUrl);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Proxy config test failed:', errorMessage);
return {
message: `Proxy config test failed: ${errorMessage}`,
success: false,
};
}
}
/**
* 应用初始代理设置
*/
async beforeAppReady(): Promise<void> {
try {
// 获取存储的代理设置
const networkProxy = this.app.storeManager.get(
'networkProxy',
defaultProxySettings,
) as NetworkProxySettings;
// 验证配置
const validation = ProxyConfigValidator.validate(networkProxy);
if (!validation.isValid) {
logger.warn('Invalid stored proxy configuration, using defaults:', validation.errors);
await ProxyDispatcherManager.applyProxySettings(defaultProxySettings);
return;
}
// 应用代理设置
await ProxyDispatcherManager.applyProxySettings(networkProxy);
logger.info('Initial proxy settings applied successfully', {
enableProxy: networkProxy.enableProxy,
proxyType: networkProxy.proxyType,
});
} catch (error) {
logger.error('Failed to apply initial proxy settings:', error);
// 出错时使用默认设置
try {
await ProxyDispatcherManager.applyProxySettings(defaultProxySettings);
logger.info('Fallback to default proxy settings');
} catch (fallbackError) {
logger.error('Failed to apply fallback proxy settings:', fallbackError);
}
}
}
}
@@ -0,0 +1,154 @@
import {
DesktopNotificationResult,
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { Notification, app } from 'electron';
import { macOS, windows } from 'electron-is';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
const logger = createLogger('controllers:NotificationCtr');
export default class NotificationCtr extends ControllerModule {
/**
* 在应用准备就绪后设置桌面通知
*/
afterAppReady() {
this.setupNotifications();
}
/**
* 设置桌面通知权限和配置
*/
private setupNotifications() {
logger.debug('Setting up desktop notifications');
try {
// 检查通知支持
if (!Notification.isSupported()) {
logger.warn('Desktop notifications are not supported on this platform');
return;
}
// 在 macOS 上,我们可能需要显式请求通知权限
if (macOS()) {
logger.debug('macOS detected, notification permissions should be handled by system');
}
// 在 Windows 上设置应用用户模型 ID
if (windows()) {
app.setAppUserModelId('com.lobehub.chat');
logger.debug('Set Windows App User Model ID for notifications');
}
logger.info('Desktop notifications setup completed');
} catch (error) {
logger.error('Failed to setup desktop notifications:', error);
}
}
/**
* 显示系统桌面通知(仅当窗口隐藏时)
*/
@ipcClientEvent('showDesktopNotification')
async showDesktopNotification(
params: ShowDesktopNotificationParams,
): Promise<DesktopNotificationResult> {
logger.debug('收到桌面通知请求:', params);
try {
// 检查通知支持
if (!Notification.isSupported()) {
logger.warn('系统不支持桌面通知');
return { error: 'Desktop notifications not supported', success: false };
}
// 检查窗口是否隐藏
const isWindowHidden = this.isMainWindowHidden();
if (!isWindowHidden) {
logger.debug('主窗口可见,跳过桌面通知');
return { reason: 'Window is visible', skipped: true, success: true };
}
logger.info('窗口已隐藏,显示桌面通知:', params.title);
const notification = new Notification({
body: params.body,
// 添加更多配置以确保通知能正常显示
hasReply: false,
silent: params.silent || false,
timeoutType: 'default',
title: params.title,
urgency: 'normal',
});
// 添加更多事件监听来调试
notification.on('show', () => {
logger.info('通知已显示');
});
notification.on('click', () => {
logger.debug('用户点击通知,显示主窗口');
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.browserWindow.focus();
});
notification.on('close', () => {
logger.debug('通知已关闭');
});
notification.on('failed', (error) => {
logger.error('通知显示失败:', error);
});
// 使用 Promise 来确保通知显示
return new Promise((resolve) => {
notification.show();
// 给通知一些时间来显示,然后检查结果
setTimeout(() => {
logger.info('通知显示调用完成');
resolve({ success: true });
}, 100);
});
} catch (error) {
logger.error('显示桌面通知失败:', error);
return {
error: error instanceof Error ? error.message : 'Unknown error',
success: false,
};
}
}
/**
* 检查主窗口是否隐藏
*/
@ipcClientEvent('isMainWindowHidden')
isMainWindowHidden(): boolean {
try {
const mainWindow = this.app.browserManager.getMainWindow();
const browserWindow = mainWindow.browserWindow;
// 如果窗口被销毁,认为是隐藏的
if (browserWindow.isDestroyed()) {
return true;
}
// 检查窗口是否可见和聚焦
const isVisible = browserWindow.isVisible();
const isFocused = browserWindow.isFocused();
const isMinimized = browserWindow.isMinimized();
logger.debug('窗口状态检查:', { isFocused, isMinimized, isVisible });
// 窗口隐藏的条件:不可见或最小化或失去焦点
return !isVisible || isMinimized || !isFocused;
} catch (error) {
logger.error('检查窗口状态失败:', error);
return true; // 发生错误时认为窗口隐藏,确保通知能显示
}
}
}
@@ -79,6 +79,12 @@ export default class RemoteServerConfigCtr extends ControllerModule {
private encryptedAccessToken?: string;
private encryptedRefreshToken?: string;
/**
* Token expiration time (timestamp in milliseconds)
* Used for automatic token refresh
*/
private tokenExpiresAt?: number;
/**
* Promise representing the ongoing token refresh operation.
* Used to prevent concurrent refreshes and allow callers to wait.
@@ -89,10 +95,19 @@ export default class RemoteServerConfigCtr extends ControllerModule {
* Encrypt and store tokens
* @param accessToken Access token
* @param refreshToken Refresh token
* @param expiresIn Token expiration time in seconds (optional)
*/
async saveTokens(accessToken: string, refreshToken: string) {
async saveTokens(accessToken: string, refreshToken: string, expiresIn?: number) {
logger.info('Saving encrypted tokens');
// Calculate expiration time if provided
if (expiresIn) {
this.tokenExpiresAt = Date.now() + expiresIn * 1000;
logger.debug(`Token expires at: ${new Date(this.tokenExpiresAt).toISOString()}`);
} else {
this.tokenExpiresAt = undefined;
}
// If platform doesn't support secure storage, store raw tokens
if (!safeStorage.isEncryptionAvailable()) {
logger.warn('Safe storage not available, storing tokens unencrypted');
@@ -101,6 +116,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
// Persist unencrypted tokens (consider security implications)
this.app.storeManager.set(this.encryptedTokensKey, {
accessToken: this.encryptedAccessToken,
expiresAt: this.tokenExpiresAt,
refreshToken: this.encryptedRefreshToken,
});
return;
@@ -120,6 +136,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
logger.debug(`Persisting encrypted tokens to store key: ${this.encryptedTokensKey}`);
this.app.storeManager.set(this.encryptedTokensKey, {
accessToken: this.encryptedAccessToken,
expiresAt: this.tokenExpiresAt,
refreshToken: this.encryptedRefreshToken,
});
}
@@ -199,17 +216,40 @@ export default class RemoteServerConfigCtr extends ControllerModule {
logger.info('Clearing access and refresh tokens');
this.encryptedAccessToken = undefined;
this.encryptedRefreshToken = undefined;
this.tokenExpiresAt = undefined;
// Also clear from persistent storage
logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`);
this.app.storeManager.delete(this.encryptedTokensKey);
}
/**
* Get token expiration time
*/
getTokenExpiresAt(): number | undefined {
return this.tokenExpiresAt;
}
/**
* Check if token is expired or will expire soon
* @param bufferTimeMs Buffer time in milliseconds (default 5 minutes)
* @returns true if token is expired or will expire soon
*/
isTokenExpiringSoon(bufferTimeMs: number = 5 * 60 * 1000): boolean {
if (!this.tokenExpiresAt) {
return false; // No expiration time available
}
const currentTime = Date.now();
const bufferTime = this.tokenExpiresAt - bufferTimeMs;
return currentTime >= bufferTime;
}
/**
* 刷新访问令牌
* 使用存储的刷新令牌获取新的访问令牌
* Handles concurrent requests by returning the existing refresh promise if one is in progress.
*/
@ipcClientEvent('refreshAccessToken')
async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
// If a refresh is already in progress, return the existing promise
if (this.refreshPromise) {
@@ -290,7 +330,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
// 保存新令牌
logger.info('Token refresh successful, saving new tokens.');
await this.saveTokens(data.access_token, data.refresh_token);
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
return { success: true };
} catch (error) {
@@ -316,6 +356,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
logger.info('Successfully loaded tokens from store into memory.');
this.encryptedAccessToken = storedTokens.accessToken;
this.encryptedRefreshToken = storedTokens.refreshToken;
this.tokenExpiresAt = storedTokens.expiresAt;
if (this.tokenExpiresAt) {
logger.debug(
`Loaded token expiration time: ${new Date(this.tokenExpiresAt).toISOString()}`,
);
}
} else {
logger.debug('No valid tokens found in store.');
}
@@ -1,12 +1,17 @@
import {
ProxyTRPCRequestParams,
ProxyTRPCRequestResult,
} from '@lobechat/electron-client-ipc/src/types/proxyTRPCRequest';
ProxyTRPCStreamRequestParams,
} from '@lobechat/electron-client-ipc';
import { IpcMainEvent, WebContents, ipcMain } from 'electron';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { Buffer } from 'node:buffer';
import http, { IncomingMessage, OutgoingHttpHeaders } from 'node:http';
import https from 'node:https';
import { URL } from 'node:url';
import { defaultProxySettings } from '@/const/store';
import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -41,6 +46,137 @@ export default class RemoteServerSyncCtr extends ControllerModule {
afterAppReady() {
logger.info('RemoteServerSyncCtr initialized (IPC based)');
// No need to register protocol handler anymore
ipcMain.on('stream:start', this.handleStreamRequest);
}
/**
* 处理流式请求的 IPC 调用
*/
private handleStreamRequest = async (event: IpcMainEvent, args: ProxyTRPCStreamRequestParams) => {
const { requestId } = args;
const logPrefix = `[StreamProxy ${args.method} ${args.urlPath}][${requestId}]`;
logger.debug(`${logPrefix} Received stream:start IPC call`);
try {
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
logger.warn(`${logPrefix} Remote server sync not active or configured.`);
event.sender.send(
`stream:error:${requestId}`,
new Error('Remote server sync not active or configured'),
);
return;
}
const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
const token = await this.remoteServerConfigCtr.getAccessToken();
if (!token) {
// 401 Unauthorized
event.sender.send(`stream:response:${requestId}`, {
headers: {},
status: 401,
statusText: 'Authentication required, missing token',
});
event.sender.send(`stream:end:${requestId}`);
return;
}
// 调用新的流式转发方法
await this.forwardStreamRequest(event.sender, {
...args,
accessToken: token,
remoteServerUrl,
});
} catch (error) {
logger.error(`${logPrefix} Unhandled error processing stream request:`, error);
event.sender.send(
`stream:error:${requestId}`,
error instanceof Error ? error : new Error('Unknown error'),
);
}
};
/**
* 执行实际的流式请求转发
*/
private async forwardStreamRequest(
sender: WebContents,
args: ProxyTRPCStreamRequestParams & { accessToken: string; remoteServerUrl: string },
) {
const {
urlPath,
method,
headers: originalHeaders,
body: requestBody,
accessToken,
remoteServerUrl,
requestId,
} = args;
const targetUrl = new URL(urlPath, remoteServerUrl);
const logPrefix = `[ForwardStream ${method} ${targetUrl.pathname}][${requestId}]`;
const { requestOptions, requester } = this.createRequester({
accessToken,
headers: originalHeaders,
method,
url: targetUrl,
});
const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
logger.debug(`${logPrefix} Received response with status ${clientRes.statusCode}`);
// 添加调试信息
logger.debug(`${logPrefix} Response details:`, {
headers: clientRes.headers,
statusCode: clientRes.statusCode,
statusMessage: clientRes.statusMessage,
});
// 1. 立刻发送响应头和状态码
const responseData = {
headers: clientRes.headers || {},
status: clientRes.statusCode || 500,
statusText: clientRes.statusMessage || 'Unknown Status',
};
logger.debug(`${logPrefix} Sending response data:`, responseData);
sender.send(`stream:response:${requestId}`, responseData);
// 2. 监听数据块并转发
clientRes.on('data', (chunk: Buffer) => {
if (sender.isDestroyed()) return;
logger.debug(`${logPrefix} Received data chunk, size: ${chunk.length}. Forwarding...`);
sender.send(`stream:data:${requestId}`, chunk);
});
// 3. 监听结束信号并转发
clientRes.on('end', () => {
logger.debug(`${logPrefix} Stream ended. Forwarding end signal...`);
if (sender.isDestroyed()) return;
sender.send(`stream:end:${requestId}`);
});
// 4. 监听响应流错误并转发
clientRes.on('error', (error) => {
logger.error(`${logPrefix} Error reading response stream:`, error);
if (sender.isDestroyed()) return;
sender.send(`stream:error:${requestId}`, error);
});
});
// 5. 监听请求本身的错误(如 DNS 解析失败)
clientReq.on('error', (error) => {
logger.error(`${logPrefix} Error forwarding request:`, error);
if (sender.isDestroyed()) return;
sender.send(`stream:error:${requestId}`, error);
});
if (requestBody) {
clientReq.write(Buffer.from(requestBody));
}
clientReq.end();
}
/**
@@ -85,28 +221,12 @@ export default class RemoteServerSyncCtr extends ControllerModule {
// 1. Determine target URL and prepare request options
const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
// Prepare headers, cloning and adding Authorization
const requestHeaders: OutgoingHttpHeaders = { ...originalHeaders }; // Use OutgoingHttpHeaders
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
// Let node handle Host, Content-Length etc. Remove potentially problematic headers
delete requestHeaders['host'];
delete requestHeaders['connection']; // Often causes issues
// delete requestHeaders['content-length']; // Let node handle it based on body
const requestOptions: https.RequestOptions | http.RequestOptions = {
// Use union type
headers: requestHeaders,
hostname: targetUrl.hostname,
method: method,
path: targetUrl.pathname + targetUrl.search,
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
protocol: targetUrl.protocol,
// agent: false, // Consider for keep-alive issues if they arise
};
const requester = targetUrl.protocol === 'https:' ? https : http;
const { requestOptions, requester } = this.createRequester({
accessToken,
headers: originalHeaders,
method,
url: targetUrl,
});
// 2. Make the request and capture response
return new Promise((resolve) => {
@@ -176,6 +296,51 @@ export default class RemoteServerSyncCtr extends ControllerModule {
});
}
private createRequester({
headers,
accessToken,
method,
url,
}: {
accessToken: string;
headers: Record<string, string>;
method: string;
url: URL;
}) {
// Prepare headers, cloning and adding Oidc-Auth
const requestHeaders: OutgoingHttpHeaders = { ...headers }; // Use OutgoingHttpHeaders
requestHeaders['Oidc-Auth'] = accessToken;
// Let node handle Host, Content-Length etc. Remove potentially problematic headers
delete requestHeaders['host'];
delete requestHeaders['connection']; // Often causes issues
// delete requestHeaders['content-length']; // Let node handle it based on body
// 读取代理配置
const proxyConfig = this.app.storeManager.get('networkProxy', defaultProxySettings);
let agent;
if (proxyConfig?.enableProxy && proxyConfig.proxyServer) {
const proxyUrl = `${proxyConfig.proxyType}://${proxyConfig.proxyServer}${proxyConfig.proxyPort ? `:${proxyConfig.proxyPort}` : ''}`;
agent =
url.protocol === 'https:' ? new HttpsProxyAgent(proxyUrl) : new HttpProxyAgent(proxyUrl);
}
const requestOptions: https.RequestOptions | http.RequestOptions = {
agent,
// Use union type
headers: requestHeaders,
hostname: url.hostname,
method: method,
path: url.pathname + url.search,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
protocol: url.protocol,
};
const requester = url.protocol === 'https:' ? https : http;
return { requestOptions, requester };
}
/**
* Handles the 'proxy-trpc-request' IPC call from the renderer process.
* This method should be invoked by the ipcMain.handle setup in your main process entry point.
@@ -1,3 +1,5 @@
import { ShortcutUpdateResult } from '@/core/ui/ShortcutManager';
import { ControllerModule, ipcClientEvent } from '.';
export default class ShortcutController extends ControllerModule {
@@ -13,7 +15,13 @@ export default class ShortcutController extends ControllerModule {
* 更新单个快捷键配置
*/
@ipcClientEvent('updateShortcutConfig')
updateShortcutConfig(id: string, accelerator: string): boolean {
updateShortcutConfig({
id,
accelerator,
}: {
accelerator: string;
id: string;
}): ShortcutUpdateResult {
return this.app.shortcutManager.updateShortcutConfig(id, accelerator);
}
}
@@ -83,7 +83,11 @@ export default class SystemController extends ControllerModule {
@ipcClientEvent('updateThemeMode')
async updateThemeModeHandler(themeMode: ThemeMode) {
this.app.storeManager.set('themeMode', themeMode);
this.app.browserManager.broadcastToAllWindows('themeChanged', { themeMode });
// Apply visual effects to all browser windows when theme mode changes
this.app.browserManager.handleAppThemeChange();
}
@ipcServerEvent('getDatabasePath')
@@ -6,16 +6,12 @@ import {
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent, shortcut } from './index';
import { ControllerModule, ipcClientEvent } from './index';
// 创建日志记录器
const logger = createLogger('controllers:TrayMenuCtr');
export default class TrayMenuCtr extends ControllerModule {
/**
* 使用快捷键切换窗口可见性
*/
@shortcut('toggleMainWindow')
async toggleMainWindow() {
logger.debug('通过快捷键切换主窗口可见性');
const mainWindow = this.app.browserManager.getMainWindow();
@@ -47,7 +43,7 @@ export default class TrayMenuCtr extends ControllerModule {
return {
error: '托盘通知仅在 Windows 平台支持',
success: false
success: false,
};
}
@@ -71,7 +67,7 @@ export default class TrayMenuCtr extends ControllerModule {
logger.error('更新托盘图标失败:', error);
return {
error: String(error),
success: false
success: false,
};
}
}
@@ -79,7 +75,7 @@ export default class TrayMenuCtr extends ControllerModule {
return {
error: '托盘功能仅在 Windows 平台支持',
success: false
success: false,
};
}
@@ -103,7 +99,7 @@ export default class TrayMenuCtr extends ControllerModule {
return {
error: '托盘功能仅在 Windows 平台支持',
success: false
success: false,
};
}
}
@@ -1,15 +1,10 @@
import { UploadFileParams } from '@lobechat/electron-client-ipc';
import { CreateFileParams } from '@lobechat/electron-server-ipc';
import FileService from '@/services/fileSrv';
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
interface UploadFileParams {
content: ArrayBuffer;
filename: string;
hash: string;
path: string;
type: string;
}
export default class UploadFileCtr extends ControllerModule {
private get fileService() {
return this.app.getService(FileService);
@@ -27,8 +22,18 @@ export default class UploadFileCtr extends ControllerModule {
return this.fileService.getFilePath(id);
}
@ipcServerEvent('getFileHTTPURL')
async getFileHTTPURL(path: string) {
return this.fileService.getFileHTTPURL(path);
}
@ipcServerEvent('deleteFiles')
async deleteFiles(paths: string[]) {
return this.fileService.deleteFiles(paths);
}
@ipcServerEvent('createFile')
async createFile(params: CreateFileParams) {
return this.fileService.uploadFile(params);
}
}
@@ -0,0 +1,420 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import NetworkProxyCtr from '../NetworkProxyCtr';
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// 模拟 undici - 使用 vi.fn() 直接在 Mock 中创建
vi.mock('undici', () => ({
fetch: vi.fn(),
getGlobalDispatcher: vi.fn(),
setGlobalDispatcher: vi.fn(),
Agent: vi.fn(),
ProxyAgent: vi.fn(),
}));
// 模拟 defaultProxySettings
vi.mock('@/const/store', () => ({
defaultProxySettings: {
enableProxy: false,
proxyBypass: 'localhost,127.0.0.1,::1',
proxyPort: '',
proxyRequireAuth: false,
proxyServer: '',
proxyType: 'http',
},
}));
// 模拟 App 及其依赖项
const mockStoreManager = {
get: vi.fn(),
set: vi.fn(),
};
const mockApp = {
storeManager: mockStoreManager,
} as unknown as App;
describe('NetworkProxyCtr', () => {
let networkProxyCtr: NetworkProxyCtr;
// 动态导入 undici 的 Mock
let mockUndici: any;
beforeEach(async () => {
vi.clearAllMocks();
// 动态导入 undici Mock
mockUndici = await import('undici');
networkProxyCtr = new NetworkProxyCtr(mockApp);
// 设置 undici mocks 的默认返回值
vi.mocked(mockUndici.Agent).mockReturnValue({});
vi.mocked(mockUndici.ProxyAgent).mockReturnValue({});
vi.mocked(mockUndici.getGlobalDispatcher).mockReturnValue({
destroy: vi.fn().mockResolvedValue(undefined),
});
vi.mocked(mockUndici.setGlobalDispatcher).mockReturnValue(undefined);
// 设置 fetch mock 的默认返回值
vi.mocked(mockUndici.fetch).mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
});
});
describe('ProxyConfigValidator', () => {
const validConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should validate enabled proxy config with all required fields', () => {
// 通过测试公共方法来间接测试验证逻辑
expect(() => networkProxyCtr.setProxySettings(validConfig)).not.toThrow();
});
it('should validate disabled proxy config', () => {
const disabledConfig: NetworkProxySettings = {
...validConfig,
enableProxy: false,
};
expect(() => networkProxyCtr.setProxySettings(disabledConfig)).not.toThrow();
});
it('should reject invalid proxy type', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyType: 'invalid' as any,
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
it('should reject missing proxy server', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyServer: '',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
it('should reject invalid proxy port', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyPort: 'invalid',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
it('should reject missing auth credentials when auth is required', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyRequireAuth: true,
proxyUsername: '',
proxyPassword: '',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
});
describe('getDesktopSettings', () => {
it('should return stored proxy settings', async () => {
const expectedSettings: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
mockStoreManager.get.mockReturnValue(expectedSettings);
const result = await networkProxyCtr.getDesktopSettings();
expect(result).toEqual(expectedSettings);
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
});
it('should return default settings when store fails', async () => {
mockStoreManager.get.mockImplementation(() => {
throw new Error('Store error');
});
const result = await networkProxyCtr.getDesktopSettings();
expect(result).toEqual({
enableProxy: false,
proxyBypass: 'localhost,127.0.0.1,::1',
proxyPort: '',
proxyRequireAuth: false,
proxyServer: '',
proxyType: 'http',
});
});
});
describe('setProxySettings', () => {
const validConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should save valid proxy settings', async () => {
mockStoreManager.get.mockReturnValue({
enableProxy: false,
proxyType: 'http',
proxyServer: '',
proxyPort: '',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
});
await networkProxyCtr.setProxySettings(validConfig);
expect(mockStoreManager.set).toHaveBeenCalledWith(
'networkProxy',
expect.objectContaining(validConfig),
);
});
it('should skip update if settings are unchanged', async () => {
mockStoreManager.get.mockReturnValue(validConfig);
await networkProxyCtr.setProxySettings(validConfig);
expect(mockStoreManager.set).not.toHaveBeenCalled();
});
it('should throw error for invalid configuration', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyServer: '',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
});
describe('testProxyConnection', () => {
it('should return success for successful connection', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
const result = await networkProxyCtr.testProxyConnection('https://www.google.com');
expect(result).toEqual({ success: true });
expect(mockUndici.fetch).toHaveBeenCalledWith('https://www.google.com', expect.any(Object));
});
it('should throw error for failed connection', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
});
it('should throw error for network error', async () => {
vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Network error'));
await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
});
});
describe('testProxyConfig', () => {
const validConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should return success for valid config and successful connection', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
expect(result.success).toBe(true);
expect(result.responseTime).toBeGreaterThanOrEqual(0);
});
it('should return failure for invalid config', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyServer: '',
};
const result = await networkProxyCtr.testProxyConfig({ config: invalidConfig });
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid proxy configuration');
});
it('should test direct connection for disabled proxy', async () => {
const disabledConfig: NetworkProxySettings = {
...validConfig,
enableProxy: false,
};
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
const result = await networkProxyCtr.testProxyConfig({ config: disabledConfig });
expect(result.success).toBe(true);
});
it('should return failure for connection error', async () => {
vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
expect(result.success).toBe(false);
expect(result.message).toContain('Connection failed');
});
});
describe('beforeAppReady', () => {
it('should apply stored proxy settings on app ready', async () => {
const storedConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
mockStoreManager.get.mockReturnValue(storedConfig);
await networkProxyCtr.beforeAppReady();
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
});
it('should use default settings if stored config is invalid', async () => {
const invalidConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: '', // 无效的服务器
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
mockStoreManager.get.mockReturnValue(invalidConfig);
await networkProxyCtr.beforeAppReady();
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
});
it('should handle errors gracefully', async () => {
mockStoreManager.get.mockImplementation(() => {
throw new Error('Store error');
});
// 不应该抛出错误
await expect(networkProxyCtr.beforeAppReady()).resolves.not.toThrow();
mockStoreManager.get.mockReset();
});
});
describe('ProxyUrlBuilder', () => {
it('should build URL without authentication', () => {
const config: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
it('should build URL with authentication', () => {
const config: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: true,
proxyUsername: 'user',
proxyPassword: 'pass',
proxyBypass: 'localhost,127.0.0.1,::1',
};
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
it('should handle special characters in credentials', () => {
const config: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: true,
proxyUsername: 'user@domain',
proxyPassword: 'pass:word',
proxyBypass: 'localhost,127.0.0.1,::1',
};
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
});
});
@@ -5,9 +5,9 @@ import type { App } from '@/core/App';
import ShortcutController from '../ShortcutCtr';
// 模拟 App 及其依赖项
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
toggleMainWindow: 'CommandOrControl+Shift+L',
openSettings: 'CommandOrControl+,'
openSettings: 'CommandOrControl+,',
});
const mockUpdateShortcutConfig = vi.fn().mockImplementation((id, accelerator) => {
// 简单模拟更新成功
@@ -32,11 +32,11 @@ describe('ShortcutController', () => {
describe('getShortcutsConfig', () => {
it('should return shortcuts config from shortcutManager', () => {
const result = shortcutController.getShortcutsConfig();
expect(mockGetShortcutsConfig).toHaveBeenCalled();
expect(result).toEqual({
toggleMainWindow: 'CommandOrControl+Shift+L',
openSettings: 'CommandOrControl+,'
openSettings: 'CommandOrControl+,',
});
});
});
@@ -45,9 +45,9 @@ describe('ShortcutController', () => {
it('should call shortcutManager.updateShortcutConfig with correct parameters', () => {
const id = 'toggleMainWindow';
const accelerator = 'CommandOrControl+Alt+L';
const result = shortcutController.updateShortcutConfig(id, accelerator);
const result = shortcutController.updateShortcutConfig({ id, accelerator });
expect(mockUpdateShortcutConfig).toHaveBeenCalledWith(id, accelerator);
expect(result).toBe(true);
});
@@ -55,10 +55,13 @@ describe('ShortcutController', () => {
it('should return the result from shortcutManager.updateShortcutConfig', () => {
// 模拟更新失败的情况
mockUpdateShortcutConfig.mockReturnValueOnce(false);
const result = shortcutController.updateShortcutConfig('invalidKey', 'invalid+combo');
const result = shortcutController.updateShortcutConfig({
id: 'invalidKey',
accelerator: 'invalid+combo',
});
expect(result).toBe(false);
});
});
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc';
import type { ServerDispatchEvents } from '@lobechat/electron-server-ipc';
import type { App } from '@/core/App';
import { IoCContainer } from '@/core/IoCContainer';
import { IoCContainer } from '@/core/infrastructure/IoCContainer';
import { ShortcutActionType } from '@/shortcuts';
const ipcDecorator =
+15 -8
View File
@@ -13,14 +13,15 @@ import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { createLogger } from '@/utils/logger';
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
import BrowserManager from './BrowserManager';
import { I18nManager } from './I18nManager';
import { IoCContainer } from './IoCContainer';
import MenuManager from './MenuManager';
import { ShortcutManager } from './ShortcutManager';
import { StoreManager } from './StoreManager';
import TrayManager from './TrayManager';
import { UpdaterManager } from './UpdaterManager';
import { BrowserManager } from './browser/BrowserManager';
import { I18nManager } from './infrastructure/I18nManager';
import { IoCContainer } from './infrastructure/IoCContainer';
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
import { StoreManager } from './infrastructure/StoreManager';
import { UpdaterManager } from './infrastructure/UpdaterManager';
import { MenuManager } from './ui/MenuManager';
import { ShortcutManager } from './ui/ShortcutManager';
import { TrayManager } from './ui/TrayManager';
const logger = createLogger('core:App');
@@ -41,6 +42,7 @@ export class App {
updaterManager: UpdaterManager;
shortcutManager: ShortcutManager;
trayManager: TrayManager;
staticFileServerManager: StaticFileServerManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
/**
@@ -97,6 +99,7 @@ export class App {
this.updaterManager = new UpdaterManager(this);
this.shortcutManager = new ShortcutManager(this);
this.trayManager = new TrayManager(this);
this.staticFileServerManager = new StaticFileServerManager(this);
// register the schema to interceptor url
// it should register before app ready
@@ -130,6 +133,9 @@ export class App {
await this.i18n.init();
this.menuManager.initialize();
// Initialize static file manager
await this.staticFileServerManager.initialize();
// Initialize global shortcuts: globalShortcut must be called after app.whenReady()
this.shortcutManager.initialize();
@@ -399,6 +405,7 @@ export class App {
}
// 执行清理操作
this.staticFileServerManager.destroy();
this.unregisterAllRequestHandlers();
};
}
@@ -1,173 +0,0 @@
import { globalShortcut } from 'electron';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
// Create logger
const logger = createLogger('core:ShortcutManager');
export class ShortcutManager {
private app: App;
private shortcuts: Map<string, () => void> = new Map();
private shortcutsConfig: Record<string, string> = {};
constructor(app: App) {
logger.debug('Initializing ShortcutManager');
this.app = app;
app.shortcutMethodMap.forEach((method, key) => {
this.shortcuts.set(key, method);
});
}
initialize() {
logger.info('Initializing global shortcuts');
// Load shortcuts configuration from storage
this.loadShortcutsConfig();
// Register configured shortcuts
this.registerConfiguredShortcuts();
}
/**
* Get shortcuts configuration
*/
getShortcutsConfig(): Record<string, string> {
return this.shortcutsConfig;
}
/**
* Update a single shortcut configuration
*/
updateShortcutConfig(id: string, accelerator: string): boolean {
try {
logger.debug(`Updating shortcut ${id} to ${accelerator}`);
// Update configuration
this.shortcutsConfig[id] = accelerator;
this.saveShortcutsConfig();
this.registerConfiguredShortcuts();
return true;
} catch (error) {
logger.error(`Error updating shortcut ${id}:`, error);
return false;
}
}
/**
* Register global shortcut
* @param accelerator Shortcut key combination
* @param callback Callback function
* @returns Whether registration was successful
*/
registerShortcut(accelerator: string, callback: () => void): boolean {
try {
// If already registered, unregister first
if (this.shortcuts.has(accelerator)) {
this.unregisterShortcut(accelerator);
}
// Register new shortcut
const success = globalShortcut.register(accelerator, callback);
if (success) {
this.shortcuts.set(accelerator, callback);
logger.debug(`Registered shortcut: ${accelerator}`);
} else {
logger.error(`Failed to register shortcut: ${accelerator}`);
}
return success;
} catch (error) {
logger.error(`Error registering shortcut: ${accelerator}`, error);
return false;
}
}
/**
* Unregister global shortcut
* @param accelerator Shortcut key combination
*/
unregisterShortcut(accelerator: string): void {
try {
globalShortcut.unregister(accelerator);
this.shortcuts.delete(accelerator);
logger.debug(`Unregistered shortcut: ${accelerator}`);
} catch (error) {
logger.error(`Error unregistering shortcut: ${accelerator}`, error);
}
}
/**
* Check if a shortcut is already registered
* @param accelerator Shortcut key combination
* @returns Whether it is registered
*/
isRegistered(accelerator: string): boolean {
return globalShortcut.isRegistered(accelerator);
}
/**
* Unregister all shortcuts
*/
unregisterAll(): void {
globalShortcut.unregisterAll();
logger.info('Unregistered all shortcuts');
}
/**
* Load shortcuts configuration from storage
*/
private loadShortcutsConfig() {
try {
// Try to get configuration from storage
const config = this.app.storeManager.get('shortcuts');
// If no configuration, use default configuration
if (!config || Object.keys(config).length === 0) {
logger.debug('No shortcuts config found, using defaults');
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
this.saveShortcutsConfig();
} else {
this.shortcutsConfig = config;
}
logger.debug('Loaded shortcuts config:', this.shortcutsConfig);
} catch (error) {
logger.error('Error loading shortcuts config:', error);
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
this.saveShortcutsConfig();
}
}
/**
* Save shortcuts configuration to storage
*/
private saveShortcutsConfig() {
try {
this.app.storeManager.set('shortcuts', this.shortcutsConfig);
logger.debug('Saved shortcuts config');
} catch (error) {
logger.error('Error saving shortcuts config:', error);
}
}
/**
* Register configured shortcuts
*/
private registerConfiguredShortcuts() {
// Unregister all shortcuts first
this.unregisterAll();
// Register each enabled shortcut
Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => {
logger.debug(`Registering shortcut '${id}' with ${accelerator}`);
const method = this.shortcuts.get(id);
if (accelerator && method) {
this.registerShortcut(accelerator, method);
}
});
}
}
@@ -6,13 +6,21 @@ import {
nativeTheme,
screen,
} from 'electron';
import os from 'node:os';
import { join } from 'node:path';
import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
import { isDev, isWindows } from '@/const/env';
import {
BACKGROUND_DARK,
BACKGROUND_LIGHT,
SYMBOL_COLOR_DARK,
SYMBOL_COLOR_LIGHT,
THEME_CHANGE_DELAY,
TITLE_BAR_HEIGHT,
} from '@/const/theme';
import { createLogger } from '@/utils/logger';
import { preloadDir, resourcesDir } from '../const/dir';
import type { App } from './App';
import type { App } from '../App';
// Create logger
const logger = createLogger('core:Browser');
@@ -20,9 +28,6 @@ const logger = createLogger('core:Browser');
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
devTools?: boolean;
height?: number;
/**
* URL
*/
identifier: string;
keepAlive?: boolean;
parentIdentifier?: string;
@@ -34,38 +39,18 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
export default class Browser {
private app: App;
/**
* Internal electron window
*/
private _browserWindow?: BrowserWindow;
private themeListenerSetup = false;
private stopInterceptHandler;
/**
* Identifier
*/
identifier: string;
/**
* Options at creation
*/
options: BrowserWindowOpts;
/**
* Key for storing window state in storeManager
*/
private readonly windowStateKey: string;
/**
* Method to expose window externally
*/
get browserWindow() {
return this.retrieveOrInitialize();
}
get webContents() {
if (this._browserWindow.isDestroyed()) return null;
return this._browserWindow.webContents;
}
@@ -86,6 +71,101 @@ export default class Browser {
this.retrieveOrInitialize();
}
/**
* Get platform-specific theme configuration for window creation
*/
private getPlatformThemeConfig(isDarkMode?: boolean): Record<string, any> {
const darkMode = isDarkMode ?? nativeTheme.shouldUseDarkColors;
if (isWindows) {
return this.getWindowsThemeConfig(darkMode);
}
return {};
}
/**
* Get Windows-specific theme configuration
*/
private getWindowsThemeConfig(isDarkMode: boolean) {
return {
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
titleBarOverlay: {
color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
height: TITLE_BAR_HEIGHT,
symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
},
titleBarStyle: 'hidden' as const,
};
}
private setupThemeListener(): void {
if (this.themeListenerSetup) return;
nativeTheme.on('updated', this.handleThemeChange);
this.themeListenerSetup = true;
}
private handleThemeChange = (): void => {
logger.debug(`[${this.identifier}] System theme changed, reapplying visual effects.`);
setTimeout(() => {
this.applyVisualEffects();
}, THEME_CHANGE_DELAY);
};
/**
* Handle application theme mode change (called from BrowserManager)
*/
handleAppThemeChange = (): void => {
logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
setTimeout(() => {
this.applyVisualEffects();
}, THEME_CHANGE_DELAY);
};
private applyVisualEffects(): void {
if (!this._browserWindow || this._browserWindow.isDestroyed()) return;
logger.debug(`[${this.identifier}] Applying visual effects for platform`);
const isDarkMode = this.isDarkMode;
try {
if (isWindows) {
this.applyWindowsVisualEffects(isDarkMode);
}
logger.debug(
`[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
);
} catch (error) {
logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
}
}
private applyWindowsVisualEffects(isDarkMode: boolean): void {
const config = this.getWindowsThemeConfig(isDarkMode);
this._browserWindow.setBackgroundColor(config.backgroundColor);
this._browserWindow.setTitleBarOverlay(config.titleBarOverlay);
}
private cleanupThemeListener(): void {
if (this.themeListenerSetup) {
// Note: nativeTheme listeners are global, consider using a centralized theme manager
nativeTheme.off('updated', this.handleThemeChange);
// for multiple windows to avoid duplicate listeners
this.themeListenerSetup = false;
}
}
private get isDarkMode() {
const themeMode = this.app.storeManager.get('themeMode');
if (themeMode === 'auto') return nativeTheme.shouldUseDarkColors;
return themeMode === 'dark';
}
loadUrl = async (path: string) => {
const initUrl = this.app.nextServerUrl + path;
@@ -203,6 +283,7 @@ export default class Browser {
destroy() {
logger.debug(`Destroying window instance: ${this.identifier}`);
this.stopInterceptHandler?.();
this.cleanupThemeListener();
this._browserWindow = undefined;
}
@@ -228,45 +309,37 @@ export default class Browser {
`[${this.identifier}] Saved window state (only size used): ${JSON.stringify(savedState)}`,
);
const { isWindows11, isWindows } = this.getWindowsVersion();
const isDarkMode = nativeTheme.shouldUseDarkColors;
const browserWindow = new BrowserWindow({
...res,
...(isWindows
? {
titleBarStyle: 'hidden',
}
: {}),
...(isWindows11
? {
backgroundMaterial: isDarkMode ? 'mica' : 'acrylic',
vibrancy: 'under-window',
visualEffectState: 'active',
}
: {}),
autoHideMenuBar: true,
backgroundColor: '#00000000',
darkTheme: isDarkMode,
frame: false,
height: savedState?.height || height,
// Always create hidden first
show: false,
title,
vibrancy: 'sidebar',
visualEffectState: 'active',
webPreferences: {
// Context isolation environment
// https://www.electronjs.org/docs/tutorial/context-isolation
backgroundThrottling: false,
contextIsolation: true,
preload: join(preloadDir, 'index.js'),
},
width: savedState?.width || width,
...this.getPlatformThemeConfig(isDarkMode),
});
this._browserWindow = browserWindow;
logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
if (isWindows11) this.applyVisualEffects();
// Initialize theme listener for this window to handle theme changes
this.setupThemeListener();
logger.debug(`[${this.identifier}] Theme listener setup and applying initial visual effects.`);
// Apply initial visual effects
this.applyVisualEffects();
logger.debug(`[${this.identifier}] Setting up nextInterceptor.`);
this.stopInterceptHandler = this.app.nextInterceptor({
@@ -320,8 +393,9 @@ export default class Browser {
} catch (error) {
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
}
// Need to clean up intercept handler
// Need to clean up intercept handler and theme manager
this.stopInterceptHandler?.();
this.cleanupThemeListener();
return;
}
@@ -355,8 +429,9 @@ export default class Browser {
} catch (error) {
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
}
// Need to clean up intercept handler
// Need to clean up intercept handler and theme manager
this.stopInterceptHandler?.();
this.cleanupThemeListener();
}
});
@@ -381,20 +456,12 @@ export default class Browser {
}
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
if (this._browserWindow.isDestroyed()) return;
logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`);
this._browserWindow.webContents.send(channel, data);
};
applyVisualEffects() {
// Windows 11 can use this new API
if (this._browserWindow) {
logger.debug(`[${this.identifier}] Setting window background material for Windows 11`);
const isDarkMode = nativeTheme.shouldUseDarkColors;
this._browserWindow?.setBackgroundMaterial(isDarkMode ? 'mica' : 'acrylic');
this._browserWindow?.setVibrancy('under-window');
}
}
toggleVisible() {
logger.debug(`Toggling visibility for window: ${this.identifier}`);
if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) {
@@ -405,35 +472,11 @@ export default class Browser {
}
}
getWindowsVersion() {
if (process.platform !== 'win32') {
return {
isWindows: false,
isWindows10: false,
isWindows11: false,
version: null,
};
}
// 获取操作系统版本(如 "10.0.22621"
const release = os.release();
const parts = release.split('.');
// 主版本和次版本
const majorVersion = parseInt(parts[0], 10);
const minorVersion = parseInt(parts[1], 10);
// 构建号是第三部分
const buildNumber = parseInt(parts[2], 10);
// Windows 11 的构建号从 22000 开始
const isWindows11 = majorVersion === 10 && minorVersion === 0 && buildNumber >= 22_000;
return {
buildNumber,
isWindows: true,
isWindows11,
version: release,
};
/**
* Manually reapply visual effects (useful for fixing lost effects after window state changes)
*/
reapplyVisualEffects(): void {
logger.debug(`[${this.identifier}] Manually reapplying visual effects via Browser.`);
this.applyVisualEffects();
}
}
@@ -3,15 +3,15 @@ import { WebContents } from 'electron';
import { createLogger } from '@/utils/logger';
import { AppBrowsersIdentifiers, appBrowsers } from '../appBrowsers';
import type { App } from './App';
import { AppBrowsersIdentifiers, appBrowsers } from '../../appBrowsers';
import type { App } from '../App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
// Create logger
const logger = createLogger('core:BrowserManager');
export default class BrowserManager {
export class BrowserManager {
app: App;
browsers: Map<AppBrowsersIdentifiers, Browser> = new Map();
@@ -128,9 +128,12 @@ export default class BrowserManager {
*/
initializeBrowsers() {
logger.info('Initializing all browsers');
Object.values(appBrowsers).forEach((browser) => {
Object.values(appBrowsers).forEach((browser: BrowserWindowOpts) => {
logger.debug(`Initializing browser: ${browser.identifier}`);
this.retrieveOrInitialize(browser);
if (browser.keepAlive) {
this.retrieveOrInitialize(browser);
}
});
}
@@ -191,4 +194,14 @@ export default class BrowserManager {
getIdentifierByWebContents(webContents: WebContents): AppBrowsersIdentifiers | null {
return this.webContentsMap.get(webContents) || null;
}
/**
* Handle application theme mode changes and reapply visual effects to all windows
*/
handleAppThemeChange(): void {
logger.debug('Handling app theme change for all browser windows');
this.browsers.forEach((browser) => {
browser.handleAppThemeChange();
});
}
}
@@ -0,0 +1,227 @@
import { getPort } from 'get-port-please';
import { createServer } from 'node:http';
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import FileService from '@/services/fileSrv';
import { createLogger } from '@/utils/logger';
import type { App } from '../App';
const logger = createLogger('core:StaticFileServerManager');
export class StaticFileServerManager {
private app: App;
private fileService: FileService;
private httpServer: any = null;
private serverPort: number = 0;
private isInitialized = false;
constructor(app: App) {
this.app = app;
this.fileService = app.getService(FileService);
logger.debug('StaticFileServerManager initialized');
}
/**
* 初始化静态文件管理器
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
logger.warn('StaticFileServerManager already initialized');
return;
}
logger.info('Initializing StaticFileServerManager');
try {
// 启动 HTTP 文件服务器
await this.startHttpServer();
this.isInitialized = true;
logger.info(
`StaticFileServerManager initialization completed, server running on port ${this.serverPort}`,
);
} catch (error) {
logger.error('Failed to initialize StaticFileServerManager:', error);
throw error;
}
}
/**
* 启动 HTTP 文件服务器
*/
private async startHttpServer(): Promise<void> {
try {
// 使用 get-port-please 获取可用端口
this.serverPort = await getPort({
// 备用端口
host: '127.0.0.1',
port: 33_250,
// 首选端口
ports: [33_251, 33_252, 33_253, 33_254, 33_255],
});
logger.debug(`Found available port: ${this.serverPort}`);
return new Promise((resolve, reject) => {
const server = createServer(async (req, res) => {
// 设置请求超时
req.setTimeout(30_000, () => {
logger.warn('Request timeout, closing connection');
if (!res.destroyed && !res.headersSent) {
res.writeHead(408, { 'Content-Type': 'text/plain' });
res.end('Request Timeout');
}
});
// 监听客户端断开连接
req.on('close', () => {
logger.debug('Client disconnected during request processing');
});
try {
await this.handleHttpRequest(req, res);
} catch (error) {
logger.error('Unhandled error in HTTP request handler:', error);
// 尝试发送错误响应,但确保不会导致进一步错误
try {
if (!res.destroyed && !res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
} catch (responseError) {
logger.error('Failed to send error response:', responseError);
}
}
});
// 监听指定端口
server.listen(this.serverPort, '127.0.0.1', () => {
this.httpServer = server;
logger.info(`HTTP file server started on port ${this.serverPort}`);
resolve();
});
server.on('error', (error) => {
logger.error('HTTP server error:', error);
reject(error);
});
});
} catch (error) {
logger.error('Failed to get available port:', error);
throw error;
}
}
/**
* 处理 HTTP 请求
*/
private async handleHttpRequest(req: any, res: any): Promise<void> {
try {
// 检查响应是否已经结束
if (res.destroyed || res.headersSent) {
logger.warn('Response already ended, skipping request processing');
return;
}
const url = new URL(req.url, `http://127.0.0.1:${this.serverPort}`);
logger.debug(`Processing HTTP file request: ${req.url}`);
// 提取文件路径:从 /desktop-file/path/to/file.png 中提取相对路径
let filePath = decodeURIComponent(url.pathname.slice(1)); // 移除开头的 /
// 如果路径以 desktop-file/ 开头,则移除该前缀
const prefixWithoutSlash = LOCAL_STORAGE_URL_PREFIX.slice(1) + '/'; // 移除开头的 / 并添加结尾的 /
if (filePath.startsWith(prefixWithoutSlash)) {
filePath = filePath.slice(prefixWithoutSlash.length);
}
if (!filePath) {
logger.warn(`Empty file path in HTTP request: ${req.url}`);
if (!res.headersSent) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Bad Request: Empty file path');
}
return;
}
// 使用 FileService 获取文件
const fileResult = await this.fileService.getFile(`desktop://${filePath}`);
// 再次检查响应状态
if (res.destroyed || res.headersSent) {
logger.warn('Response ended during file processing');
return;
}
// 设置响应头
res.writeHead(200, {
// 缓存一年
'Access-Control-Allow-Origin': 'http://localhost:*',
'Cache-Control': 'public, max-age=31536000',
// 允许 localhost 的任意端口
'Content-Length': Buffer.byteLength(fileResult.content),
'Content-Type': fileResult.mimeType,
});
// 发送文件内容
res.end(Buffer.from(fileResult.content));
logger.debug(`HTTP file served successfully: desktop://${filePath}`);
} catch (error) {
logger.error(`Error serving HTTP file: ${error}`);
// 检查响应是否仍然可写
if (!res.destroyed && !res.headersSent) {
try {
// 判断是否是文件未找到错误
if (error.name === 'FileNotFoundError') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File Not Found');
} else {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
} catch (writeError) {
logger.error('Failed to write error response:', writeError);
}
} else {
logger.warn('Cannot write error response: connection already closed');
}
}
}
/**
* 获取文件服务器域名
*/
getFileServerDomain(): string {
if (!this.isInitialized || !this.serverPort) {
throw new Error('StaticFileServerManager not initialized or server not started');
}
const serverDomain = `http://127.0.0.1:${this.serverPort}`;
return serverDomain;
}
/**
* 销毁静态文件管理器
*/
destroy() {
logger.info('Destroying StaticFileServerManager');
if (this.httpServer) {
logger.debug('Closing HTTP file server');
this.httpServer.close(() => {
logger.debug('HTTP file server closed');
});
this.httpServer = null;
this.serverPort = 0;
}
this.isInitialized = false;
logger.info('StaticFileServerManager destroyed');
}
}
@@ -5,7 +5,7 @@ import { ElectronMainStore, StoreKey } from '@/types/store';
import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import { App } from './App';
import { App } from '../App';
// Create logger
const logger = createLogger('core:StoreManager');
@@ -5,7 +5,7 @@ import { isDev } from '@/const/env';
import { UPDATE_CHANNEL as channel, updaterConfig } from '@/modules/updater/configs';
import { createLogger } from '@/utils/logger';
import type { App as AppCore } from './App';
import type { App as AppCore } from '../App';
// Create logger
const logger = createLogger('core:UpdaterManager');
@@ -3,12 +3,12 @@ import { Menu } from 'electron';
import { IMenuPlatform, MenuOptions, createMenuImpl } from '@/menus';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
import type { App } from '../App';
// Create logger
const logger = createLogger('core:MenuManager');
export default class MenuManager {
export class MenuManager {
app: App;
private platformImpl: IMenuPlatform;
@@ -0,0 +1,300 @@
import { globalShortcut } from 'electron';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import { createLogger } from '@/utils/logger';
import type { App } from '../App';
// Create logger
const logger = createLogger('core:ShortcutManager');
export interface ShortcutUpdateResult {
errorType?:
| 'INVALID_ID'
| 'INVALID_FORMAT'
| 'NO_MODIFIER'
| 'CONFLICT'
| 'SYSTEM_OCCUPIED'
| 'UNKNOWN';
success: boolean;
}
export class ShortcutManager {
private app: App;
private shortcuts: Map<string, () => void> = new Map();
private shortcutsConfig: Record<string, string> = {};
constructor(app: App) {
logger.debug('Initializing ShortcutManager');
this.app = app;
app.shortcutMethodMap.forEach((method, key) => {
this.shortcuts.set(key, method);
});
}
/**
* Convert react-hotkey format to Electron accelerator format
* @param accelerator The accelerator string from frontend
* @returns Converted accelerator string for Electron
*/
private convertAcceleratorFormat(accelerator: string): string {
return accelerator
.split('+')
.map((key) => {
const trimmedKey = key.trim().toLowerCase();
// Convert react-hotkey 'mod' to Electron 'CommandOrControl'
if (trimmedKey === 'mod') {
return 'CommandOrControl';
}
// Keep other keys as is, but preserve proper casing
return key.trim().length === 1 ? key.trim().toUpperCase() : key.trim();
})
.join('+');
}
initialize() {
logger.info('Initializing global shortcuts');
// Load shortcuts configuration from storage
this.loadShortcutsConfig();
// Register configured shortcuts
this.registerConfiguredShortcuts();
}
/**
* Get shortcuts configuration
*/
getShortcutsConfig(): Record<string, string> {
return this.shortcutsConfig;
}
/**
* Update a single shortcut configuration
*/
updateShortcutConfig(id: string, accelerator: string): ShortcutUpdateResult {
try {
logger.debug(`Updating shortcut ${id} to ${accelerator}`);
// 1. 检查 ID 是否有效
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
logger.error(`Invalid shortcut ID: ${id}`);
return { errorType: 'INVALID_ID', success: false };
}
// 2. 基本格式校验
if (!accelerator || typeof accelerator !== 'string' || accelerator.trim() === '') {
logger.error(`Invalid accelerator format: ${accelerator}`);
return { errorType: 'INVALID_FORMAT', success: false };
}
// 转换前端格式到 Electron 格式
const convertedAccelerator = this.convertAcceleratorFormat(accelerator.trim());
const cleanAccelerator = convertedAccelerator.toLowerCase();
logger.debug(`Converted accelerator from ${accelerator} to ${convertedAccelerator}`);
// 3. 检查是否包含 + 号(修饰键格式)
if (!cleanAccelerator.includes('+')) {
logger.error(
`Invalid accelerator format: ${cleanAccelerator}. Must contain modifier keys like 'CommandOrControl+E'`,
);
return { errorType: 'INVALID_FORMAT', success: false };
}
// 4. 检查是否有基本的修饰键
const hasModifier = ['CommandOrControl', 'Command', 'Ctrl', 'Alt', 'Shift'].some((modifier) =>
cleanAccelerator.includes(modifier.toLowerCase()),
);
if (!hasModifier) {
logger.error(`Invalid accelerator format: ${cleanAccelerator}. Must contain modifier keys`);
return { errorType: 'NO_MODIFIER', success: false };
}
// 5. 检查冲突
for (const [existingId, existingAccelerator] of Object.entries(this.shortcutsConfig)) {
if (
existingId !== id &&
typeof existingAccelerator === 'string' &&
existingAccelerator.toLowerCase() === cleanAccelerator
) {
logger.error(`Shortcut conflict: ${cleanAccelerator} already used by ${existingId}`);
return { errorType: 'CONFLICT', success: false };
}
}
// 6. 尝试注册测试(检查是否被系统占用)
const testSuccess = globalShortcut.register(convertedAccelerator, () => {});
if (!testSuccess) {
logger.error(
`Shortcut ${convertedAccelerator} is already registered by system or other app`,
);
return { errorType: 'SYSTEM_OCCUPIED', success: false };
} else {
// 测试成功,立即取消注册
globalShortcut.unregister(convertedAccelerator);
}
// 7. 更新配置
this.shortcutsConfig[id] = convertedAccelerator;
this.saveShortcutsConfig();
this.registerConfiguredShortcuts();
return { success: true };
} catch (error) {
logger.error(`Error updating shortcut ${id}:`, error);
return { errorType: 'UNKNOWN', success: false };
}
}
/**
* Register global shortcut
* @param accelerator Shortcut key combination
* @param callback Callback function
* @returns Whether registration was successful
*/
registerShortcut(accelerator: string, callback: () => void): boolean {
try {
// If already registered, unregister first
if (this.shortcuts.has(accelerator)) {
this.unregisterShortcut(accelerator);
}
// Register new shortcut
const success = globalShortcut.register(accelerator, callback);
if (success) {
this.shortcuts.set(accelerator, callback);
logger.debug(`Registered shortcut: ${accelerator}`);
} else {
logger.error(`Failed to register shortcut: ${accelerator}`);
}
return success;
} catch (error) {
logger.error(`Error registering shortcut: ${accelerator}`, error);
return false;
}
}
/**
* Unregister global shortcut
* @param accelerator Shortcut key combination
*/
unregisterShortcut(accelerator: string): void {
try {
globalShortcut.unregister(accelerator);
this.shortcuts.delete(accelerator);
logger.debug(`Unregistered shortcut: ${accelerator}`);
} catch (error) {
logger.error(`Error unregistering shortcut: ${accelerator}`, error);
}
}
/**
* Check if a shortcut is already registered
* @param accelerator Shortcut key combination
* @returns Whether it is registered
*/
isRegistered(accelerator: string): boolean {
return globalShortcut.isRegistered(accelerator);
}
/**
* Unregister all shortcuts
*/
unregisterAll(): void {
globalShortcut.unregisterAll();
logger.info('Unregistered all shortcuts');
}
/**
* Load shortcuts configuration from storage
*/
private loadShortcutsConfig() {
try {
// Try to get configuration from storage
const config = this.app.storeManager.get('shortcuts');
// If no configuration, use default configuration
if (!config || Object.keys(config).length === 0) {
logger.debug('No shortcuts config found, using defaults');
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
this.saveShortcutsConfig();
} else {
// Filter out invalid shortcuts that are not in DEFAULT_SHORTCUTS_CONFIG
const filteredConfig: Record<string, string> = {};
let hasInvalidKeys = false;
Object.entries(config).forEach(([id, accelerator]) => {
if (DEFAULT_SHORTCUTS_CONFIG[id]) {
filteredConfig[id] = accelerator;
} else {
hasInvalidKeys = true;
logger.debug(`Filtering out invalid shortcut ID: ${id}`);
}
});
// Ensure all default shortcuts are present
Object.entries(DEFAULT_SHORTCUTS_CONFIG).forEach(([id, defaultAccelerator]) => {
if (!(id in filteredConfig)) {
filteredConfig[id] = defaultAccelerator;
logger.debug(`Adding missing default shortcut: ${id} = ${defaultAccelerator}`);
}
});
this.shortcutsConfig = filteredConfig;
// Save the filtered configuration back to storage if we removed invalid keys
if (hasInvalidKeys) {
logger.debug('Saving filtered shortcuts config to remove invalid keys');
this.saveShortcutsConfig();
}
}
logger.debug('Loaded shortcuts config:', this.shortcutsConfig);
} catch (error) {
logger.error('Error loading shortcuts config:', error);
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
this.saveShortcutsConfig();
}
}
/**
* Save shortcuts configuration to storage
*/
private saveShortcutsConfig() {
try {
this.app.storeManager.set('shortcuts', this.shortcutsConfig);
logger.debug('Saved shortcuts config');
} catch (error) {
logger.error('Error saving shortcuts config:', error);
}
}
/**
* Register configured shortcuts
*/
private registerConfiguredShortcuts() {
// Unregister all shortcuts first
this.unregisterAll();
// Register each enabled shortcut
Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => {
logger.debug(`Registering shortcut '${id}' with ${accelerator}`);
// 只注册在 DEFAULT_SHORTCUTS_CONFIG 中存在的快捷键
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
logger.debug(`Skipping shortcut '${id}' - not found in DEFAULT_SHORTCUTS_CONFIG`);
return;
}
const method = this.shortcuts.get(id);
if (accelerator && method) {
this.registerShortcut(accelerator, method);
}
});
}
}
@@ -12,157 +12,157 @@ import { join } from 'node:path';
import { resourcesDir } from '@/const/dir';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
import type { App } from '../App';
// 创建日志记录器
// Create logger
const logger = createLogger('core:Tray');
export interface TrayOptions {
/**
*
* Tray icon path (relative to resource directory)
*/
iconPath: string;
/**
*
* Tray identifier
*/
identifier: string;
/**
*
* Tray tooltip text
*/
tooltip?: string;
}
export default class Tray {
export class Tray {
private app: App;
/**
* Electron
* Internal Electron tray
*/
private _tray?: ElectronTray;
/**
*
* Identifier
*/
identifier: string;
/**
*
* Options when created
*/
options: TrayOptions;
/**
*
* Get tray instance
*/
get tray() {
return this.retrieveOrInitialize();
}
/**
*
* @param options
* @param application
* Construct tray object
* @param options Tray options
* @param application App instance
*/
constructor(options: TrayOptions, application: App) {
logger.debug(`创建托盘实例: ${options.identifier}`);
logger.debug(`托盘选项: ${JSON.stringify(options)}`);
logger.debug(`Creating tray instance: ${options.identifier}`);
logger.debug(`Tray options: ${JSON.stringify(options)}`);
this.app = application;
this.identifier = options.identifier;
this.options = options;
// 初始化
// Initialize
this.retrieveOrInitialize();
}
/**
*
* Initialize tray
*/
retrieveOrInitialize() {
// 如果托盘已存在且未被销毁,则返回
// If tray already exists and is not destroyed, return it
if (this._tray) {
logger.debug(`[${this.identifier}] 返回现有托盘实例`);
logger.debug(`[${this.identifier}] Returning existing tray instance`);
return this._tray;
}
const { iconPath, tooltip } = this.options;
// 加载托盘图标
logger.info(`创建新的托盘实例: ${this.identifier}`);
// Load tray icon
logger.info(`Creating new tray instance: ${this.identifier}`);
const iconFile = join(resourcesDir, iconPath);
logger.debug(`[${this.identifier}] 加载图标: ${iconFile}`);
logger.debug(`[${this.identifier}] Loading icon: ${iconFile}`);
try {
const icon = nativeImage.createFromPath(iconFile);
this._tray = new ElectronTray(icon);
// 设置工具提示
// Set tooltip
if (tooltip) {
logger.debug(`[${this.identifier}] 设置提示文本: ${tooltip}`);
logger.debug(`[${this.identifier}] Setting tooltip: ${tooltip}`);
this._tray.setToolTip(tooltip);
}
// 设置默认上下文菜单
// Set default context menu
this.setContextMenu();
// 设置点击事件
// Set click event
this._tray.on('click', () => {
logger.debug(`[${this.identifier}] 托盘被点击`);
logger.debug(`[${this.identifier}] Tray clicked`);
this.onClick();
});
logger.debug(`[${this.identifier}] 托盘实例创建完成`);
logger.debug(`[${this.identifier}] Tray instance created successfully`);
return this._tray;
} catch (error) {
logger.error(`[${this.identifier}] 创建托盘失败:`, error);
logger.error(`[${this.identifier}] Failed to create tray:`, error);
throw error;
}
}
/**
*
* @param template 使
* Set tray context menu
* @param template Menu template, if not provided default template will be used
*/
setContextMenu(template?: MenuItemConstructorOptions[]) {
logger.debug(`[${this.identifier}] 设置托盘上下文菜单`);
logger.debug(`[${this.identifier}] Setting tray context menu`);
// 如果未提供模板,使用默认菜单
// If no template provided, use default menu
const defaultTemplate: MenuItemConstructorOptions[] = template || [
{
click: () => {
logger.debug(`[${this.identifier}] 菜单项 "显示主窗口" 被点击`);
logger.debug(`[${this.identifier}] Menu item "Show Main Window" clicked`);
this.app.browserManager.showMainWindow();
},
label: '显示主窗口',
label: 'Show Main Window',
},
{ type: 'separator' },
{
click: () => {
logger.debug(`[${this.identifier}] 菜单项 "退出" 被点击`);
logger.debug(`[${this.identifier}] Menu item "Quit" clicked`);
app.quit();
},
label: '退出',
label: 'Quit',
},
];
const contextMenu = Menu.buildFromTemplate(defaultTemplate);
this._tray?.setContextMenu(contextMenu);
logger.debug(`[${this.identifier}] 托盘上下文菜单已设置`);
logger.debug(`[${this.identifier}] Tray context menu has been set`);
}
/**
*
* Handle tray click event
*/
onClick() {
logger.debug(`[${this.identifier}] 处理托盘点击事件`);
logger.debug(`[${this.identifier}] Handling tray click event`);
const mainWindow = this.app.browserManager.getMainWindow();
if (mainWindow) {
if (mainWindow.browserWindow.isVisible() && mainWindow.browserWindow.isFocused()) {
logger.debug(`[${this.identifier}] 主窗口已可见且聚焦,现在隐藏它`);
logger.debug(`[${this.identifier}] Main window is visible and focused, hiding it now`);
mainWindow.hide();
} else {
logger.debug(`[${this.identifier}] 显示并聚焦主窗口`);
logger.debug(`[${this.identifier}] Showing and focusing main window`);
mainWindow.show();
mainWindow.browserWindow.focus();
}
@@ -170,59 +170,61 @@ export default class Tray {
}
/**
*
* @param iconPath
* Update tray icon
* @param iconPath New icon path (relative to resource directory)
*/
updateIcon(iconPath: string) {
logger.debug(`[${this.identifier}] 更新图标: ${iconPath}`);
logger.debug(`[${this.identifier}] Updating icon: ${iconPath}`);
try {
const iconFile = join(resourcesDir, iconPath);
const icon = nativeImage.createFromPath(iconFile);
this._tray?.setImage(icon);
this.options.iconPath = iconPath;
logger.debug(`[${this.identifier}] 图标已更新`);
logger.debug(`[${this.identifier}] Icon updated successfully`);
} catch (error) {
logger.error(`[${this.identifier}] 更新图标失败:`, error);
logger.error(`[${this.identifier}] Failed to update icon:`, error);
}
}
/**
*
* @param tooltip
* Update tooltip text
* @param tooltip New tooltip text
*/
updateTooltip(tooltip: string) {
logger.debug(`[${this.identifier}] 更新提示文本: ${tooltip}`);
logger.debug(`[${this.identifier}] Updating tooltip: ${tooltip}`);
this._tray?.setToolTip(tooltip);
this.options.tooltip = tooltip;
}
/**
* Windows
* @param options
* Display balloon notification (only supported on Windows)
* @param options Balloon options
*/
displayBalloon(options: DisplayBalloonOptions) {
if (process.platform === 'win32' && this._tray) {
logger.debug(`[${this.identifier}] 显示气泡通知: ${JSON.stringify(options)}`);
logger.debug(
`[${this.identifier}] Displaying balloon notification: ${JSON.stringify(options)}`,
);
this._tray.displayBalloon(options);
} else {
logger.debug(`[${this.identifier}] 气泡通知仅在 Windows 上支持`);
logger.debug(`[${this.identifier}] Balloon notification is only supported on Windows`);
}
}
/**
* 广
* Broadcast event
*/
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
logger.debug(`向托盘 ${this.identifier} 广播, 频道: ${channel}`);
// 可以通过 App 实例的 browserManager 将消息转发到主窗口
logger.debug(`Broadcasting to tray ${this.identifier}, channel: ${channel}`);
// Can forward message to main window through App instance's browserManager
this.app.browserManager.getMainWindow()?.broadcast(channel, data);
};
/**
*
* Destroy tray instance
*/
destroy() {
logger.debug(`销毁托盘实例: ${this.identifier}`);
logger.debug(`Destroying tray instance: ${this.identifier}`);
if (this._tray) {
this._tray.destroy();
this._tray = undefined;
@@ -1,10 +1,12 @@
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import { nativeTheme } from 'electron';
import { name } from '@/../../package.json';
import { isMac } from '@/const/env';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
import Tray, { TrayOptions } from './Tray';
import type { App } from '../App';
import { Tray, TrayOptions } from './Tray';
// 创建日志记录器
const logger = createLogger('core:TrayManager');
@@ -14,7 +16,7 @@ const logger = createLogger('core:TrayManager');
*/
export type TrayIdentifiers = 'main';
export default class TrayManager {
export class TrayManager {
app: App;
/**
@@ -54,9 +56,13 @@ export default class TrayManager {
initializeMainTray() {
logger.debug('初始化主托盘');
return this.retrieveOrInitialize({
iconPath: 'tray-icon.png',
identifier: 'main', // 使用应用图标,需要确保资源目录中有此文件
tooltip: name, // 可以使用 app.getName() 或本地化字符串
iconPath: isMac
? nativeTheme.shouldUseDarkColors
? 'tray-dark.png'
: 'tray-light.png'
: 'tray.png',
identifier: 'main', // Use app icon, ensure this file exists in resources directory
tooltip: name, // Can use app.getName() or localized string
});
}
@@ -0,0 +1,539 @@
import { globalShortcut } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import type { App } from '../../App';
import { ShortcutManager } from '../ShortcutManager';
// Mock electron
vi.mock('electron', () => ({
globalShortcut: {
register: vi.fn(),
unregister: vi.fn(),
unregisterAll: vi.fn(),
isRegistered: vi.fn(),
},
}));
// Mock Logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// Mock DEFAULT_SHORTCUTS_CONFIG
vi.mock('@/shortcuts', () => ({
DEFAULT_SHORTCUTS_CONFIG: {
showApp: 'Control+E',
openSettings: 'CommandOrControl+,',
},
}));
describe('ShortcutManager', () => {
let shortcutManager: ShortcutManager;
let mockApp: App;
let mockStoreManager: any;
let mockShortcutMethodMap: Map<string, () => void>;
beforeEach(() => {
vi.clearAllMocks();
// Reset all mocks to their default behavior
vi.mocked(globalShortcut.register).mockReturnValue(true);
vi.mocked(globalShortcut.unregister).mockReturnValue(undefined);
vi.mocked(globalShortcut.unregisterAll).mockReturnValue(undefined);
vi.mocked(globalShortcut.isRegistered).mockReturnValue(false);
// Mock store manager
mockStoreManager = {
get: vi.fn(),
set: vi.fn(),
};
// Mock shortcut method map
mockShortcutMethodMap = new Map();
const showAppMethod = vi.fn();
const openSettingsMethod = vi.fn();
mockShortcutMethodMap.set('showApp', showAppMethod);
mockShortcutMethodMap.set('openSettings', openSettingsMethod);
// Mock App
mockApp = {
storeManager: mockStoreManager,
shortcutMethodMap: mockShortcutMethodMap,
} as unknown as App;
shortcutManager = new ShortcutManager(mockApp);
});
describe('constructor', () => {
it('should initialize shortcut manager with app', () => {
expect(shortcutManager).toBeDefined();
expect(shortcutManager['app']).toBe(mockApp);
});
it('should populate shortcuts map from app shortcut method map', () => {
expect(shortcutManager['shortcuts'].size).toBe(2);
expect(shortcutManager['shortcuts'].has('showApp')).toBe(true);
expect(shortcutManager['shortcuts'].has('openSettings')).toBe(true);
});
});
describe('convertAcceleratorFormat', () => {
it('should convert mod to CommandOrControl', () => {
const result = shortcutManager['convertAcceleratorFormat']('mod+e');
expect(result).toBe('CommandOrControl+E');
});
it('should preserve other keys as is except single characters', () => {
const result = shortcutManager['convertAcceleratorFormat']('ctrl+alt+f12');
expect(result).toBe('ctrl+alt+f12');
});
it('should handle single character keys with uppercase', () => {
const result = shortcutManager['convertAcceleratorFormat']('ctrl + a');
expect(result).toBe('ctrl+A');
});
it('should handle complex combinations', () => {
const result = shortcutManager['convertAcceleratorFormat']('mod+shift+delete');
expect(result).toBe('CommandOrControl+shift+delete');
});
});
describe('initialize', () => {
it('should load shortcuts config and register shortcuts', () => {
// Mock store to return empty config (will use defaults)
mockStoreManager.get.mockReturnValue({});
shortcutManager.initialize();
expect(mockStoreManager.get).toHaveBeenCalledWith('shortcuts');
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).toHaveBeenCalledWith('Control+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+,',
expect.any(Function),
);
});
it('should handle stored config with filtering', () => {
const storedConfig = {
showApp: 'Alt+E',
openSettings: 'Ctrl+Shift+P',
invalidKey: 'Ctrl+I', // Should be filtered out
};
mockStoreManager.get.mockReturnValue(storedConfig);
shortcutManager.initialize();
const config = shortcutManager.getShortcutsConfig();
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('Ctrl+Shift+P');
expect(config.invalidKey).toBeUndefined();
});
});
describe('getShortcutsConfig', () => {
it('should return current shortcuts configuration', () => {
mockStoreManager.get.mockReturnValue({});
shortcutManager.initialize();
const config = shortcutManager.getShortcutsConfig();
expect(config).toEqual(DEFAULT_SHORTCUTS_CONFIG);
});
});
describe('updateShortcutConfig', () => {
beforeEach(() => {
mockStoreManager.get.mockReturnValue({});
shortcutManager.initialize();
});
it('should successfully update valid shortcut', () => {
const result = shortcutManager.updateShortcutConfig('showApp', 'Alt+E');
expect(result.success).toBe(true);
expect(result.errorType).toBeUndefined();
expect(mockStoreManager.set).toHaveBeenCalledWith(
'shortcuts',
expect.objectContaining({
showApp: 'Alt+E',
}),
);
});
it('should reject invalid shortcut ID', () => {
const result = shortcutManager.updateShortcutConfig('invalidId', 'Alt+E');
expect(result.success).toBe(false);
expect(result.errorType).toBe('INVALID_ID');
});
it('should reject empty accelerator', () => {
const result = shortcutManager.updateShortcutConfig('showApp', '');
expect(result.success).toBe(false);
expect(result.errorType).toBe('INVALID_FORMAT');
});
it('should reject accelerator without modifier keys', () => {
const result = shortcutManager.updateShortcutConfig('showApp', 'E');
expect(result.success).toBe(false);
expect(result.errorType).toBe('INVALID_FORMAT');
});
it('should reject accelerator without proper modifiers', () => {
const result = shortcutManager.updateShortcutConfig('showApp', 'F1+E');
expect(result.success).toBe(false);
expect(result.errorType).toBe('NO_MODIFIER');
});
it('should detect conflicts with existing shortcuts', () => {
// First set a shortcut
shortcutManager.updateShortcutConfig('showApp', 'Alt+E');
// Try to set the same accelerator for another shortcut
const result = shortcutManager.updateShortcutConfig('openSettings', 'Alt+E');
expect(result.success).toBe(false);
expect(result.errorType).toBe('CONFLICT');
});
it('should detect system occupied shortcuts', () => {
vi.mocked(globalShortcut.register).mockReturnValue(false);
const result = shortcutManager.updateShortcutConfig('showApp', 'Ctrl+Alt+T');
expect(result.success).toBe(false);
expect(result.errorType).toBe('SYSTEM_OCCUPIED');
});
it('should handle registration test cleanup', () => {
vi.mocked(globalShortcut.register).mockReturnValue(true);
shortcutManager.updateShortcutConfig('showApp', 'Ctrl+Alt+T');
// Should unregister the test registration
expect(globalShortcut.unregister).toHaveBeenCalledWith('Ctrl+Alt+T');
});
it('should handle conversion from react-hotkey format', () => {
const result = shortcutManager.updateShortcutConfig('showApp', 'mod+shift+e');
expect(result.success).toBe(true);
const config = shortcutManager.getShortcutsConfig();
expect(config.showApp).toBe('CommandOrControl+shift+E');
});
it('should handle errors gracefully', () => {
// Mock globalShortcut.register to throw an error during testing
vi.mocked(globalShortcut.register).mockImplementation(() => {
throw new Error('Register error');
});
const result = shortcutManager.updateShortcutConfig('showApp', 'Alt+E');
expect(result.success).toBe(false);
expect(result.errorType).toBe('UNKNOWN');
});
});
describe('registerShortcut', () => {
it('should register new shortcut successfully', () => {
const callback = vi.fn();
vi.mocked(globalShortcut.register).mockReturnValue(true);
const result = shortcutManager.registerShortcut('Ctrl+T', callback);
expect(result).toBe(true);
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+T', callback);
expect(shortcutManager['shortcuts'].has('Ctrl+T')).toBe(true);
});
it('should unregister existing shortcut before registering new one', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
// First registration
shortcutManager['shortcuts'].set('Ctrl+T', callback1);
vi.mocked(globalShortcut.register).mockReturnValue(true);
shortcutManager.registerShortcut('Ctrl+T', callback2);
expect(globalShortcut.unregister).toHaveBeenCalledWith('Ctrl+T');
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+T', callback2);
});
it('should handle registration failure', () => {
const callback = vi.fn();
vi.mocked(globalShortcut.register).mockReturnValue(false);
const result = shortcutManager.registerShortcut('Ctrl+T', callback);
expect(result).toBe(false);
expect(shortcutManager['shortcuts'].has('Ctrl+T')).toBe(false);
});
it('should handle registration errors', () => {
const callback = vi.fn();
vi.mocked(globalShortcut.register).mockImplementation(() => {
throw new Error('Registration error');
});
const result = shortcutManager.registerShortcut('Ctrl+T', callback);
expect(result).toBe(false);
});
});
describe('unregisterShortcut', () => {
it('should unregister shortcut successfully', () => {
const callback = vi.fn();
shortcutManager['shortcuts'].set('Ctrl+T', callback);
shortcutManager.unregisterShortcut('Ctrl+T');
expect(globalShortcut.unregister).toHaveBeenCalledWith('Ctrl+T');
expect(shortcutManager['shortcuts'].has('Ctrl+T')).toBe(false);
});
it('should handle unregistration errors', () => {
vi.mocked(globalShortcut.unregister).mockImplementation(() => {
throw new Error('Unregister error');
});
// Should not throw
expect(() => shortcutManager.unregisterShortcut('Ctrl+T')).not.toThrow();
});
});
describe('isRegistered', () => {
it('should check if shortcut is registered', () => {
vi.mocked(globalShortcut.isRegistered).mockReturnValue(true);
const result = shortcutManager.isRegistered('Ctrl+T');
expect(result).toBe(true);
expect(globalShortcut.isRegistered).toHaveBeenCalledWith('Ctrl+T');
});
});
describe('unregisterAll', () => {
it('should unregister all shortcuts', () => {
shortcutManager.unregisterAll();
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
});
});
describe('loadShortcutsConfig', () => {
it('should use defaults when no config exists', () => {
mockStoreManager.get.mockReturnValue(null);
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
});
it('should use defaults when config is empty', () => {
mockStoreManager.get.mockReturnValue({});
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
});
it('should filter invalid keys from stored config', () => {
const storedConfig = {
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
invalidKey1: 'Ctrl+I',
invalidKey2: 'Ctrl+J',
};
mockStoreManager.get.mockReturnValue(storedConfig);
shortcutManager['loadShortcutsConfig']();
const config = shortcutManager['shortcutsConfig'];
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('Ctrl+P');
expect(config.invalidKey1).toBeUndefined();
expect(config.invalidKey2).toBeUndefined();
// Should save filtered config
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', config);
});
it('should add missing default shortcuts', () => {
const incompleteConfig = {
showApp: 'Alt+E',
// Missing openSettings
};
mockStoreManager.get.mockReturnValue(incompleteConfig);
shortcutManager['loadShortcutsConfig']();
const config = shortcutManager['shortcutsConfig'];
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('CommandOrControl+,'); // Default value
});
it('should not save config if no invalid keys were found', () => {
const validConfig = {
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
mockStoreManager.get.mockReturnValue(validConfig);
shortcutManager['loadShortcutsConfig']();
// Should not call set since no changes were made
expect(mockStoreManager.set).not.toHaveBeenCalled();
});
it('should handle store errors gracefully', () => {
mockStoreManager.get.mockImplementation(() => {
throw new Error('Store error');
});
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
});
});
describe('saveShortcutsConfig', () => {
it('should save shortcuts config to store', () => {
shortcutManager['shortcutsConfig'] = { showApp: 'Alt+E', openSettings: 'Ctrl+P' };
shortcutManager['saveShortcutsConfig']();
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', {
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
});
});
it('should handle save errors gracefully', () => {
mockStoreManager.set.mockImplementation(() => {
throw new Error('Save error');
});
// Should not throw
expect(() => shortcutManager['saveShortcutsConfig']()).not.toThrow();
});
});
describe('registerConfiguredShortcuts', () => {
beforeEach(() => {
shortcutManager['shortcutsConfig'] = {
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
});
it('should register all configured shortcuts', () => {
vi.mocked(globalShortcut.register).mockReturnValue(true);
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
it('should skip shortcuts not in DEFAULT_SHORTCUTS_CONFIG', () => {
shortcutManager['shortcutsConfig'] = {
showApp: 'Alt+E',
invalidKey: 'Ctrl+I',
};
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+I', expect.any(Function));
});
it('should skip shortcuts with empty accelerator', () => {
shortcutManager['shortcutsConfig'] = {
showApp: '',
openSettings: 'Ctrl+P',
};
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.register).not.toHaveBeenCalledWith('', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
it('should skip shortcuts without corresponding methods', () => {
// Remove method from map
mockShortcutMethodMap.delete('openSettings');
shortcutManager = new ShortcutManager(mockApp);
shortcutManager['shortcutsConfig'] = {
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
});
describe('integration tests', () => {
it('should complete full initialization flow', () => {
const storedConfig = {
showApp: 'Alt+E',
openSettings: 'Ctrl+Shift+P',
invalidKey: 'Ctrl+I',
};
mockStoreManager.get.mockReturnValue(storedConfig);
vi.mocked(globalShortcut.register).mockReturnValue(true);
shortcutManager.initialize();
// Should filter config and register valid shortcuts
const config = shortcutManager.getShortcutsConfig();
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('Ctrl+Shift+P');
expect(config.invalidKey).toBeUndefined();
expect(globalShortcut.register).toHaveBeenCalledTimes(2);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', config);
});
it('should handle complete update workflow', () => {
mockStoreManager.get.mockReturnValue({});
shortcutManager.initialize();
// Update a shortcut
const result = shortcutManager.updateShortcutConfig('showApp', 'mod+alt+e');
expect(result.success).toBe(true);
// Should convert format and register
const config = shortcutManager.getShortcutsConfig();
expect(config.showApp).toBe('CommandOrControl+alt+E');
// Should have saved and re-registered shortcuts
expect(mockStoreManager.set).toHaveBeenCalled();
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+alt+E',
expect.any(Function),
);
});
});
});
@@ -0,0 +1,116 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { Agent, ProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici';
import { createLogger } from '@/utils/logger';
import { ProxyUrlBuilder } from './urlBuilder';
// Create logger
const logger = createLogger('modules:networkProxy:dispatcher');
/**
* 代理管理器
*/
export class ProxyDispatcherManager {
private static isChanging = false;
private static changeQueue: Array<() => Promise<void>> = [];
/**
* 应用代理设置(带并发控制)
*/
static async applyProxySettings(config: NetworkProxySettings): Promise<void> {
return new Promise((resolve, reject) => {
const operation = async () => {
try {
await this.doApplyProxySettings(config);
resolve();
} catch (error) {
reject(error);
}
};
if (this.isChanging) {
// 如果正在切换,加入队列
this.changeQueue.push(operation);
} else {
// 立即执行
operation();
}
});
}
/**
* 执行代理设置应用
*/
private static async doApplyProxySettings(config: NetworkProxySettings): Promise<void> {
this.isChanging = true;
try {
const currentDispatcher = getGlobalDispatcher();
// 禁用代理,恢复默认连接
if (!config.enableProxy) {
await this.safeDestroyDispatcher(currentDispatcher);
// 创建一个新的默认 Agent 来替代代理
setGlobalDispatcher(new Agent());
logger.debug('Proxy disabled, reset to direct connection mode');
return;
}
// 构建代理 URL
const proxyUrl = ProxyUrlBuilder.build(config);
// 创建代理 agent
const agent = this.createProxyAgent(config.proxyType, proxyUrl);
// 切换代理前销毁旧 dispatcher
await this.safeDestroyDispatcher(currentDispatcher);
setGlobalDispatcher(agent);
logger.info(
`Proxy settings applied: ${config.proxyType}://${config.proxyServer}:${config.proxyPort}`,
);
logger.debug(
'Global request proxy set, all Node.js network requests will go through this proxy',
);
} finally {
this.isChanging = false;
// 处理队列中的下一个操作
if (this.changeQueue.length > 0) {
const nextOperation = this.changeQueue.shift();
if (nextOperation) {
setTimeout(() => nextOperation(), 0);
}
}
}
}
/**
* 创建代理 agent
*/
static createProxyAgent(proxyType: string, proxyUrl: string) {
try {
// undici 的 ProxyAgent 支持 http, https 和 socks5
return new ProxyAgent({ uri: proxyUrl });
} catch (error) {
logger.error(`Failed to create proxy agent for ${proxyType}:`, error);
throw new Error(
`Failed to create proxy agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
/**
* 安全销毁 dispatcher
*/
private static async safeDestroyDispatcher(dispatcher: any): Promise<void> {
try {
if (dispatcher && typeof dispatcher.destroy === 'function') {
await dispatcher.destroy();
}
} catch (error) {
logger.warn('Failed to destroy dispatcher:', error);
}
}
}
@@ -0,0 +1,6 @@
export { ProxyDispatcherManager } from './dispatcher';
export type { ProxyTestResult } from './tester';
export { ProxyConnectionTester } from './tester';
export { ProxyUrlBuilder } from './urlBuilder';
export type { ProxyValidationResult } from './validator';
export { ProxyConfigValidator } from './validator';
@@ -0,0 +1,163 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { fetch, getGlobalDispatcher, setGlobalDispatcher } from 'undici';
import { createLogger } from '@/utils/logger';
import { ProxyDispatcherManager } from './dispatcher';
import { ProxyUrlBuilder } from './urlBuilder';
import { ProxyConfigValidator } from './validator';
// Create logger
const logger = createLogger('modules:networkProxy:tester');
/**
* 代理连接测试结果
*/
export interface ProxyTestResult {
message?: string;
responseTime?: number;
success: boolean;
}
/**
* 代理连接测试器
*/
export class ProxyConnectionTester {
private static readonly DEFAULT_TIMEOUT = 10_000; // 10秒超时
private static readonly DEFAULT_TEST_URL = 'https://www.google.com';
/**
* 测试代理连接
*/
static async testConnection(
url: string = this.DEFAULT_TEST_URL,
timeout: number = this.DEFAULT_TIMEOUT,
): Promise<ProxyTestResult> {
const startTime = Date.now();
try {
logger.info(`Testing proxy connection with URL: ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
headers: {
'User-Agent': 'LobeChat-Desktop/1.0.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseTime = Date.now() - startTime;
logger.info(`Proxy connection test successful, response time: ${responseTime}ms`);
return {
responseTime,
success: true,
};
} catch (error) {
const responseTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error(`Proxy connection test failed after ${responseTime}ms:`, errorMessage);
return {
message: errorMessage,
responseTime,
success: false,
};
}
}
/**
* 测试指定代理配置的连接
*/
static async testProxyConfig(
config: NetworkProxySettings,
testUrl: string = this.DEFAULT_TEST_URL,
): Promise<ProxyTestResult> {
// 验证配置
const validation = ProxyConfigValidator.validate(config);
if (!validation.isValid) {
return {
message: `Invalid proxy configuration: ${validation.errors.join(', ')}`,
success: false,
};
}
// 如果未启用代理,直接测试
if (!config.enableProxy) {
return this.testConnection(testUrl);
}
// 创建临时代理 agent 进行测试
try {
const proxyUrl = ProxyUrlBuilder.build(config);
logger.debug(`Testing proxy with URL: ${proxyUrl}`);
const agent = ProxyDispatcherManager.createProxyAgent(config.proxyType, proxyUrl);
const startTime = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.DEFAULT_TIMEOUT);
// 临时设置代理进行测试
const originalDispatcher = getGlobalDispatcher();
setGlobalDispatcher(agent);
try {
const response = await fetch(testUrl, {
dispatcher: agent,
headers: {
'User-Agent': 'LobeChat-Desktop/1.0.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseTime = Date.now() - startTime;
logger.info(`Proxy test successful, response time: ${responseTime}ms`);
return {
responseTime,
success: true,
};
} catch (fetchError) {
clearTimeout(timeoutId);
throw fetchError;
} finally {
// 恢复原来的 dispatcher
setGlobalDispatcher(originalDispatcher);
// 清理临时创建的代理 agent
if (agent && typeof agent.destroy === 'function') {
try {
await agent.destroy();
} catch (error) {
logger.warn('Failed to destroy test agent:', error);
}
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error(`Proxy test failed: ${errorMessage}`, error);
return {
message: `Proxy test failed: ${errorMessage}`,
success: false,
};
}
}
}
@@ -0,0 +1,25 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
/**
* 代理 URL 构建器
*/
export const ProxyUrlBuilder = {
/**
* 构建代理 URL
*/
build(config: NetworkProxySettings): string {
const { proxyType, proxyServer, proxyPort, proxyRequireAuth, proxyUsername, proxyPassword } =
config;
let proxyUrl = `${proxyType}://${proxyServer}:${proxyPort}`;
// 添加认证信息
if (proxyRequireAuth && proxyUsername && proxyPassword) {
const encodedUsername = encodeURIComponent(proxyUsername);
const encodedPassword = encodeURIComponent(proxyPassword);
proxyUrl = `${proxyType}://${encodedUsername}:${encodedPassword}@${proxyServer}:${proxyPort}`;
}
return proxyUrl;
},
};
@@ -0,0 +1,80 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
/**
* 代理配置验证结果
*/
export interface ProxyValidationResult {
errors: string[];
isValid: boolean;
}
/**
* 代理配置验证器
*/
export class ProxyConfigValidator {
private static readonly SUPPORTED_TYPES = ['http', 'https', 'socks5'] as const;
private static readonly DEFAULT_BYPASS = 'localhost,127.0.0.1,::1';
/**
* 验证代理配置
*/
static validate(config: NetworkProxySettings): ProxyValidationResult {
const errors: string[] = [];
// 如果未启用代理,跳过验证
if (!config.enableProxy) {
return { errors: [], isValid: true };
}
// 验证代理类型
if (!this.SUPPORTED_TYPES.includes(config.proxyType as any)) {
errors.push(
`Unsupported proxy type: ${config.proxyType}. Supported types: ${this.SUPPORTED_TYPES.join(', ')}`,
);
}
// 验证代理服务器
if (!config.proxyServer?.trim()) {
errors.push('Proxy server is required when proxy is enabled');
} else if (!this.isValidHost(config.proxyServer)) {
errors.push('Invalid proxy server format');
}
// 验证代理端口
if (!config.proxyPort?.trim()) {
errors.push('Proxy port is required when proxy is enabled');
} else {
const port = parseInt(config.proxyPort, 10);
if (isNaN(port) || port < 1 || port > 65_535) {
errors.push('Proxy port must be a valid number between 1 and 65535');
}
}
// 验证认证信息
if (config.proxyRequireAuth) {
if (!config.proxyUsername?.trim()) {
errors.push('Proxy username is required when authentication is enabled');
}
if (!config.proxyPassword?.trim()) {
errors.push('Proxy password is required when authentication is enabled');
}
}
return {
errors,
isValid: errors.length === 0,
};
}
/**
* 验证主机名格式
*/
private static isValidHost(host: string): boolean {
// 简单的主机名验证(IP 地址或域名)
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
const domainRegex =
/^[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?(\.[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?)*$/;
return ipRegex.test(host) || domainRegex.test(host);
}
}
+230 -43
View File
@@ -1,15 +1,28 @@
import { DeleteFilesResponse } from '@lobechat/electron-server-ipc';
import * as fs from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import path, { join } from 'node:path';
import { promisify } from 'node:util';
import { FILE_STORAGE_DIR } from '@/const/dir';
import { FILE_STORAGE_DIR, LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import { ServiceModule } from './index';
/**
* 文件未找到错误类
*/
export class FileNotFoundError extends Error {
constructor(
message: string,
public path: string,
) {
super(message);
this.name = 'FileNotFoundError';
}
}
const readFilePromise = promisify(fs.readFile);
const unlinkPromise = promisify(fs.unlink);
@@ -17,7 +30,7 @@ const unlinkPromise = promisify(fs.unlink);
const logger = createLogger('services:FileService');
interface UploadFileParams {
content: ArrayBuffer;
content: ArrayBuffer | string; // ArrayBuffer from browser or Base64 string from server
filename: string;
hash: string;
path: string;
@@ -32,17 +45,16 @@ interface FileMetadata {
}
export default class FileService extends ServiceModule {
/**
* 获取旧版上传目录路径
* @deprecated 仅用于向后兼容旧版文件访问,新文件应存储在 FILE_STORAGE_DIR 的自定义路径下
*/
get UPLOADS_DIR() {
return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads');
}
constructor(app) {
super(app);
// Initialize file storage directory
logger.info('Initializing file storage directory');
makeSureDirExist(this.UPLOADS_DIR);
logger.debug(`Upload directory created: ${this.UPLOADS_DIR}`);
}
/**
@@ -52,31 +64,44 @@ export default class FileService extends ServiceModule {
content,
filename,
hash,
path: filePath,
type,
}: UploadFileParams): Promise<{ metadata: FileMetadata; success: boolean }> {
logger.info(`Starting to upload file: ${filename}, hash: ${hash}`);
logger.info(`Starting to upload file: ${filename}, hash: ${hash}, path: ${filePath}`);
try {
// 创建时间戳目录
const date = (Date.now() / 1000 / 60 / 60).toFixed(0);
const dirname = join(this.UPLOADS_DIR, date);
logger.debug(`Creating timestamp directory: ${dirname}`);
makeSureDirExist(dirname);
// 获取当前时间戳,避免重复调用 Date.now()
const now = Date.now();
const date = (now / 1000 / 60 / 60).toFixed(0);
// 生成文件保存路径
const fileExt = filename.split('.').pop() || '';
const savedFilename = `${hash}${fileExt ? `.${fileExt}` : ''}`;
const savedPath = join(dirname, savedFilename);
logger.debug(`Generated file save path: ${savedPath}`);
// 使用传入的 filePath 作为文件的存储路径
const fullStoragePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, filePath);
logger.debug(`Target file storage path: ${fullStoragePath}`);
// 写入文件内容
const buffer = Buffer.from(content);
logger.debug(`Writing file content, size: ${buffer.length} bytes`);
// 确保目标目录存在
const targetDir = path.dirname(fullStoragePath);
logger.debug(`Ensuring target directory exists: ${targetDir}`);
makeSureDirExist(targetDir);
const savedPath = fullStoragePath;
logger.debug(`Final file save path: ${savedPath}`);
// 根据 content 类型创建 Buffer
let buffer: Buffer;
if (typeof content === 'string') {
// 来自服务端的 Base64 字符串
buffer = Buffer.from(content, 'base64');
logger.debug(`Creating buffer from Base64 string, size: ${buffer.length} bytes`);
} else {
// 来自浏览器端的 ArrayBuffer
buffer = Buffer.from(content);
logger.debug(`Creating buffer from ArrayBuffer, size: ${buffer.length} bytes`);
}
await writeFile(savedPath, buffer);
// 写入元数据文件
const metaFilePath = `${savedPath}.meta`;
const metadata = {
createdAt: Date.now(),
createdAt: now, // 使用统一的时间戳
filename,
hash,
size: buffer.length,
@@ -86,13 +111,18 @@ export default class FileService extends ServiceModule {
await writeFile(metaFilePath, JSON.stringify(metadata, null, 2));
// 返回与S3兼容的元数据格式
const desktopPath = `desktop://${date}/${savedFilename}`;
const desktopPath = `desktop://${filePath}`;
logger.info(`File upload successful: ${desktopPath}`);
// 从路径中提取文件名和目录信息
const parsedPath = path.parse(filePath);
const dirname = parsedPath.dir || '';
const savedFilename = parsedPath.base;
return {
metadata: {
date,
dirname: date,
date, // 保持时间戳格式,用于兼容性和时间追踪
dirname,
filename: savedFilename,
path: desktopPath,
},
@@ -104,6 +134,24 @@ export default class FileService extends ServiceModule {
}
}
/**
* 判断路径是否为旧版格式(时间戳目录)
*
* 旧版路径格式: {timestamp}/{hash}.{ext} (例如: 1234567890/abc123.png)
* 新版路径格式: 任意自定义路径 (例如: user_uploads/images/photo.png, ai_generations/image.jpg)
*
* @param path - 相对路径,不包含 desktop:// 前缀
* @returns true 如果是旧版格式,false 如果是新版格式
*/
private isLegacyPath(path: string): boolean {
const parts = path.split('/');
if (parts.length < 2) return false;
// 如果第一部分是纯数字(时间戳),则认为是旧版格式
// 时间戳格式:精确到小时的 Unix 时间戳,通常是 10 位数字
return /^\d+$/.test(parts[0]);
}
/**
* 获取文件内容
*/
@@ -123,13 +171,49 @@ export default class FileService extends ServiceModule {
// 解析路径
const relativePath = normalizedPath.replace('desktop://', '');
const filePath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Reading file from path: ${filePath}`);
// 读取文件内容
// 智能路由:根据路径格式决定从哪个目录读取文件
let filePath: string;
let isLegacyAttempt = false;
if (this.isLegacyPath(relativePath)) {
// 旧版路径:从 uploads 目录读取(向后兼容)
filePath = join(this.UPLOADS_DIR, relativePath);
isLegacyAttempt = true;
logger.debug(`Legacy path detected, reading from uploads directory: ${filePath}`);
} else {
// 新版路径:从 FILE_STORAGE_DIR 根目录读取
filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, reading from storage root: ${filePath}`);
}
// 读取文件内容,如果第一次尝试失败且是 legacy 路径,则尝试新路径
logger.debug(`Starting to read file content`);
const content = await readFilePromise(filePath);
logger.debug(`File content read complete, size: ${content.length} bytes`);
let content: Buffer;
try {
content = await readFilePromise(filePath);
logger.debug(`File content read complete, size: ${content.length} bytes`);
} catch (firstError) {
if (isLegacyAttempt) {
// 如果是 legacy 路径读取失败,尝试从新路径读取
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(
`Legacy path read failed, attempting fallback to storage root: ${fallbackPath}`,
);
try {
content = await readFilePromise(fallbackPath);
filePath = fallbackPath; // 更新 filePath 用于后续的元数据读取
logger.debug(`Fallback read successful, size: ${content.length} bytes`);
} catch (fallbackError) {
logger.error(
`Both legacy and fallback paths failed. Legacy error: ${(firstError as Error).message}, Fallback error: ${(fallbackError as Error).message}`,
);
throw firstError; // 抛出原始错误
}
} else {
throw firstError;
}
}
// 读取元数据获取MIME类型
const metaFilePath = `${filePath}.meta`;
@@ -142,7 +226,9 @@ export default class FileService extends ServiceModule {
mimeType = metadata.type || mimeType;
logger.debug(`Got MIME type from metadata: ${mimeType}`);
} catch (metaError) {
logger.warn(`Failed to read metadata file: ${(metaError as Error).message}, using default MIME type`);
logger.warn(
`Failed to read metadata file: ${(metaError as Error).message}, using default MIME type`,
);
// 如果元数据文件不存在,尝试从文件扩展名猜测MIME类型
const ext = path.split('.').pop()?.toLowerCase();
if (ext) {
@@ -184,6 +270,12 @@ export default class FileService extends ServiceModule {
};
} catch (error) {
logger.error(`File retrieval failed:`, error);
// 如果是文件不存在错误,抛出自定义的 FileNotFoundError
if (error instanceof Error && error.message.includes('ENOENT')) {
throw new FileNotFoundError(`File not found: ${path}`, path);
}
throw new Error(`File retrieval failed: ${(error as Error).message}`);
}
}
@@ -200,15 +292,53 @@ export default class FileService extends ServiceModule {
throw new Error(`Invalid desktop file path: ${path}`);
}
// 解析路径
const relativePath = path.replace('desktop://', '');
const filePath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`File deletion path: ${filePath}`);
// 标准化路径格式
const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
// 删除文件及其元数据
// 解析路径
const relativePath = normalizedPath.replace('desktop://', '');
// 智能路由:根据路径格式决定从哪个目录删除文件
let filePath: string;
let isLegacyAttempt = false;
if (this.isLegacyPath(relativePath)) {
// 旧版路径:从 uploads 目录删除(向后兼容)
filePath = join(this.UPLOADS_DIR, relativePath);
isLegacyAttempt = true;
logger.debug(`Legacy path detected, deleting from uploads directory: ${filePath}`);
} else {
// 新版路径:从 FILE_STORAGE_DIR 根目录删除
filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, deleting from storage root: ${filePath}`);
}
// 删除文件及其元数据,如果第一次尝试失败且是 legacy 路径,则尝试新路径
logger.debug(`Starting file deletion`);
await unlinkPromise(filePath);
logger.debug(`File deletion successful`);
try {
await unlinkPromise(filePath);
logger.debug(`File deletion successful`);
} catch (firstError) {
if (isLegacyAttempt) {
// 如果是 legacy 路径删除失败,尝试从新路径删除
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(
`Legacy path deletion failed, attempting fallback to storage root: ${fallbackPath}`,
);
try {
await unlinkPromise(fallbackPath);
filePath = fallbackPath; // 更新 filePath 用于后续的元数据删除
logger.debug(`Fallback deletion successful`);
} catch (fallbackError) {
logger.error(
`Both legacy and fallback deletion failed. Legacy error: ${(firstError as Error).message}, Fallback error: ${(fallbackError as Error).message}`,
);
throw firstError; // 抛出原始错误
}
} else {
throw firstError;
}
}
// 尝试删除元数据文件,但不强制要求存在
try {
@@ -270,7 +400,9 @@ export default class FileService extends ServiceModule {
});
const success = errors.length === 0;
logger.info(`Batch deletion operation complete, success: ${success}, error count: ${errors.length}`);
logger.info(
`Batch deletion operation complete, success: ${success}, error count: ${errors.length}`,
);
return {
success,
...(errors.length > 0 && { errors }),
@@ -285,10 +417,65 @@ export default class FileService extends ServiceModule {
throw new Error(`Invalid desktop file path: ${path}`);
}
// 标准化路径格式
const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
// 解析路径
const relativePath = path.replace('desktop://', '');
const fullPath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Resolved filesystem path: ${fullPath}`);
const relativePath = normalizedPath.replace('desktop://', '');
// 智能路由:根据路径格式决定从哪个目录获取文件路径
let fullPath: string;
if (this.isLegacyPath(relativePath)) {
// 旧版路径:从 uploads 目录获取(向后兼容)
fullPath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Legacy path detected, resolved to uploads directory: ${fullPath}`);
// 检查文件是否存在,如果不存在则尝试新路径
try {
await fs.promises.access(fullPath, fs.constants.F_OK);
logger.debug(`Legacy path file exists: ${fullPath}`);
} catch {
// 如果 legacy 路径文件不存在,尝试新路径
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`Legacy path file not found, trying fallback path: ${fallbackPath}`);
try {
await fs.promises.access(fallbackPath, fs.constants.F_OK);
fullPath = fallbackPath;
logger.debug(`Fallback path file exists: ${fullPath}`);
} catch {
// 两个路径都不存在,返回原始的 legacy 路径(保持原有行为)
logger.debug(
`Neither legacy nor fallback path exists, returning legacy path: ${fullPath}`,
);
}
}
} else {
// 新版路径:从 FILE_STORAGE_DIR 根目录获取
fullPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, resolved to storage root: ${fullPath}`);
}
return fullPath;
}
async getFileHTTPURL(path: string): Promise<string> {
logger.debug(`Getting file HTTP URL: ${path}`);
// 处理desktop://路径
if (!path.startsWith('desktop://')) {
logger.error(`Invalid desktop file path: ${path}`);
throw new Error(`Invalid desktop file path: ${path}`);
}
// 标准化路径格式
const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
// 解析路径:从 desktop://path/to/file.png 中提取 path/to/file.png
const relativePath = normalizedPath.replace('desktop://', '');
// 使用 StaticFileServerManager 获取文件服务器域名,然后构建完整 URL
const serverDomain = this.app.staticFileServerManager.getFileServerDomain();
const httpURL = `${serverDomain}${LOCAL_STORAGE_URL_PREFIX}/${relativePath}`;
logger.debug(`Generated HTTP URL: ${httpURL}`);
return httpURL;
}
}
+4 -2
View File
@@ -2,10 +2,11 @@
* 快捷键操作类型枚举
*/
export const ShortcutActionEnum = {
openSettings: 'openSettings',
/**
* 显示/隐藏主窗口
*/
toggleMainWindow: 'toggleMainWindow',
showApp: 'showApp',
} as const;
export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof ShortcutActionEnum];
@@ -14,5 +15,6 @@ export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof Shortc
* 默认快捷键配置
*/
export const DEFAULT_SHORTCUTS_CONFIG: Record<ShortcutActionType, string> = {
[ShortcutActionEnum.toggleMainWindow]: 'CommandOrControl+E',
[ShortcutActionEnum.showApp]: 'Control+E',
[ShortcutActionEnum.openSettings]: 'CommandOrControl+,',
};
+4 -1
View File
@@ -1,14 +1,17 @@
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
import { DataSyncConfig, NetworkProxySettings } from '@lobechat/electron-client-ipc';
export interface ElectronMainStore {
dataSyncConfig: DataSyncConfig;
encryptedTokens: {
accessToken?: string;
expiresAt?: number;
refreshToken?: string;
};
locale: string;
networkProxy: NetworkProxySettings;
shortcuts: Record<string, string>;
storagePath: string;
themeMode: 'dark' | 'light' | 'auto';
}
export type StoreKey = keyof ElectronMainStore;
+2 -1
View File
@@ -19,8 +19,9 @@ export const createLogger = (namespace: string) => {
error: (message, ...args) => {
if (process.env.NODE_ENV === 'production') {
electronLog.error(message, ...args);
} else {
console.error(message, ...args);
}
debugLogger(`ERROR: ${message}`, ...args);
},
info: (message, ...args) => {
if (process.env.NODE_ENV === 'production') {
@@ -1,6 +1,6 @@
// copy from https://github.com/kirill-konshin/next-electron-rsc
import { serialize as serializeCookie } from 'cookie';
import type { Protocol, Session } from 'electron';
import { type Protocol, type Session, protocol } from 'electron';
import type { NextConfig } from 'next';
import type NextNodeServer from 'next/dist/server/next-server';
import assert from 'node:assert';
@@ -11,6 +11,7 @@ import { parse } from 'node:url';
import resolve from 'resolve';
import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import { isDev } from '@/const/env';
import { createLogger } from '@/utils/logger';
@@ -178,7 +179,9 @@ export function createHandler({
}
};
}
let registerProtocolHandle = false;
let interceptorCount = 0; // 追踪活跃的拦截器数量
protocol.registerSchemesAsPrivileged([
{
@@ -224,6 +227,14 @@ export function createHandler({
socket: Socket,
): Promise<Response> => {
try {
// 检查是否是本地文件服务请求,如果是则跳过处理
const url = new URL(request.url);
if (url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/')) {
if (debug) logger.debug(`Skipping local file service request: ${request.url}`);
// 直接使用 fetch 转发请求到本地文件服务
return fetch(request);
}
// 先尝试使用自定义处理器处理请求
for (const customHandler of customHandlers) {
try {
@@ -354,19 +365,32 @@ export function createHandler({
);
const socket = new Socket();
interceptorCount++; // 增加拦截器计数
const closeSocket = () => socket.end();
process.on('SIGTERM', () => closeSocket);
process.on('SIGINT', () => closeSocket);
if (!isDev && !registerProtocolHandle) {
if (!registerProtocolHandle) {
logger.debug(
`Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`,
);
protocol.handle('http', async (request) => {
if (!isDev) {
assert(request.url.startsWith(localhostUrl), 'External HTTP not supported, use HTTPS');
// 检查是否是本地文件服务请求,如果是则允许通过
const isLocalhost = request.url.startsWith(localhostUrl);
const url = new URL(request.url);
const isLocalIP =
request.url.startsWith('http://127.0.0.1:') ||
request.url.startsWith('http://localhost:');
const isLocalFileService = url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/');
const valid = isLocalhost || (isLocalIP && isLocalFileService);
if (!valid) {
throw new Error('External HTTP not supported, use HTTPS');
}
}
return handleRequest(request, session, socket);
@@ -374,12 +398,19 @@ export function createHandler({
registerProtocolHandle = true;
}
logger.debug(`Active interceptors count: ${interceptorCount}`);
return function stopIntercept() {
if (registerProtocolHandle) {
logger.debug('Unregistering HTTP protocol handler');
interceptorCount--; // 减少拦截器计数
logger.debug(`Stopping interceptor, remaining count: ${interceptorCount}`);
// 只有当没有活跃的拦截器时才取消注册协议处理器
if (registerProtocolHandle && interceptorCount === 0) {
logger.debug('Unregistering HTTP protocol handler (no active interceptors)');
protocol.unhandle('http');
registerProtocolHandle = false;
}
process.off('SIGTERM', () => closeSocket);
process.off('SIGINT', () => closeSocket);
closeSocket();
+2 -1
View File
@@ -2,6 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge } from 'electron';
import { invoke } from './invoke';
import { onStreamInvoke } from './streamer';
export const setupElectronApi = () => {
// Use `contextBridge` APIs to expose Electron APIs to
@@ -14,5 +15,5 @@ export const setupElectronApi = () => {
console.error(error);
}
contextBridge.exposeInMainWorld('electronAPI', { invoke });
contextBridge.exposeInMainWorld('electronAPI', { invoke, onStreamInvoke });
};
+58
View File
@@ -0,0 +1,58 @@
import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
import { ipcRenderer } from 'electron';
import { v4 as uuid } from 'uuid';
interface StreamResponse {
headers: Record<string, string>;
status: number;
statusText: string;
}
export interface StreamerCallbacks {
onData: (chunk: Uint8Array) => void;
onEnd: () => void;
onError: (error: Error) => void;
onResponse: (response: StreamResponse) => void;
}
/**
* Calls the main process method and handles the stream response via callbacks.
* @param params The request parameters.
* @param callbacks The callbacks to handle stream events.
*/
export const onStreamInvoke = (
params: ProxyTRPCRequestParams,
callbacks: StreamerCallbacks,
): (() => void) => {
const requestId = uuid();
const cleanup = () => {
ipcRenderer.removeAllListeners(`stream:data:${requestId}`);
ipcRenderer.removeAllListeners(`stream:end:${requestId}`);
ipcRenderer.removeAllListeners(`stream:error:${requestId}`);
ipcRenderer.removeAllListeners(`stream:response:${requestId}`);
};
ipcRenderer.on(`stream:data:${requestId}`, (_, chunk: Buffer) => {
callbacks.onData(new Uint8Array(chunk));
});
ipcRenderer.once(`stream:end:${requestId}`, () => {
callbacks.onEnd();
cleanup();
});
ipcRenderer.once(`stream:error:${requestId}`, (_, error: Error) => {
callbacks.onError(error);
cleanup();
});
ipcRenderer.once(`stream:response:${requestId}`, (_, response: StreamResponse) => {
callbacks.onResponse(response);
});
ipcRenderer.send('stream:start', { ...params, requestId });
// Return a cleanup function to be called on cancellation
return cleanup;
};
+285
View File
@@ -1,4 +1,289 @@
[
{
"children": {
"improvements": ["Support more Text2Image from Qwen."]
},
"date": "2025-07-29",
"version": "1.105.1"
},
{
"children": {
"features": ["Implement API Key management functionality."]
},
"date": "2025-07-28",
"version": "1.105.0"
},
{
"children": {
"improvements": ["Fix setting window layout when in desktop was disappear."]
},
"date": "2025-07-28",
"version": "1.104.5"
},
{
"children": {
"improvements": ["Fix setting window layout size, update i18n."]
},
"date": "2025-07-28",
"version": "1.104.4"
},
{
"children": {
"improvements": ["Add Gemini 2.5 Flash-Lite GA model."]
},
"date": "2025-07-26",
"version": "1.104.3"
},
{
"children": {
"fixes": ["Fix update hotkey invalid when input mod in desktop."]
},
"date": "2025-07-26",
"version": "1.104.2"
},
{
"children": {
"fixes": [
"Update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider."
]
},
"date": "2025-07-25",
"version": "1.104.1"
},
{
"children": {
"features": ["Support custom hotkey on desktop."]
},
"date": "2025-07-24",
"version": "1.104.0"
},
{
"children": {
"fixes": ["Fix chat stream in desktop and update shortcut."],
"improvements": [
"Add cached token count to usage of GoogleAI and VertexAI, fix desktop titlebar style in window, fix sub topic width in md responsive."
]
},
"date": "2025-07-24",
"version": "1.103.2"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-23",
"version": "1.103.1"
},
{
"children": {
"features": ["Add Qwen image generation capabilities."]
},
"date": "2025-07-22",
"version": "1.103.0"
},
{
"children": {
"improvements": ["Update tray icon."]
},
"date": "2025-07-22",
"version": "1.102.4"
},
{
"children": {
"fixes": ["Remove debug logging from ModelRuntime and async caller."]
},
"date": "2025-07-22",
"version": "1.102.3"
},
{
"children": {
"improvements": ["Add notification for desktop."]
},
"date": "2025-07-22",
"version": "1.102.2"
},
{
"children": {
"improvements": ["Modal list header sticky style."]
},
"date": "2025-07-21",
"version": "1.102.1"
},
{
"children": {
"features": ["Add image generation capabilities using Google AI Imagen API."]
},
"date": "2025-07-21",
"version": "1.102.0"
},
{
"children": {
"improvements": ["Fix lobehub provider /chat in desktop."]
},
"date": "2025-07-21",
"version": "1.101.2"
},
{
"children": {
"fixes": ["Try fix authorization code exchange & pin next-auto to beta.29."]
},
"date": "2025-07-19",
"version": "1.101.1"
},
{
"children": {
"features": ["Add zhipu cogview4."],
"fixes": ["Some ai image bugs."]
},
"date": "2025-07-19",
"version": "1.101.0"
},
{
"children": {
"fixes": ["Fix webapi proxy with clerk."]
},
"date": "2025-07-18",
"version": "1.100.2"
},
{
"children": {
"fixes": ["Use server env config image models."]
},
"date": "2025-07-17",
"version": "1.100.1"
},
{
"children": {
"features": ["Refactor desktop oauth and use JWTs token to support remote chat."]
},
"date": "2025-07-17",
"version": "1.100.0"
},
{
"children": {
"fixes": ["Desktop local db can't upload image."]
},
"date": "2025-07-16",
"version": "1.99.6"
},
{
"children": {
"fixes": ["Fix page error when url is not defined in web search plugin."]
},
"date": "2025-07-16",
"version": "1.99.5"
},
{
"children": {
"fixes": ["Fix apikey issue on server log."]
},
"date": "2025-07-16",
"version": "1.99.4"
},
{
"children": {
"fixes": ["Chat model list should not show image model."]
},
"date": "2025-07-16",
"version": "1.99.3"
},
{
"children": {
"fixes": ["Some ai image generation feedback issues."]
},
"date": "2025-07-15",
"version": "1.99.2"
},
{
"children": {},
"date": "2025-07-15",
"version": "1.99.1"
},
{
"children": {
"features": ["support AI Image."]
},
"date": "2025-07-14",
"version": "1.99.0"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-14",
"version": "1.98.2"
},
{
"children": {
"improvements": ["Fix discover translation."]
},
"date": "2025-07-14",
"version": "1.98.1"
},
{
"children": {
"features": ["Add network proxy for desktop."]
},
"date": "2025-07-13",
"version": "1.98.0"
},
{
"children": {
"improvements": ["Support Hunyuan A13B thinking model."]
},
"date": "2025-07-13",
"version": "1.97.17"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-13",
"version": "1.97.16"
},
{
"children": {
"fixes": ["Add vision support to Grok 4."]
},
"date": "2025-07-12",
"version": "1.97.15"
},
{
"children": {
"fixes": ["Revert \"💄 style: Open new topic by tap Just Chat again\"."],
"improvements": ["Add Kimi K2 model."]
},
"date": "2025-07-12",
"version": "1.97.14"
},
{
"children": {
"improvements": ["Support new Doubao thinking models, update i18n."]
},
"date": "2025-07-12",
"version": "1.97.13"
},
{
"children": {
"fixes": ["Grok-4 reasoning model universal matching."]
},
"date": "2025-07-11",
"version": "1.97.12"
},
{
"children": {
"improvements": ["Open new topic by tap Just Chat again."]
},
"date": "2025-07-11",
"version": "1.97.11"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-11",
"version": "1.97.10"
},
{
"children": {
"improvements": ["Integrate Amazon Cognito for user authentication."]
@@ -0,0 +1,162 @@
# Adding New Image Models
> Learn more about the AI image generation modal design in the [AI Image Generation Modal Design Discussion](https://github.com/lobehub/lobe-chat/discussions/7442)
## Parameter Standardization
All image generation models must use the standard parameters defined in `src/libs/standard-parameters/index.ts`. This ensures parameter consistency across different Providers, creating a more unified user experience.
**Supported Standard Parameters**:
- `prompt` (required): Text prompt for image generation
- `aspectRatio`: Aspect ratio (e.g., "16:9", "1:1")
- `width` / `height`: Image dimensions
- `size`: Preset dimensions (e.g., "1024x1024")
- `seed`: Random seed
- `steps`: Generation steps
- `cfg`: Guidance scale
- For other parameters, please check the source file
## OpenAI Compatible Models
These models can be requested using the OpenAI SDK, with request parameters and return values consistent with DALL-E and GPT-Image-X series.
Taking Zhipu's CogView-4 as an example, which is an OpenAI-compatible model, you can add it by adding the model configuration in the corresponding AI models file `src/config/aiModels/zhipu.ts`:
```ts
const zhipuImageModels: AIImageModelCard[] = [
// Add model configuration
// https://bigmodel.cn/dev/howuse/image-generation-model/cogview-4
{
description:
'CogView-4 is the first open-source text-to-image model from Zhipu that supports Chinese character generation, with comprehensive improvements in semantic understanding, image generation quality, and Chinese-English text generation capabilities.',
displayName: 'CogView-4',
enabled: true,
id: 'cogview-4',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024', '768x1344', '864x1152', '1344x768', '1152x864', '1440x720', '720x1440'],
},
},
releasedAt: '2025-03-04',
type: 'image',
},
];
```
## Non-OpenAI Compatible Models
For image generation models that are not compatible with OpenAI format, you need to implement a custom `createImage` method. There are two main implementation approaches:
### Method 1: Using OpenAI Compatible Factory
Most Providers use `openaiCompatibleFactory` for OpenAI compatibility. You can pass in a custom `createImage` function (reference [PR #8534](https://github.com/lobehub/lobe-chat/pull/8534)).
**Implementation Steps**:
1. **Read Provider documentation and standard parameter definitions**
- Review the Provider's image generation API documentation to understand request and response formats
- Read `src/libs/standard-parameters/index.ts` to understand supported parameters
- Add image model configuration in the corresponding AI models file
2. **Implement custom createImage method**
- Create a standalone image generation function that accepts standard parameters
- Convert standard parameters to Provider-specific format
- Call the Provider's image generation API
- Return a unified response format (imageUrl and optional width/height)
3. **Add tests**
- Write unit tests covering success scenarios
- Test various error cases and edge conditions
**Code Example**:
```ts
// src/libs/model-runtime/provider-name/createImage.ts
export const createProviderImage = async (
payload: ImageGenerationPayload,
options: any,
): Promise<ImageGenerationResponse> => {
const { model, prompt, ...params } = payload;
// Call Provider's native API
const result = await callProviderAPI({
model,
prompt,
// Convert parameter format
custom_param: params.width,
// ...
});
// Return unified format
return {
created: Date.now(),
data: [{ url: result.imageUrl }],
};
};
```
```ts
// src/libs/model-runtime/provider-name/index.ts
export const LobeProviderAI = openaiCompatibleFactory({
constructorOptions: {
// ... other configurations
},
createImage: createProviderImage, // Pass custom implementation
provider: ModelProvider.ProviderName,
});
```
### Method 2: Direct Implementation in Provider Class
If your Provider has an independent class implementation, you can directly add the `createImage` method in the class (reference [PR #8503](https://github.com/lobehub/lobe-chat/pull/8503)).
**Implementation Steps**:
1. **Read Provider documentation and standard parameter definitions**
- Review the Provider's image generation API documentation
- Read `src/libs/standard-parameters/index.ts`
- Add image model configuration in the corresponding AI models file
2. **Implement createImage method in Provider class**
- Add the `createImage` method directly in the class
- Handle parameter conversion and API calls
- Return a unified response format
3. **Add tests**
- Write comprehensive test cases for the new method
**Code Example**:
```ts
// src/libs/model-runtime/provider-name/index.ts
export class LobeProviderAI {
async createImage(
payload: ImageGenerationPayload,
options?: ChatStreamCallbacks,
): Promise<ImageGenerationResponse> {
const { model, prompt, ...params } = payload;
// Call native API and handle response
const result = await this.client.generateImage({
model,
prompt,
// Parameter conversion
});
return {
created: Date.now(),
data: [{ url: result.url }],
};
}
}
```
### Important Notes
- **Testing Requirements**: Add comprehensive unit tests for custom implementations, ensuring coverage of success scenarios and various error cases
- **Error Handling**: Use `AgentRuntimeError` consistently for error wrapping to maintain error message consistency
@@ -0,0 +1,162 @@
# 添加新的图像模型
> 了解更多关于 AI 绘画模态的设计,请参考 [AI 绘画模态设计讨论](https://github.com/lobehub/lobe-chat/discussions/7442)
## 参数标准化
所有图像生成模型都必须使用 `src/libs/standard-parameters/index.ts` 中定义的标准参数。这确保了不同 Provider 之间的参数一致性,让用户体验更加统一。
**支持的标准参数**
- `prompt` (必需):生成图像的提示词
- `aspectRatio`:宽高比(如 "16:9", "1:1"
- `width` / `height`:图像宽高
- `size`:预设尺寸(如 "1024x1024"
- `seed`:随机种子
- `steps`:生成步数
- `cfg`:引导缩放
- 其他参数请查看源文件
## 兼容 OpenAI 请求格式的模型
指的是可以使用 openai SDK 进行请求,并且请求参数和和返回值和 dall-e 以及 gpt-image-x 系列一致。
以智谱的 CogView-4 为例,它是一个兼容 openai 请求格式的模型。你只需要在对应的 ai models 文件 `src/config/aiModels/zhipu.ts` 中,添加模型配置,例如:
```ts
const zhipuImageModels: AIImageModelCard[] = [
// 添加模型配置
// https://bigmodel.cn/dev/howuse/image-generation-model/cogview-4
{
description:
'CogView-4 是智谱首个支持生成汉字的开源文生图模型,在语义理解、图像生成质量、中英文字生成能力等方面全面提升,支持任意长度的中英双语输入,能够生成在给定范围内的任意分辨率图像。',
displayName: 'CogView-4',
enabled: true,
id: 'cogview-4',
parameters: {
prompt: {
default: '',
},
size: {
default: '1024x1024',
enum: ['1024x1024', '768x1344', '864x1152', '1344x768', '1152x864', '1440x720', '720x1440'],
},
},
releasedAt: '2025-03-04',
type: 'image',
},
];
```
## 不兼容 OpenAI 请求格式的模型
对于不兼容 OpenAI 格式的图像生成模型,需要实现自定义的 `createImage` 方法。有两种主要实现方式:
### 方式一:使用 OpenAI Compatible Factory
大部分 Provider 都使用 `openaiCompatibleFactory` 来兼容 OpenAI,可以通过传入自定义的 `createImage` 函数(参考 [PR #8534](https://github.com/lobehub/lobe-chat/pull/8534))。
**实现步骤**
1. **阅读 Provider 官方文档和标准参数定义**
- 查看 Provider 的图像生成 API 文档,了解请求格式和响应格式
- 阅读 `src/libs/standard-parameters/index.ts`,了解支持的参数
- 在对应的 ai models 文件中增加 image model 配置
2. **实现自定义的 createImage 方法**
- 创建独立的图像生成函数,接受标准生图参数
- 将标准参数转换为 Provider 特定的格式
- 调用 Provider 的生图接口
- 返回统一格式的响应(imageUrl 和可选的宽高)
3. **补充测试**
- 编写单元测试覆盖成功场景
- 测试各种错误情况和边界条件
**代码示例**
```ts
// src/libs/model-runtime/provider-name/createImage.ts
export const createProviderImage = async (
payload: ImageGenerationPayload,
options: any,
): Promise<ImageGenerationResponse> => {
const { model, prompt, ...params } = payload;
// 调用 Provider 的原生 API
const result = await callProviderAPI({
model,
prompt,
// 转换参数格式
custom_param: params.width,
// ...
});
// 返回统一格式
return {
created: Date.now(),
data: [{ url: result.imageUrl }],
};
};
```
```ts
// src/libs/model-runtime/provider-name/index.ts
export const LobeProviderAI = openaiCompatibleFactory({
constructorOptions: {
// ... 其他配置
},
createImage: createProviderImage, // 传入自定义实现
provider: ModelProvider.ProviderName,
});
```
### 方式二:在 Provider 类中直接实现
如果你的 Provider 有独立的类实现,可以直接在类中添加 `createImage` 方法(参考 [PR #8503](https://github.com/lobehub/lobe-chat/pull/8503))。
**实现步骤**
1. **阅读 Provider 官方文档和标准参数定义**
- 查看 Provider 的图像生成 API 文档
- 阅读 `src/libs/standard-parameters/index.ts`
- 在对应的 ai models 文件中增加 image model 配置
2. **在 Provider 类中实现 createImage 方法**
- 直接在类中添加 `createImage` 方法
- 处理参数转换和 API 调用
- 返回统一格式的响应
3. **补充测试**
- 为新方法编写完整的测试用例
**代码示例**
```ts
// src/libs/model-runtime/provider-name/index.ts
export class LobeProviderAI {
async createImage(
payload: ImageGenerationPayload,
options?: ChatStreamCallbacks,
): Promise<ImageGenerationResponse> {
const { model, prompt, ...params } = payload;
// 调用原生 API 并处理响应
const result = await this.client.generateImage({
model,
prompt,
// 参数转换
});
return {
created: Date.now(),
data: [{ url: result.url }],
};
}
}
```
### 重要注意事项
- **测试要求**:为自定义实现添加完整的单元测试,确保覆盖成功场景和各种错误情况
- **错误处理**:统一使用 `AgentRuntimeError` 进行错误封装,保持错误信息的一致性
+79
View File
@@ -163,6 +163,7 @@ table files {
name text [not null]
size integer [not null]
url text [not null]
source text
client_id text
metadata jsonb
chunk_task_id uuid
@@ -218,6 +219,45 @@ table knowledge_bases {
}
}
table generation_batches {
id text [pk, not null]
user_id text [not null]
generation_topic_id text [not null]
provider text [not null]
model text [not null]
prompt text [not null]
width integer
height integer
ratio varchar(64)
config jsonb
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table generation_topics {
id text [pk, not null]
user_id text [not null]
title text
cover_url text
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table generations {
id text [pk, not null]
user_id text [not null]
generation_batch_id varchar(64) [not null]
async_task_id uuid
file_id text
seed integer
asset jsonb
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table message_chunks {
message_id text
chunk_id uuid
@@ -391,6 +431,15 @@ table nextauth_verificationtokens {
}
}
table oauth_handoffs {
id text [pk, not null]
client varchar(50) [not null]
payload jsonb [not null]
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table oidc_access_tokens {
id varchar(255) [pk, not null]
data jsonb [not null]
@@ -834,6 +883,10 @@ table users {
updated_at "timestamp with time zone" [not null, default: `now()`]
}
ref: agents_files.file_id > files.id
ref: agents_files.agent_id > agents.id
ref: agents_knowledge_bases.knowledge_base_id - knowledge_bases.id
ref: agents_knowledge_bases.agent_id > agents.id
@@ -848,8 +901,34 @@ ref: document_chunks.document_id > documents.id
ref: documents.file_id > files.id
ref: file_chunks.file_id - files.id
ref: file_chunks.chunk_id - chunks.id
ref: generations.file_id - files.id
ref: files.embedding_task_id - async_tasks.id
ref: files_to_sessions.file_id > files.id
ref: files_to_sessions.session_id > sessions.id
ref: generation_batches.user_id - users.id
ref: generation_batches.generation_topic_id > generation_topics.id
ref: generation_topics.user_id - users.id
ref: generations.user_id - users.id
ref: generations.generation_batch_id > generation_batches.id
ref: generations.async_task_id - async_tasks.id
ref: messages_files.file_id > files.id
ref: messages_files.message_id > messages.id
ref: messages.session_id - sessions.id
ref: messages.parent_id - messages.id
@@ -625,4 +625,29 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
- Default: `-`
- Example: `-all,+qwq-32b,+deepseek-r1`
## FAL
### `ENABLED_FAL`
- Type: Optional
- Description: Enables FAL as a model provider by default. Set to `0` to disable the FAL service.
- Default: `1`
- Example: `0`
### `FAL_API_KEY`
- Type: Required
- Description: This is the API key you applied for in the FAL service.
- Default: -
- Example: `fal-xxxxxx...xxxxxx`
### `FAL_MODEL_LIST`
- Type: Optional
- Description: Used to control the FAL model list. Use `+` to add a model, `-` to hide a model, and `model_name=display_name` to customize the display name of a model. Separate multiple entries with commas. The definition syntax follows the same rules as other providers' model lists.
- Default: `-`
- Example: `-all,+fal-model-1,+fal-model-2=fal-special`
The above example disables all models first, then enables `fal-model-1` and `fal-model-2` (displayed as `fal-special`).
[model-list]: /docs/self-hosting/advanced/model-list
@@ -624,4 +624,29 @@ LobeChat 在部署时提供了丰富的模型服务商相关的环境变量,
- 默认值:`-`
- 示例:`-all,+qwq-32b,+deepseek-r1`
## FAL
### `ENABLED_FAL`
- 类型:可选
- 描述:默认启用 FAL 作为模型供应商,当设为 0 时关闭 FAL 服务
- 默认值:`1`
- 示例:`0`
### `FAL_API_KEY`
- 类型:必选
- 描述:这是你在 FAL 服务中申请的 API 密钥
- 默认值:-
- 示例:`fal-xxxxxx...xxxxxx`
### `FAL_MODEL_LIST`
- 类型:可选
- 描述:用来控制 FAL 模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。模型定义语法规则与其他 provider 保持一致。
- 默认值:`-`
- 示例:`-all,+fal-model-1,+fal-model-2=fal-special`
上述示例表示先禁用所有模型,再启用 `fal-model-1` 和 `fal-model-2`(显示名为 `fal-special`)。
[model-list]: /zh/docs/self-hosting/advanced/model-list
@@ -0,0 +1,65 @@
---
title: Resolving AI Image Generation Timeout on Vercel
description: >-
Learn how to resolve timeout issues when using AI image generation models like gpt-image-1 on Vercel by enabling Fluid Compute for extended execution time.
tags:
- Vercel
- AI Image Generation
- Timeout
- Fluid Compute
- gpt-image-1
---
# Resolving AI Image Generation Timeout on Vercel
## Problem Description
When using AI image generation models (such as `gpt-image-1`) on Vercel, you may encounter timeout errors. This occurs because AI image generation typically requires more than 1 minute to complete, which exceeds Vercel's default function execution time limit.
Common error symptoms include:
- Function timeout errors during image generation
- Failed image generation requests after approximately 60 seconds
- "Function execution timed out" messages
### Typical Log Symptoms
In your Vercel function logs, you may see entries like this:
```plaintext
JUL 16 18:39:09.51 POST 504 /trpc/async/image.createImage
Provider runtime map found for provider: openai
```
The key indicators are:
- **Status Code**: `504` (Gateway Timeout)
- **Endpoint**: `/trpc/async/image.createImage` or similar image generation endpoints
- **Timing**: Usually occurs around 60 seconds after the request starts
## Solution: Enable Fluid Compute
For projects created before Vercel's dashboard update, you can resolve this issue by enabling Fluid Compute, which extends the maximum execution duration to 300 seconds.
### Steps to Enable Fluid Compute (Legacy Vercel Dashboard)
1. Go to your project dashboard on Vercel
2. Navigate to the **Settings** tab
3. Find the **Functions** section
4. Enable **Fluid Compute** as shown in the screenshot below:
![Enable Fluid Compute](https://hub-apac-1.lobeobjects.space/docs/2e2ff332c4b440b584efe1d7ba46aed5.png)
5. After enabling, the maximum execution duration will be extended to 300 seconds by default
### Important Notes
- **For new projects**: Newer Vercel projects have Fluid Compute enabled by default, so this issue primarily affects legacy projects
## Additional Resources
For more information about Vercel's function limitations and Fluid Compute:
- [Vercel Fluid Compute Documentation](https://vercel.com/docs/fluid-compute)
- [Vercel Functions Limitations](https://vercel.com/docs/functions/limitations#max-duration)
@@ -0,0 +1,63 @@
---
title: 解决 Vercel 上 AI 绘画生图超时问题
description: 了解如何通过开启 Fluid Compute 来解决在 Vercel 上使用 gpt-image-1 等 AI 绘画模型时遇到的超时问题。
tags:
- Vercel
- AI 绘画
- 超时问题
- Fluid Compute
- gpt-image-1
---
# 解决 Vercel 上 AI 绘画生图超时问题
## 问题描述
在 Vercel 上使用 AI 绘画模型(如 `gpt-image-1`)时,您可能会遇到超时错误。这是因为 AI 绘画生成通常需要超过 1 分钟的时间,超出了 Vercel 默认的函数执行时间限制。
常见的错误症状包括:
- 图像生成过程中出现函数超时错误
- 图像生成请求在大约 60 秒后失败
- 出现 "函数执行超时" 的错误消息
### 典型的日志现象
在您的 Vercel 函数日志中,您可能会看到类似这样的条目:
```plaintext
JUL 16 18:39:09.51 POST 504 /trpc/async/image.createImage
Provider runtime map found for provider: openai
```
关键指标包括:
- **状态码**: `504`(网关超时)
- **端点**: `/trpc/async/image.createImage` 或类似的图像生成端点
- **时间**: 通常在请求开始后约 60 秒出现
## 解决方案:开启 Fluid Compute
对于在 Vercel 控制台更新前创建的项目,您可以通过开启 Fluid Compute 来解决此问题,这将最大执行时长延长至 300 秒。
### 开启 Fluid Compute 的步骤(旧版 Vercel 控制台)
1. 前往您在 Vercel 上的项目控制台
2. 进入 **Settings**(设置)选项卡
3. 找到 **Functions**(函数)部分
4. 按照下方截图所示开启 **Fluid Compute**
![开启 Fluid Compute](https://hub-apac-1.lobeobjects.space/docs/2e2ff332c4b440b584efe1d7ba46aed5.png)
5. 开启后,最大执行时长将默认延长至 300 秒
### 重要说明
- **新项目**:较新的 Vercel 项目默认已启用 Fluid Compute,因此此问题主要影响旧版项目
## 其他资源
有关 Vercel 函数限制和 Fluid Compute 的更多信息:
- [Vercel Fluid Compute 文档](https://vercel.com/docs/fluid-compute)
- [Vercel 函数限制说明](https://vercel.com/docs/functions/limitations#max-duration)
+1 -1
View File
@@ -16,7 +16,7 @@ tags:
# Desktop Application
<Image alt={'Desktop Application'} borderless cover src={'https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96'}> />
<Image alt={'Desktop Application'} borderless cover src={'https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96'} />
**Peak Performance, Zero Distractions**
+1 -1
View File
@@ -16,7 +16,7 @@ tags:
# MCP Marketplace
<Image alt={'MCP Marketplace'} borderless cover src={'https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0'}> />
<Image alt={'MCP Marketplace'} borderless cover src={'https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0'} />
**Discover, Connect, Expand**
+1 -1
View File
@@ -30,7 +30,7 @@ This article will guide you on how to use AI21 Labs within LobeChat.
### Step 2: Configure AI21 Labs in LobeChat
- Go to the `Settings` page in LobeChat
- Under `Language Model`, find the setting for `AI21 Labs`
- Under `AI Service Provider`, find the setting for `AI21 Labs`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/9336d6c5-2a83-4aa9-854e-75e245b665cb'} />
+1 -1
View File
@@ -28,7 +28,7 @@ tags:
### 步骤二:在 LobeChat 中配置 AI21 Labs
- 访问 LobeChat 的`设置`界面
- 在`语言模型`下找到 `AI21labs` 的设置项
- 在`AI 服务商`下找到 `AI21labs` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/9336d6c5-2a83-4aa9-854e-75e245b665cb'} />
+1 -1
View File
@@ -28,7 +28,7 @@ This article will guide you on how to use the 360AI in LobeChat.
### Step 2: Configure 360AI in LobeChat
- Access the `Settings` interface in LobeChat
- Under `Language Models`, find the option for `360`
- Under `AI Service Provider`, find the option for `360`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63'} />
+1 -1
View File
@@ -28,7 +28,7 @@ tags:
### 步骤二:在 LobeChat 中配置 360 智脑
- 访问 LobeChat 的`设置`界面
- 在`语言模型`下找到 `360` 的设置项
- 在`AI 服务商`下找到 `360` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63'} />
+1 -1
View File
@@ -37,7 +37,7 @@ The Anthropic Claude API is now available for everyone to use. This document wil
### Step 2: Configure Anthropic Claude in LobeChat
- Access the `Settings` interface in LobeChat.
- Find the setting for `Anthropic Claude` under `Language Models`.
- Find the setting for `Anthropic Claude` under `AI Service Provider`.
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff9c3eb8-412b-4275-80be-177ae7b7acbc'} />
+1 -1
View File
@@ -36,7 +36,7 @@ Anthropic Claude API 现在可供所有人使用,本文档将指导你如何
### 步骤二:在 LobeChat 中配置 Anthropic Claude
- 访问 LobeChat 的`设置`界面
- 在`语言模型`下找到`Anthropic Claude`的设置项
- 在`AI 服务商`下找到`Anthropic Claude`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff9c3eb8-412b-4275-80be-177ae7b7acbc'} />
+1 -1
View File
@@ -39,7 +39,7 @@ This document will guide you on how to use [Azure OpenAI](https://oai.azure.com/
### Step 2: Configure Azure OpenAI in LobeChat
- Access the `Settings` interface in LobeChat.
- Find the setting for `Azure OpenAI` under `Language Model`.
- Find the setting for `Azure OpenAI` under `AI Service Provider`.
<Image alt={'Enter the API key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/63d9f6d4-5b78-4c65-8cd1-ff8b7f143406'} />
+1 -1
View File
@@ -33,7 +33,7 @@ tags:
### 步骤二:在 LobeChat 中配置 Azure OpenAI
- 访问 LobeChat 的`设置`界面
- 在`语言模型`下找到`Azure OpenAI`的设置项
- 在`AI 服务商`下找到`Azure OpenAI`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/63d9f6d4-5b78-4c65-8cd1-ff8b7f143406'} />
+1 -1
View File
@@ -27,7 +27,7 @@ This article will guide you on how to use Baichuan in LobeChat:
### Step 2: Configure Baichuan in LobeChat
- Visit the `Settings` interface in LobeChat
- Find the setting for `Baichuan` under `Language Model`
- Find the setting for `Baichuan` under `AI Service Provider`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/dec6665a-b3ec-4c50-a57f-7c7eb3160e7b'} />

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