Compare commits

..

554 Commits

Author SHA1 Message Date
ONLY-yours 3abf3b242e feat: i18n 2025-12-17 19:12:23 +08:00
ONLY-yours a3c459226f feat: add mcp tools auth aleart 2025-12-17 19:07:50 +08:00
arvinxx af66709990 fix lint 2025-12-17 16:05:29 +08:00
arvinxx 2bc61becda fix args save and topic saving 2025-12-17 15:59:05 +08:00
arvinxx fcfea3f588 support stepContext 2025-12-17 15:59:05 +08:00
Rene Wang 69659b228f style: Remove extra divider 2025-12-17 15:46:10 +08:00
Rene Wang c8d66b5f31 fix: Reset editor canva content after deletion 2025-12-17 15:33:20 +08:00
Rene Wang 0cb9524b85 feat: Context detect for /group 2025-12-17 15:30:06 +08:00
YuTengjing 8fda73d147 docs: update project rules 2025-12-17 15:29:44 +08:00
Rene Wang e0147efde5 fix: CMDK states after refresh 2025-12-17 15:26:19 +08:00
Innei eeb1e9d289 style: enhance dropdown styling and structure in Tools component
Signed-off-by: Innei <tukon479@gmail.com>
2025-12-17 14:24:57 +08:00
YuTengjing efb3bba568 🔄 refactor: replace Next.js router with React Router in ModeSelectionStep component
- Update navigation logic to use `useNavigate` from React Router instead of `useRouter` from Next.js
- Adjust the redirection path from '/chat' to '/' after finishing onboarding
2025-12-17 14:23:08 +08:00
YuTengjing 4285749df2 📝 docs: standardize typecheck command to type-check
Unify the naming convention across documentation and CI workflows
2025-12-17 14:23:08 +08:00
YuTengjing 96850efdf4 ♻️ refactor: consolidate chatConfig selectors to use chatConfigByIdSelectors
- Remove duplicate logic in chatConfigSelectors by reusing chatConfigByIdSelectors
- Rename getAgentChatConfigById to getChatConfigById for consistency
- Update all imports and usages across chat service and tests
- Add proper mock setup in chat.test.ts for resolveAgentConfig dependencies
2025-12-17 14:23:07 +08:00
arvinxx 18970d5983 show member popup panel 2025-12-17 14:08:47 +08:00
arvinxx a9ece03d0a fix recent topic issue 2025-12-17 14:08:47 +08:00
Innei 1f136227b8 refactor: replace loadUrl with broadcast for navigation in BrowserWindowsCtr and macOS menu
Signed-off-by: Innei <tukon479@gmail.com>
2025-12-17 13:59:21 +08:00
arvinxx 08c338267e fix create new topic issue 2025-12-17 12:22:32 +08:00
Rene Wang 9d0da361fa feat: Ask AI 2025-12-17 12:02:51 +08:00
Rene Wang b36c04c404 style: Search skelton 2025-12-17 11:36:23 +08:00
canisminor1990 3cc647a49d style: update Avatar 2025-12-17 11:23:13 +08:00
Innei a06f45b6cf refactor accessibility tests for macOS in SystemController
Signed-off-by: Innei <tukon479@gmail.com>
2025-12-17 11:11:33 +08:00
YuTengjing 25c2e90cf5 🐛 fix: resolve circular dependency in user store onboarding slice 2025-12-17 11:07:11 +08:00
arvinxx 5a3cbf6a59 fix message branch 2025-12-17 03:41:52 +08:00
arvinxx 0b76fabc56 update todo list style 2025-12-17 03:37:53 +08:00
arvinxx ac2b1eca37 support todo list 2025-12-17 03:24:27 +08:00
arvinxx 1a5caf9c48 refactor the updateToolArguments api 2025-12-17 03:12:24 +08:00
arvinxx fffd8a04b0 refactor the builtin structure 2025-12-17 02:20:03 +08:00
arvinxx 446c10b598 refactor the builtin structure 2025-12-17 02:15:56 +08:00
arvinxx 56677e0415 refactor to finish Todo list tool 2025-12-17 01:17:22 +08:00
arvinxx ae74142ae0 refactor to finish Todo list tool 2025-12-17 00:46:23 +08:00
arvinxx 4f641269f8 support todo list 2025-12-17 00:29:40 +08:00
arvinxx 2643a46a2c refactor agent config 2025-12-17 00:05:20 +08:00
arvinxx 4517562629 refactor to the todo list 2025-12-16 23:13:45 +08:00
Innei 26da014e94 fix: dev turbo api cjs import error
- Removed *.patch from .gitignore to allow tracking of patch files.
- Added a new patch for @swagger-api/apidom-reference to fix buffer import issues in binary parsers.
- Updated package.json to include the new patched dependency.

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 22:36:31 +08:00
Neko Ayaka ddd759464d fix(tools): method missing for builtin knowledge base 2025-12-16 22:28:47 +08:00
Neko Ayaka e72adfe552 feat(memory-user-memory): added tracing for embedding calls 2025-12-16 22:28:47 +08:00
Innei 681590f2a1 chore: desktop title bar height
- Added isDesktop check to modify paddingTop in the layout style.
- Enhanced layout responsiveness for better user experience on different devices.

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 22:21:53 +08:00
Innei 2df15871b8 feat: Expose darwinMajorVersion in lobeEnv and update layout border radius
- Added darwinMajorVersion to the lobeEnv object exposed in the main world.
- Updated DesktopLayoutContainer to conditionally set border radius based on darwinMajorVersion.
- Modified electronApi tests to verify the exposure of lobeEnv and its properties.

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 22:14:44 +08:00
Neko Ayaka 5a1f2e460c fix(database): try to fix the deadlock issue 2025-12-16 21:55:18 +08:00
Innei ccd4ac56e0 refactor: Clean up desktop-specific routes in the modifyRoutes function
- Removed redundant entries for desktop routes in the route modification logic.
- Consolidated desktop-specific routes for better organization and clarity.

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 21:54:51 +08:00
Innei fc6097bb4f feat: Add desktop-specific routes and components for DevTools and layout
- Introduced new layout component for desktop applications.
- Added DevTools component with system inspector functionality.
- Updated route modification logic to include new desktop-specific files.

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 21:54:50 +08:00
YuTengjing 1bb74b3b5f chore: revert some changes 2025-12-16 21:53:27 +08:00
YuTengjing 38df1277a3 feat(onboarding): add toggleDefaultPlugin action for Klavis server
- Add toggleDefaultPlugin action to onboarding slice that updates both
  user settings defaultAgentConfig and inbox agent plugins
- Update KlavisServerList to use toggleDefaultPlugin instead of togglePlugin
- Fix ProSettingsStep to use react-router-dom navigation
- Add optional chaining to chatConfigSelectors for safety
2025-12-16 21:46:58 +08:00
YuTengjing ecc9ef8846 fix: jump to home page instead of chat page 2025-12-16 21:46:58 +08:00
YuTengjing 940c804caf feat: onboarding rest work 2025-12-16 21:46:58 +08:00
YuTengjing ae66a23350 feat(onboarding): add step 5 pro settings and fix default model sync
- Add ProSettingsStep component with default model selection
- Add updateDefaultModel action to sync both user settings and inbox agent
- Move agent config merge logic to server side (merge user's defaultAgentConfig)
- Remove obsolete defaultAgentConfig from agent store
- Add loading guards to profile pages to prevent accessing undefined data
- Add optional chaining to selectors for safety
2025-12-16 21:45:06 +08:00
YuTengjing aaa61f7994 feat(onboarding): add step 4 mode selection
- Add ModeSelectionStep component for Lite/Pro mode choice
- Lite mode finishes onboarding directly
- Pro mode proceeds to step 5 (Pro Settings)
- Update ResponseLanguageStep to call onNext instead of finishing
- Add i18n translations for mode selection
2025-12-16 21:44:28 +08:00
YuTengjing cd2d00e7c1 feat: implement new onboarding flow (steps 1-3)
- Add onboarding UI with telemetry, full name, and response language steps
- Create onboarding slice with state management and selectors
- Add version-based onboarding control for future upgrades
- Implement redirect logic with backward compatibility for isOnboarded field
- Add tRPC endpoint for updating onboarding progress
- Add i18n support for onboarding translations
2025-12-16 21:44:27 +08:00
Innei 565f355a9b feat: Enhance manual build workflow for desktop applications with multi-architecture support and separate build jobs for macOS, Windows, and Linux
- Updated the build workflow to include distinct jobs for macOS, Windows, and Linux.
- Added logic to rename macOS artifact files based on architecture (arm64 or x64).
- Improved artifact upload process for each platform.
- Refactored package.json to streamline the build script and added type checking.

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 21:42:21 +08:00
Innei 9ea87353be feat: Add setup actions for Node.js with Bun and pnpm, and create manual build workflow for desktop
Signed-off-by: Innei <tukon479@gmail.com>
2025-12-16 21:36:11 +08:00
Innei 03782b8b54 fix(desktop): desktop setting crash 2025-12-16 21:25:27 +08:00
arvinxx 88406e070f support builtin tool structures 2025-12-16 20:40:07 +08:00
arvinxx d09a8f23fa refactor builtin tool structures 2025-12-16 20:40:07 +08:00
arvinxx 4a900bd775 fix inbox agent topic 2025-12-16 20:40:07 +08:00
arvinxx 1d889b705f refactor agent builder 2025-12-16 20:40:07 +08:00
Innei d67754bde1 feat: Implement desktop auto OIDC handling on first open and update onboarding flow 2025-12-16 20:32:21 +08:00
Shinji-Li 4dc5308323 feat: add collection files using code interpreter (#10811)
* feat: add files into ai message & display way

* feat: update the system role
2025-12-16 20:21:06 +08:00
Innei c8b4c6d13e refactor(desktop-onboarding): remove desktop runtime guard and update theme usage across components 2025-12-16 20:08:30 +08:00
Rene Wang 8040a3cc69 feat: Use CMDK to search code 2025-12-16 20:02:31 +08:00
Rene Wang 29309549c4 fix: Topic not loading 2025-12-16 19:55:32 +08:00
canisminor1990 8467b3fbea chore: update lint 2025-12-16 19:54:11 +08:00
Neko Ayaka 0e7b2ba4c5 style(desktop): desktop onboarding lint issue 2025-12-16 19:42:37 +08:00
canisminor1990 034b82d915 style: update diffnode style 2025-12-16 19:14:42 +08:00
Rene Wang 0b1c19a017 style: Show preview text 2025-12-16 18:21:23 +08:00
Neko Ayaka 3dcf785e7e chore(memory-user-memory): now use responseLanguage for memory language 2025-12-16 18:13:06 +08:00
Neko Ayaka 60d9151802 feat(memory-user-memory): now support to specify language of gate keeper 2025-12-16 18:13:06 +08:00
Neko Ayaka 64c6527de2 refactor(memory-user-memory): adjust s3 hooks implementation 2025-12-16 18:13:05 +08:00
Rene Wang d3847fad34 feat: selection message 2025-12-16 18:08:56 +08:00
Rene Wang ca7570f817 style: Selection chip style 2025-12-16 18:08:56 +08:00
Shinji-Li 2c452dddfc feat: add the klavisAuth manager in settings profiles (#10810)
* feat: update the klavisAuth manager in settings profiles

* chore: update i18n files
2025-12-16 18:06:11 +08:00
Rene Wang 92c879b2a7 style: Context list 2025-12-16 17:49:59 +08:00
Innei 37a2e8edf6 feat: implement desktop onboarding flow 2025-12-16 17:34:36 +08:00
Shinji-Li 9865c2d620 🔨 chore: update klavis use way (#10808)
* feat: update agent-builder executionRutime & invertions toolCall way

* chore: update i18n
2025-12-16 17:24:20 +08:00
Neko Ayaka 02f53afbf9 fix(userMemories,memory-user-memory): capturedAt not set, improve prompts 2025-12-16 17:19:13 +08:00
canisminor1990 ee707d2d01 style: add agent builder avatar 2025-12-16 17:12:49 +08:00
Rene Wang e5c5036f10 feat: Chat selection 2025-12-16 16:24:01 +08:00
Neko Ayaka 39c5d77163 test(userMemories,database): remove update access metrics test as findById no longer need this 2025-12-16 16:00:17 +08:00
Shinji-Li fb70ce70ef feat:update the profile avatar banner url & socaillinks (#10805)
* feat: add update userImage & bannerUrl & modify sociallinks in discover user profile

* fix: delete old settings/my agents

* feat: update i18n
2025-12-16 15:54:53 +08:00
Rene Wang 2b89dbe8f5 feat: Remove famer-motion 2025-12-16 15:33:37 +08:00
Innei 252ed1fe7c fix: replace motion to m use lazy motion 2025-12-16 15:28:12 +08:00
Rene Wang 5ee75b73da feat: wide screen toggke 2025-12-16 15:26:47 +08:00
Rene Wang 29b28ece20 feat: wide screen toggke 2025-12-16 15:26:47 +08:00
Innei f433332f87 chore: replace lazy motion to reduce bundle size 2025-12-16 15:20:35 +08:00
Innei c62da64fcc chore: inject desktop .env 2025-12-16 15:19:41 +08:00
Rene Wang 8e8cb58b78 lint: Optimize prompt 2025-12-16 15:00:10 +08:00
arvinxx 3d9a1488bb add gtd 2025-12-16 14:51:13 +08:00
arvinxx 23b67b364a fix tests 2025-12-16 14:51:13 +08:00
canisminor1990 a2be282be6 feat: update memory edit 2025-12-16 14:38:04 +08:00
YuTengjing 09b001067a 🗃️ db: add onboarding field to users table
- Add `onboarding` jsonb field to track user onboarding progress
- Add `UserOnboarding` type with version, currentStep, and finishedAt
- Merge occupation and onboarding fields into single migration 0063
2025-12-16 14:19:18 +08:00
Rene Wang 36ba02efe9 feat: Use modify 2025-12-16 14:09:18 +08:00
canisminor1990 fd60bee830 fix: fix memory type 2025-12-16 12:32:41 +08:00
Shinji-Li 0ec35f6c32 🐛 fix: change the react router data mode to delcarative mode (#10801)
* feat: change the react router data mode to delcarative mode

* feat: mobile router change
2025-12-16 11:54:37 +08:00
arvinxx 3ee17092ab fix some tests 2025-12-16 09:48:01 +08:00
arvinxx db0a650bd5 fix some tests 2025-12-16 01:58:54 +08:00
arvinxx 5678b9fadb fix agent group system prompts 2025-12-16 01:28:08 +08:00
Neko Ayaka a99ccf4534 refactor(userMemories): rename many types 2025-12-15 23:16:21 +08:00
Neko Ayaka 0bd603cc29 feat(userMemories): improved the model for queryMemories & queryMemoryDetail 2025-12-15 23:04:57 +08:00
YuTengjing 6a4c355b54 ♻️ refactor: user onboarding and settings improvements (#10796) 2025-12-15 22:42:37 +08:00
canisminor1990 1a075f3ca5 style: update Memory 2025-12-15 21:59:33 +08:00
Innei d4b618ed71 feat(desktop): implement system theme mode management and optimize layout background handling 2025-12-15 21:49:21 +08:00
canisminor1990 6d62e1ec80 style: update Memory 2025-12-15 21:42:39 +08:00
canisminor1990 3bdd1cbc0f style: update Memory 2025-12-15 21:25:23 +08:00
canisminor1990 600a20c822 style: update Memory 2025-12-15 21:16:12 +08:00
canisminor1990 bbf20289db style: update Memory 2025-12-15 21:16:11 +08:00
canisminor1990 9a57fcd600 style: update Memory 2025-12-15 21:16:11 +08:00
Innei d6bdf406aa feat: add development script and enhance authentication logic for desktop 2025-12-15 21:12:06 +08:00
Innei b98813f0b1 chore(DevPanel): fix removed pg data typing 2025-12-15 21:01:41 +08:00
arvinxx dce2925ab9 fix error 2025-12-15 20:47:22 +08:00
arvinxx 86f9f1c06c fix builtin tool 2025-12-15 20:44:00 +08:00
Innei 5accb96fec refactor(electron)!: remove local server integrate and reduce desktop bundle size (#10736)
* backup

* refactor: update electron build process and improve prebuild script

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance electron build process with source modifications and cleanup utilities

- Added new modifiers for app code, cleanup, next config, and routes to adapt the source for Electron compatibility.
- Integrated @ast-grep/napi for AST manipulation in the new modifiers.
- Updated build script to include the new modification steps.
- Added tests for the new parserPluginSettings function to ensure correct parsing of plugin settings.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: format tsconfig.json and enhance electron workflow modifiers

- Reformatted tsconfig.json for improved readability by aligning array elements and paths.
- Added imports for path and utility functions in the electron workflow modifiers.
- Implemented a standalone execution check for the modifySourceForElectron function to enhance modularity.

Signed-off-by: Innei <tukon479@gmail.com>

* update

* update

* update

* chore: update .gitignore to include 'out' directory

- Added 'out' directory to .gitignore to prevent build artifacts from being tracked.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance Electron desktop build process with Next.js export integration

- Added a new script to copy Next.js export assets to the Electron desktop build directory.
- Updated Dockerfile to prepare and copy Next.js export assets for Electron packaging.
- Refactored application code to utilize a centralized RendererProtocolManager for handling renderer URLs and asset requests.
- Introduced new utilities for managing route variants and localization in the desktop bridge package.
- Removed obsolete standalone move script and updated related configurations.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: improve path resolution and asset handling in Electron app

- Introduced conditional logic to determine the correct export directory for Next.js assets based on their existence.
- Enhanced path normalization in the App class to handle trailing slashes and extensions more effectively.
- Updated the RendererProtocolManager to improve asset request handling and ensure proper registration after app readiness.
- Added a new utility function for converting file paths to application URLs.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: transition data sync storage mode from local to cloud

* refactor: streamline Next.js export handling and remove database lock management

* refactor: streamline Next.js export handling and remove database lock management

* refactor: improve request handling in Browser class

* feat: Introduce ELECTRON_BE_PROTOCOL_SCHEME and update protocol handling

* refactor: streamline Electron build process and remove unused remote request constants

* refactor: enhance RendererProtocolManager for improved 404 handling and add unit tests

* chore: remove unused const package from pnpm workspace configuration

* feat: Implement desktop-specific authentication handling and update app browser path

* fix: Update logout item visibility based on authentication and desktop status

* test: Update various tests to improve assertions and ensure proper handling of results

Signed-off-by: Innei <tukon479@gmail.com>

* chore: Enhance pnpm caching in GitHub Actions workflow for improved build performance

Signed-off-by: Innei <tukon479@gmail.com>

* chore: Remove hardcoded environment variables from GitHub Actions workflow

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: Remove SystemServerCtr and related tests, update pre-commit script, and enhance URL rewriting error handling in Browser class

* feat: Add DesktopAutoOidcOnFirstOpen component for automatic OIDC authorization on first app launch

* fix: Improve error handling in Browser class and add duplex support for streaming requests

* refactor: Update DevPanel to conditionally render PostgresViewer based on desktop status and remove unused DesktopService

* feat: Implement MCP Controller and client for managing MCP server interactions, including tool calls and manifest retrieval

* feat: Integrate SuperJSON for serialization in MCP service and controller, enhancing data handling for tool calls and manifests

* fix: Correct baseUrl path in tsconfig.json for consistent module resolution

* fix: Update module resolution paths in tsconfig.json for consistency

* fix: Update module resolution paths in tsconfig.json for consistency

* refactor: Clean up formatting in streamInvoke function and remove unused fetch logic in toolsClient

* chore: Add @lobechat/types package and update pnpm workspace configuration to include new packages

* chore: Remove @lobechat/types package from dependencies and update pnpm workspace configuration; add local interface for CheckMcpInstallResult to avoid path-alias issues

* chore: Add alias for utils directory in vitest configuration to streamline module resolution

---------

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-15 20:36:03 +08:00
arvinxx 8b83609103 improve system role 2025-12-15 20:19:02 +08:00
Shinji-Li 74f4341e8d feat: support market Cloud Code Interpreter implementation (#10795)
* feat: Cloud Code Interpreter implementation

- Add new code-interpreter tool with Market SDK integration
- Create ExecutionRuntime for cloud sandbox execution
- Add Render components for tool results display
- Add Intervention components for user confirmations
- Add exportFile tool with S3 pre-signed URL support
- Create tRPC router for code interpreter API calls
- Rename old Pyodide-based interpreter to code-interpreter-draft

* feat: update the system role & icon
2025-12-15 20:17:30 +08:00
Rene Wang c73416141b feat: Update multiopkle node 2025-12-15 20:05:14 +08:00
Rene Wang 9f6084a2fb fix: Not jumping after creation 2025-12-15 19:29:26 +08:00
Rene Wang cbd6715dd7 lint: Clean up code 2025-12-15 19:25:09 +08:00
Neko Ayaka d42f8f64c7 fix(userMemories): should accept string types 2025-12-15 19:22:09 +08:00
arvinxx 5330159fa8 clean 2025-12-15 18:36:04 +08:00
Rene Wang 34850e3d1c lint: Clean up code 2025-12-15 18:27:45 +08:00
Rene Wang e06a1bd4d9 lint: Remove unused props 2025-12-15 18:27:45 +08:00
Neko Ayaka ab09c04bee chore(memory-user-memory): improved scoring, add range constraints, improved gate keeper 2025-12-15 18:27:29 +08:00
Neko 39af292f6e feat(userMemories): added queryMemoryDetail (#10791) 2025-12-15 18:08:41 +08:00
Rene Wang c951cc0afd fix: Type script error 2025-12-15 17:59:51 +08:00
Neko Ayaka 80ea77ac6b refactor(userMemories): re-design the queryMemories 2025-12-15 17:43:04 +08:00
Rene Wang 544639ff92 fix: List not updated after upload 2025-12-15 17:25:22 +08:00
Rene Wang 9e58abb37d lint: Removw unused code 2025-12-15 17:14:21 +08:00
Rene Wang 3b71cb1eee refac: Get page content tool 2025-12-15 17:11:11 +08:00
arvinxx 5d3fb27a19 create to navigate to profile 2025-12-15 16:42:26 +08:00
arvinxx f73ce9be9e remove group feature flag 2025-12-15 14:40:14 +08:00
canisminor1990 d9e95fbbb7 style: update Memory Empty 2025-12-15 14:37:49 +08:00
Neko 132ced10a5 feat(userMemories): added queryMemories for searching & listing (#10779) 2025-12-15 14:37:39 +08:00
arvinxx d6c74efacb update lint 2025-12-15 14:01:33 +08:00
Neko Ayaka 6229876ff5 fix(userMemories): capturedAt field missing 2025-12-15 13:59:20 +08:00
arvinxx 9b0926efdc add gtd tool 2025-12-15 13:58:59 +08:00
canisminor1990 6faa261e3f style: update Empty and Skeleton 2025-12-15 13:56:50 +08:00
arvinxx 03817cd427 update prompts 2025-12-15 09:59:33 +08:00
arvinxx 3b2767aec9 support search and create agent 2025-12-15 09:59:33 +08:00
arvinxx 8b3b8fd218 fix add and remove group member 2025-12-15 09:59:33 +08:00
arvinxx 5d251563b3 fix group member selection api 2025-12-15 09:59:33 +08:00
arvinxx e6263ebb09 fix group issue 2025-12-15 09:59:33 +08:00
arvinxx 4dd315126b add tests for different speaker 2025-12-15 09:59:33 +08:00
arvinxx 54da37d4bf implement getAgentInfo 2025-12-15 09:59:33 +08:00
arvinxx 5912717721 fix group mode 2025-12-15 09:59:33 +08:00
arvinxx 061b91a76e agentCouncil 2025-12-15 09:59:33 +08:00
Tsuki 09bf76e10b feat(mobile): add topic router for mobile compatibility (#10776)
feat(mobile): add topic router for mobile compatibility
2025-12-15 09:59:32 +08:00
canisminor1990 59a6e3e0ac style: update i18n 2025-12-15 09:59:32 +08:00
canisminor1990 aebe297429 style: update error style 2025-12-15 09:59:32 +08:00
canisminor1990 b5a6e2d60f style: update error style 2025-12-15 09:59:32 +08:00
arvinxx 11eb5834bb 初步跑通群聊 pipeline 2025-12-15 09:59:32 +08:00
arvinxx e5cd73f7dd fix group and thread 2025-12-15 09:59:32 +08:00
arvinxx 4237f7c2b9 fix total workflow in group mode 2025-12-15 09:59:32 +08:00
arvinxx 54c9900997 fix group message query 2025-12-15 09:59:31 +08:00
arvinxx d786491be1 fix avatar id 2025-12-15 09:59:31 +08:00
arvinxx af392959fa improve 2025-12-15 09:59:31 +08:00
arvinxx 5f062a802c clean 2025-12-15 09:59:31 +08:00
arvinxx 619627f100 fix regenerate and delete 2025-12-15 09:59:31 +08:00
arvinxx 989a108db9 clean actions 2025-12-15 09:59:31 +08:00
arvinxx c85fb193f8 完成 Supervisor 注入链路 2025-12-15 09:59:31 +08:00
arvinxx 18b2564ac5 完成 GroupOrchestrationSupervisor 定义 2025-12-15 09:59:29 +08:00
arvinxx 111aa922d7 添加 builtin group agent 2025-12-15 09:58:48 +08:00
arvinxx c06b63330e add timing 2025-12-15 09:58:48 +08:00
arvinxx d0771c2dbf update content 2025-12-15 09:58:48 +08:00
arvinxx 3eeeed0c43 fix loading 2025-12-15 09:58:48 +08:00
Rene Wang 24920c93b4 lint: Remove unused files 2025-12-15 09:58:48 +08:00
Rene Wang 2fc6e855d1 style: Add todos 2025-12-15 09:58:48 +08:00
Rene Wang 68be44a053 feat: New context item style 2025-12-15 09:58:48 +08:00
arvinxx 6364027ac2 update provider 2025-12-15 09:58:48 +08:00
Rene Wang 0875ac4ca2 lint: Remove unused code 2025-12-15 09:58:48 +08:00
Rene Wang 23a3cf609f feat: Search community content 2025-12-15 09:58:47 +08:00
Neko Ayaka 40d4f496db perf(memory-user-memory): improve identity & gatekeeper for better results 2025-12-15 09:58:47 +08:00
Shinji-Li c47d7285b4 🐛 fix: slove makret oidc error but the set usersetting to much (#10754)
fix: fixed the marketoidc call updateSettings
2025-12-15 09:58:47 +08:00
Rene Wang e8d64606c8 style: Add missing border 2025-12-15 09:58:47 +08:00
Rene Wang d95582fdcb feat: Optimize drag UX 2025-12-15 09:58:47 +08:00
arvinxx 8e4c481ffb update agent group 2025-12-15 09:58:47 +08:00
Rene Wang 1ebc06b1b4 opti: Better prompt 2025-12-15 09:58:47 +08:00
canisminor1990 5f13f37544 style: update avatar 2025-12-15 09:58:47 +08:00
canisminor1990 ded42374d5 style: update discover 2025-12-15 09:58:47 +08:00
Neko Ayaka 07963b3552 fix(memory-user-memory): extractor should handle errors correctly 2025-12-15 09:58:47 +08:00
canisminor1990 754b28382e style: update discover 2025-12-15 09:58:47 +08:00
Innei 0c1b19114c 💄 style: update layout background color based on layout color (#10738)
feat: Update layout background color based on theme
2025-12-15 09:58:47 +08:00
Neko 09fc1d2d13 🐛 fix(memory-user-memory): extractor not passing results correctly (#10751) 2025-12-15 09:58:47 +08:00
canisminor1990 da34f490c8 style: update discover 2025-12-15 09:58:47 +08:00
Rene Wang 85af7b16a2 fix: Cross panel drag issue 2025-12-15 09:58:47 +08:00
canisminor1990 9f93568fcf style: update discover 2025-12-15 09:58:47 +08:00
canisminor1990 b7ed1fc744 style: update discover 2025-12-15 09:58:47 +08:00
canisminor1990 2713cce61e style: update discover 2025-12-15 09:58:47 +08:00
canisminor1990 4e83977e7c style: update discover 2025-12-15 09:58:47 +08:00
arvinxx 07c6ad56ea refactor group agent 2025-12-15 09:58:47 +08:00
arvinxx 6fb1a3ba90 update api 2025-12-15 09:58:46 +08:00
arvinxx f04c789043 fix group agent 2025-12-15 09:58:46 +08:00
arvinxx 21b3b2a693 2 messages on server runtime 2025-12-15 09:58:46 +08:00
arvinxx ce97ffc0e0 group send message 2025-12-15 09:58:46 +08:00
arvinxx f0ebdacc00 refactor aiAgent tests 2025-12-15 09:58:46 +08:00
Neko e864c9e44f 🐛 fix(memory-user-memory): should handle the exceptions loosely (#10742) 2025-12-15 09:58:46 +08:00
arvinxx dc4d26d13f refactor Conversation context 2025-12-15 09:58:46 +08:00
arvinxx aa5cb2953d refactor with new supervisor 2025-12-15 09:58:46 +08:00
arvinxx 0fc6f20840 refactor the topic map issue 2025-12-15 09:58:46 +08:00
Neko b75d81fa4e 🐛 fix(memory-user-memory): do not update updated_at for topic when extracted (#10741) 2025-12-15 09:58:46 +08:00
canisminor1990 cec6751bda style: add start converstation button 2025-12-15 09:58:46 +08:00
canisminor1990 319dfb21d5 style: fix draw z-index 2025-12-15 09:58:46 +08:00
canisminor1990 b3c67430e8 style: update discover 2025-12-15 09:58:45 +08:00
Rene Wang 0186a4b01d feat: Update CMDK icons 2025-12-15 09:58:45 +08:00
canisminor1990 a1d98cb05b style: update discover 2025-12-15 09:58:45 +08:00
Rene Wang beba396c8b fix: Remove unused files 2025-12-15 09:58:45 +08:00
arvinxx 16f3c0df75 fix market build 2025-12-15 09:58:45 +08:00
Neko e99092e091 🐛 fix(userMemories,memory-user-memory): trim off based on the configured model token limit (#10737) 2025-12-15 09:58:45 +08:00
Rene Wang f4329b81bb feat: Add copilot entry 2025-12-15 09:58:45 +08:00
Rene Wang d9d149de2a feat: Add copilot entry 2025-12-15 09:58:45 +08:00
Rene Wang b72fc88b3f style: Hide topic list 2025-12-15 09:58:45 +08:00
Rene Wang d06b0dbbcf feat: Show a built in copilot 2025-12-15 09:58:45 +08:00
arvinxx 31ea03382b fix group agent list 2025-12-15 09:58:45 +08:00
Rene Wang 2110a87422 lint: Remove unused files 2025-12-15 09:58:44 +08:00
canisminor1990 2d0dde74c9 style: update discover 2025-12-15 09:58:41 +08:00
Rene Wang 0973458529 feat: Active zone for entire folder 2025-12-15 09:58:23 +08:00
Rene Wang 208b07d417 opti: Better DND performance 2025-12-15 09:58:23 +08:00
Rene Wang 52983de190 opti: Better DND performance 2025-12-15 09:58:23 +08:00
arvinxx 18f5834550 fix types 2025-12-15 09:58:23 +08:00
arvinxx a915507938 refactor types 2025-12-15 09:58:23 +08:00
arvinxx c27bf79e0f refactor agent profile 2025-12-15 09:58:23 +08:00
arvinxx d59b44c042 test(hooks): add tests for useFetchTopics hook
- Test fetching topics with agentId when no groupId is active
- Test fetching topics with groupId for group sessions
- Test isInbox is forced to false when groupId is present
- Test isInbox is true for inbox agent without groupId
- Test topicPageSize from global store is passed correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 09:58:23 +08:00
Neko Ayaka 7428a58303 fix: ignore function scoping for SearchResults component 2025-12-15 09:58:23 +08:00
arvinxx 0090bd715e [DB Repository] 创建 AgentGroup repository 添加 findByIdWithAgents 方法 2025-12-15 09:58:23 +08:00
arvinxx d7d38d338e refactor agent group store 2025-12-15 09:58:22 +08:00
arvinxx b6fd29787b move action 2025-12-15 09:58:22 +08:00
Neko 0a184130dc 🐛 fix(database,userMemories): cursor paginated topic iteration will cause infinite loop for memory extraction task (#10732) 2025-12-15 09:58:22 +08:00
Rene Wang 706569e61c fix: Title not saving 2025-12-15 09:58:22 +08:00
Rene Wang 2cec8062f1 fix: Filter out message with role=tool 2025-12-15 09:58:22 +08:00
Rene Wang 274f95a4a5 feat: Add search 2025-12-15 09:58:22 +08:00
Rene Wang 604e0cf1c8 feat: Block files 2025-12-15 09:58:22 +08:00
arvinxx bf90eef3d4 clean the ttl 2025-12-15 09:58:22 +08:00
arvinxx d8d56a6809 improve operationId 2025-12-15 09:58:22 +08:00
arvinxx ca9e1a124c try unlimited tasks 2025-12-15 09:58:22 +08:00
Neko ae521e0d61 ️ perf(memory-user-memory): now layer runner will run conrrently (#10725) 2025-12-15 09:58:22 +08:00
Neko 9179f63449 🐛 fix(memory-user-memory): not correctly defining schema (#10724)
fix(memory-user-memory): not correctly defining schema
2025-12-15 09:58:22 +08:00
arvinxx 40271db7a1 support execAgents 2025-12-15 09:58:22 +08:00
arvinxx 9cfd816b50 fix tool calling issues in server runtime 2025-12-15 09:58:19 +08:00
canisminor1990 64052762d1 style: update discover avatar 2025-12-15 09:58:07 +08:00
Neko 6f10874617 🐛 fix(memory-user-memory): yet again encountered the openai structured format issue for required objects (#10722) 2025-12-15 09:58:07 +08:00
arvinxx db681ac87d fix topic in recent 2025-12-15 09:58:07 +08:00
Shinji-Li f01327a796 feat: add discover user profiles page (#10721)
* feat: discover user page init

* feat: add update user profile modal

* fix: change the editor userinfo into discover profile page

* feat: change drawer into card options

* feat: add i18n locals
2025-12-15 09:58:07 +08:00
canisminor1990 9604523c51 style: update memory cloud tag 2025-12-15 09:58:07 +08:00
Neko 0dc5e711bc feat(database): added queryIdentityRoles model for user memory (#10720) 2025-12-15 09:58:07 +08:00
Rene Wang ce75257975 feat: Select agent 2025-12-15 09:58:07 +08:00
arvinxx a149391b0e fix build 2025-12-15 09:58:07 +08:00
Neko ace6d34d5f 🐛 fix(memory-user-memory): exports caused the deps module (#10719) 2025-12-15 09:58:07 +08:00
canisminor1990 705e8f7090 style: update memory cloud tag 2025-12-15 09:58:07 +08:00
Rene Wang af52fb887a style: Keep query while navigating 2025-12-15 09:58:07 +08:00
Rene Wang 3688e5f3eb style: Optimzie folder style in masonry 2025-12-15 09:58:07 +08:00
Neko f61f2ae589 🐛 fix(database,memory-user-memory): mismatched types & incorrect type for identity (#10717) 2025-12-15 09:58:07 +08:00
Rene Wang 9ac6f5fdc5 feat: Select own agent 2025-12-15 09:58:04 +08:00
arvinxx 2eca77a535 update api agent 2025-12-15 09:57:53 +08:00
canisminor1990 3b5da54660 style: update memory 2025-12-15 09:57:53 +08:00
Rene Wang b8ff536d14 lint: Clean up code 2025-12-15 09:57:53 +08:00
Rene Wang 4513f8ddd2 feat: New drag style 2025-12-15 09:57:53 +08:00
Rene Wang 1ebd67e00f feat: Add more navigation commands 2025-12-15 09:57:53 +08:00
canisminor1990 e279bdfb93 style: update Share 2025-12-15 09:57:53 +08:00
canisminor1990 515b7b1127 style: update Minimap 2025-12-15 09:57:53 +08:00
canisminor1990 2edc83b79c style: update ChatItem 2025-12-15 09:57:53 +08:00
Rene Wang cc3a310717 fix: Cannot go topic 2025-12-15 09:57:53 +08:00
canisminor1990 8a9451a69e style: update ChatItem 2025-12-15 09:57:53 +08:00
Rene Wang 84cc9ef336 feat: Context arwraing commands 2025-12-15 09:57:52 +08:00
Neko 2e14d9ff18 🐛 fix(memory-user-memory): populate source id, user id, langauge, tuned language of extractors (#10709) 2025-12-15 09:57:52 +08:00
canisminor1990 2082eb2292 style: update ChatItem 2025-12-15 09:57:52 +08:00
arvinxx 6c3ce6195d support grouping 2025-12-15 09:57:52 +08:00
Neko acd29d7e4d feat(database,memory-user-memory): include traceId of memory extraction into topic metadata (#10713) 2025-12-15 09:57:52 +08:00
Rene Wang 3f7f964c1b style: Masonry item style 2025-12-15 09:57:52 +08:00
arvinxx 7b45fc5831 refactor to remove feature flag 2025-12-15 09:57:52 +08:00
Neko f97df5c05e feat(observability-otel,memory-user-memory): expand resource attributes to include Vercel & Node.js attributes (#10712) 2025-12-15 09:57:52 +08:00
Rene Wang e2ce0e5f19 style: Adjust editor background 2025-12-15 09:57:52 +08:00
Rene Wang 40b1630eec fix: flushSync error 2025-12-15 09:57:52 +08:00
Rene Wang cc7d13db30 fix: Auto save not working if only modify title 2025-12-15 09:57:52 +08:00
canisminor1990 83df1f4339 style: update ChatItem 2025-12-15 09:57:52 +08:00
canisminor1990 42242c0c24 style: update ChatItem 2025-12-15 09:57:52 +08:00
canisminor1990 3f8c6c50f3 style: update ChatItem 2025-12-15 09:57:51 +08:00
canisminor1990 024f65a47e style: update ChatItem 2025-12-15 09:57:51 +08:00
canisminor1990 18f757e925 style: update ChatItem 2025-12-15 09:57:48 +08:00
arvinxx 33b5ac4bc8 move to /api/agent 2025-12-15 09:57:33 +08:00
arvinxx 55e177f0bd refactor for pure api call 2025-12-15 09:57:33 +08:00
Neko b43f78eac5 🐛 fix(memory-user-memory): existing memory not being passed as context of gatekeeper (#10703) 2025-12-15 09:57:33 +08:00
arvinxx 8cb4096d27 fix topic list 2025-12-15 09:57:33 +08:00
arvinxx 034c68e251 fix parentId 2025-12-15 09:57:33 +08:00
arvinxx 2f4ed1a016 fix plugins 2025-12-15 09:57:33 +08:00
Neko 8bafec3d8b 🔨 chore(memory-user-memory): add more attributes to tracing for user memory (#10702) 2025-12-15 09:57:33 +08:00
arvinxx 6b368c562d refactor runByAgentId to execAgent 2025-12-15 09:57:33 +08:00
arvinxx 72c5d74041 fix parentId issue 2025-12-15 09:57:33 +08:00
arvinxx 93a6d89b02 fix type 2025-12-15 09:57:33 +08:00
arvinxx 59a555ab9e improve tests 2025-12-15 09:57:33 +08:00
arvinxx 18f6f381c0 fix aiAgent runByAgentId 2025-12-15 09:57:33 +08:00
arvinxx 5df9feecfb fix aiAgent.integration.test.ts 2025-12-15 09:57:33 +08:00
arvinxx 0b8a3a2812 support server agent runtime with integration testing 2025-12-15 09:57:33 +08:00
arvinxx ef0d6a3d3a refactor GeneralChatAgent to agent runtime 2025-12-15 09:57:33 +08:00
arvinxx cedb98c3bf refactor agent service 2025-12-15 09:57:33 +08:00
Neko f98e60b679 feat(database): added queryTags for user memory (#10694)
* feat(database): added queryTags model for user memory

* feat(database): to trpc
2025-12-15 09:57:32 +08:00
Rene Wang 38cf8f28ac feat: Sync folder expand 2025-12-15 09:57:32 +08:00
Rene Wang 21333da8e7 feat: Add a close button 2025-12-15 09:57:32 +08:00
Shinji-Li 8293741560 feat: market profile page fixed done (#10696)
* feat: simple agents profile pages done

* feat: add the agent install report to market

* feat: show agent download tag

* feat: update the agent detial drawer create agent

* feat: add market identifier into agent meta

* feat: supportgetOwnAgents、deprecate、unpublish、publish agent api

* feat: update i18n
2025-12-15 09:57:32 +08:00
canisminor1990 bce556cd76 style: update message edit 2025-12-15 09:57:32 +08:00
Rene Wang 90f49a967f style: Warp title if too long 2025-12-15 09:57:32 +08:00
Rene Wang a8ca5f78f7 lint: Clean up codes 2025-12-15 09:57:32 +08:00
Rene Wang d89474a304 fix: Cannot open page in resource manager 2025-12-15 09:57:32 +08:00
canisminor1990 5c7775dc9f chore: clean chat list 2025-12-15 09:57:32 +08:00
canisminor1990 784128c96b chore: clean chat list 2025-12-15 09:57:32 +08:00
arvinxx ae87e83e22 clean chat input 2025-12-15 09:57:32 +08:00
arvinxx 84c43d9091 refactor to skip turbopack warning 2025-12-15 09:57:32 +08:00
Rene Wang 6444f27751 lint: Remove unused files 2025-12-15 09:57:32 +08:00
canisminor1990 4478e72d9c chore: clean chat list 2025-12-15 09:57:32 +08:00
canisminor1990 769c8871b7 chore: clean chat list 2025-12-15 09:57:32 +08:00
canisminor1990 d6d4290b9e chore: clean chat list 2025-12-15 09:57:32 +08:00
Rene Wang 7e7ef23fb3 feat: Notion guide video 2025-12-15 09:57:31 +08:00
Rene Wang eb65c57bfa feat: Batch delete documents 2025-12-15 09:57:31 +08:00
arvinxx bf7744f93b fix enable server agent 2025-12-15 09:57:31 +08:00
arvinxx 34183041e4 support runAgentById 2025-12-15 09:57:31 +08:00
arvinxx 93dda6bfee fix tests 2025-12-15 09:57:31 +08:00
arvinxx ab34b9c400 fix tests 2025-12-15 09:57:31 +08:00
arvinxx ff783b3fd4 fix tests 2025-12-15 09:57:31 +08:00
Shinji-Li 977dff7201 🔨 chore: update agent builder metion in ediotor canvas (#10691)
* fix: delete old market oidc way

* fix: clean some console.log

* fix: delete log

* fix: delete message console

* fix: delete some message

* feat: update metion plugin in editor canvas
2025-12-15 09:57:31 +08:00
Rene Wang bedbb00ec4 feat: Extract title from markdown 2025-12-15 09:57:31 +08:00
arvinxx 61a9ae2b22 fix tests 2025-12-15 09:57:31 +08:00
canisminor1990 b2f9b2288a chore: clean chat item 2025-12-15 09:57:31 +08:00
arvinxx d9a93c996e fix tools resolver 2025-12-15 09:57:30 +08:00
canisminor1990 69dbf4966d style: update default avatar 2025-12-15 09:57:30 +08:00
Rene Wang 18bb4cb95d feat: Import Notion 2025-12-15 09:57:30 +08:00
Shinji-Li 4717b8e945 🔨 chore: update the market oidc oauth (#10688)
* fix: delete old market oidc way

* fix: clean some console.log
2025-12-15 09:57:30 +08:00
Rene Wang acc6efb778 feat: Delete node 2025-12-15 09:57:30 +08:00
Rene Wang c9eeb26bcb feat: Update node 2025-12-15 09:57:30 +08:00
Neko e9ea0ef92a feat(memory-user-memory): capture extract steps data to otel & s3 (#10677) 2025-12-15 09:57:30 +08:00
Rene Wang 173c091d54 lint: Add doc for the agent 2025-12-15 09:57:30 +08:00
arvinxx 7c1a52298b refactor context engine 2025-12-15 09:57:30 +08:00
Arvin Xu fb742204e6 server agent runtime (#10686)
* add server agent runtime

* refactor with new mode

* refactor sessionId to operationId

* refactor message engines
2025-12-15 09:57:28 +08:00
arvinxx bed9e7e797 update i18n 2025-12-15 09:57:10 +08:00
Shinji-Li b25f669ca6 🔨 chore: update the toggleAgentPlugin into config tools (#10682)
* feat: remove the installPlugins invetion plugins button ,use appreove is enought

* feat: update the toggle agent into agent config tools
2025-12-15 09:57:09 +08:00
canisminor1990 d6316566f7 style: update memory panel 2025-12-15 09:57:09 +08:00
Rene Wang a1520e643e feat: Page copilot 2025-12-15 09:57:09 +08:00
Shinji-Li 1cc10d3f31 🐛 fix: remove the installPlugins invertion button,use defalut approve instead (#10681)
feat: remove the installPlugins invetion plugins button ,use appreove is enought
2025-12-15 09:57:09 +08:00
Shinji-Li fb37fd0ca1 🔨 chore:change the updateMeta tools into updateConfig tools (#10678)
* fix: update agent meta into config

* feat: add agent builder welcome

* feat: update locals
2025-12-15 09:57:09 +08:00
arvinxx 99400b1ac2 try to fix operation 2025-12-15 09:57:09 +08:00
arvinxx 7fa6e406d8 fix approve and push 2025-12-15 09:57:09 +08:00
Rene Wang 0bf2d38089 feat: Tap header to go root folder 2025-12-15 09:57:09 +08:00
Shinji-Li 23a26a567f feat: when install tools have oauth ,should oauth in approve way (#10675)
feat: add auto oauth when click approve
2025-12-15 09:57:09 +08:00
Rene Wang bfced7f8ec style: Add density for the tree 2025-12-15 09:57:09 +08:00
Rene Wang 5f097faa5f fix: No loading if cached 2025-12-15 09:57:09 +08:00
canisminor1990 1b51e9deba style: update right panel 2025-12-15 09:57:09 +08:00
arvinxx b298ec9c6d Update i18n 2025-12-15 09:57:09 +08:00
canisminor1990 af471ee9f4 style: rename action 2025-12-15 09:57:09 +08:00
canisminor1990 978c7498e5 style: rename action 2025-12-15 09:57:09 +08:00
Rene Wang 8181028c09 style: Move the close button 2025-12-15 09:57:08 +08:00
arvinxx 1b4ec2d1e4 support always intervention 2025-12-15 09:57:08 +08:00
Rene Wang af9af15861 feat: Select All checkbox 2025-12-15 09:57:08 +08:00
Shinji-Li 9708d90533 feat: change all get tools into one & change tools into context (#10670)
* feat: change all set fc in agentbuilder to one tool

* feat: add offical tools into context
2025-12-15 09:57:08 +08:00
arvinxx b85ac86253 fix intervention issue 2025-12-15 09:57:08 +08:00
Rene Wang 0d20305aac refac: Make the file preview overflow 2025-12-15 09:57:08 +08:00
arvinxx 761420d6ef fix title and avatar 2025-12-15 09:57:08 +08:00
Shinji-Li 2356adb6cc feat: add the agentbuilder inject context & delete some get tools (#10666)
* feat: add agent builder inject context well down

* fix: delelte some get agentbuilder tools
2025-12-15 09:57:08 +08:00
Rene Wang f632ed48d2 feat: Allow DND in tree 2025-12-15 09:57:08 +08:00
arvinxx e07605a6fe push group route 2025-12-15 09:57:08 +08:00
arvinxx bef203f8ff refactor agent group 2025-12-15 09:57:08 +08:00
arvinxx 582aed80ac refactor create group 2025-12-15 09:57:08 +08:00
arvinxx da282c75d6 refactor create group 2025-12-15 09:57:08 +08:00
Rene Wang 8fbc4e4a0a feat: Page copilot 2025-12-15 09:57:08 +08:00
Shinji-Li bf037e333e feat: support approve install plugins in agent builder (#10662)
* feat: update agent builder tools call way

* feat: add install Plugins tools & add human approve intervation
2025-12-15 09:57:08 +08:00
arvinxx 0ab5195c11 support delete memory 2025-12-15 09:57:08 +08:00
arvinxx 7b779b2e5a improve start input action 2025-12-15 09:57:08 +08:00
arvinxx 06b3ee068d fix delete agent and improve start input action 2025-12-15 09:57:07 +08:00
arvinxx fff1e4f10e fix create agent flow 2025-12-15 09:57:07 +08:00
arvinxx e486e15c64 refactor sessionStore to agentStore 2025-12-15 09:57:07 +08:00
Rene Wang 1e332bec07 lint: Clean up coed 2025-12-15 09:57:07 +08:00
arvinxx 206cf8c645 refactor Conversation with agentStore 2025-12-15 09:57:07 +08:00
arvinxx 9fc8a59458 refactor home page using homeStore and agentStore 2025-12-15 09:57:07 +08:00
arvinxx 067970d532 refactor sidebar using homeStore and agentStore 2025-12-15 09:57:07 +08:00
arvinxx 837bf8c461 refactor agent using homeStore and agentStore 2025-12-15 09:57:07 +08:00
arvinxx c0f77deeab home store 2025-12-15 09:57:07 +08:00
canisminor1990 392cdef720 style: fix auth card 2025-12-15 09:57:07 +08:00
canisminor1990 1fbb302e25 style: update memory card 2025-12-15 09:57:07 +08:00
canisminor1990 5f81bcdea6 style: update memory card 2025-12-15 09:56:56 +08:00
arvinxx bee3f8e456 Home Repo 2025-12-15 09:56:56 +08:00
canisminor1990 1b239e229b style: update memory GroupedVirtuoso 2025-12-15 09:56:56 +08:00
Rene Wang 749c90c105 feat: Optimize hook 2025-12-15 09:56:56 +08:00
canisminor1990 9ed52c4135 style: update memory 2025-12-15 09:56:56 +08:00
Rene Wang dcd1989faa lint: Clean up codes 2025-12-15 09:56:56 +08:00
canisminor1990 3438c3de9f style: update memory 2025-12-15 09:56:55 +08:00
arvinxx d25ed06845 refactor send 2025-12-15 09:56:55 +08:00
arvinxx e186e27d71 support switch input mode 2025-12-15 09:56:55 +08:00
Rene Wang a1b77bc7ac refac: Reorgnize folders 2025-12-15 09:56:55 +08:00
Rene Wang d9a0cc5ca2 lint: Remove unused files 2025-12-15 09:56:55 +08:00
Rene Wang 100f114e6b refac: Clean up code 2025-12-15 09:56:55 +08:00
Rene Wang 269c9a5faf opti: Better dnd performance 2025-12-15 09:56:55 +08:00
Rene Wang 8cfb934afd opti: Better D & D performance 2025-12-15 09:56:55 +08:00
Rene Wang 2fa471bb62 refac: Clean up code 2025-12-15 09:56:55 +08:00
arvinxx 239f3e7154 fix build 2025-12-15 09:56:55 +08:00
arvinxx ed36d3b760 try to fix build 2025-12-15 09:56:55 +08:00
Neko aaf2045835 ♻️ refactor(memory-user-memory): better structure, added tests, simplified executor (#10641) 2025-12-15 09:56:55 +08:00
Neko 110f3c7b1e 🔨 chore(userMemories): improved the results of memory extractor (#10636) 2025-12-15 09:56:55 +08:00
canisminor1990 2ecf47f89b chore: rm unused loading 2025-12-15 09:56:54 +08:00
canisminor1990 0d5a5f1e37 style: update market-auth-callback 2025-12-15 09:56:54 +08:00
arvinxx d9f812a026 Context 情景记忆 2025-12-15 09:56:54 +08:00
canisminor1990 e8ffde951f style: update oidc style 2025-12-15 09:56:54 +08:00
arvinxx e1e8416583 Preference 偏好记忆 2025-12-15 09:56:54 +08:00
arvinxx ce1806b88a Experience 经验记忆 2025-12-15 09:56:54 +08:00
arvinxx 10fdf24bb4 Experience 经验记忆 2025-12-15 09:56:54 +08:00
canisminor1990 8d2959f455 style: update market-auth-callback 2025-12-15 09:56:54 +08:00
Rene Wang 2036b75095 refac: Clean up code 2025-12-15 09:56:54 +08:00
Rene Wang a7facab0ca fix: back button 2025-12-15 09:56:54 +08:00
Rene Wang c3526ac0f3 fix: View mode 2025-12-15 09:56:54 +08:00
Rene Wang 0c8c8cf83f fix: Update library id based on URL 2025-12-15 09:56:54 +08:00
canisminor1990 ba96c6d2f6 fix: fix home market avatar z-index 2025-12-15 09:56:54 +08:00
Rene Wang 286c62660c refac: State & UI 2025-12-15 09:56:54 +08:00
canisminor1990 3d286323c5 fix: roll back file pagesize 2025-12-15 09:56:53 +08:00
Rene Wang cbf19e1c89 style: Update header 2025-12-15 09:56:53 +08:00
Rene Wang d34130e8a6 fix: Root folder 2025-12-15 09:56:53 +08:00
canisminor1990 46e376a0fd fix: fix mobile 2025-12-15 09:56:53 +08:00
canisminor1990 efb6c9f350 fix: fix mobile 2025-12-15 09:56:53 +08:00
canisminor1990 598c840684 fix: fix mobile 2025-12-15 09:56:53 +08:00
Shinji-Li 9d7975ee1f feat: add more official tools into agentbuilder (#10638)
*  feat: add klavis tools into agent builder

- Add searchOfficialTools API for searching builtin and Klavis integrations
- Add searchMarketTools API for searching marketplace plugins
- Update AgentTool.tsx with dual-column Segmented tabs layout (All/Installed)
- Update PluginTag.tsx to support Klavis tools display with proper icons
- Fix pre-existing type errors in Header.tsx and pluginTypes.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* 🐛 fix: resolve circular dependencies in klavisStore imports

- Import KlavisServerStatus from types.ts instead of index.ts
- Import selectors from @/store/tool/selectors instead of slice paths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-15 09:56:53 +08:00
canisminor1990 e5a5c772e0 style: update res pagesize 2025-12-15 09:56:53 +08:00
canisminor1990 25d4a46550 style: update agent setting 2025-12-15 09:56:51 +08:00
Neko 8e8a2beee4 fix(userMemories): Upstash Workflows will serialize into JSON, causes the Date being incorrectly used (#10635) 2025-12-15 09:56:43 +08:00
canisminor1990 016c6360aa style: update agent publish 2025-12-15 09:56:43 +08:00
Shinji-Li 842cae1ae2 feat: add modify system prompt tools in agentbuilder (#10634)
* feat: support modify system role & use stream output

* fix: slove metion dropdown position
2025-12-15 09:56:43 +08:00
canisminor1990 0e2a739e6a style: update page sidebar 2025-12-15 09:56:40 +08:00
canisminor1990 214d156e09 feat: add Suspense debug 2025-12-15 09:55:11 +08:00
canisminor1990 fbed2713bb feat: add Suspense debug 2025-12-15 09:55:11 +08:00
Neko 6d4d77a854 🐛 fix: expose /api/workflows endpoints (#10632) 2025-12-15 09:55:11 +08:00
canisminor1990 b22d30e13b fix: fix DraggablePanel 2025-12-15 09:55:11 +08:00
canisminor1990 15b73b9627 fix: fix cmd k register 2025-12-15 09:55:11 +08:00
canisminor1990 89abb0e87c fix: fix auto save 2025-12-15 09:55:08 +08:00
canisminor1990 6030b1794d style: update editor 2025-12-15 09:54:58 +08:00
Neko 7a50256d04 feat(userMemories): unify layer names, add webhook verify headers (#10629) 2025-12-15 09:54:58 +08:00
canisminor1990 f1df8bd50b style: update editor 2025-12-15 09:54:57 +08:00
canisminor1990 a4bbd63abc style: update editor 2025-12-15 09:54:57 +08:00
canisminor1990 f11c7d2033 style: update agent builder 2025-12-15 09:54:57 +08:00
canisminor1990 1751c4a644 fix: DraggablePanel 2025-12-15 09:54:57 +08:00
canisminor1990 ceac3493e1 fix: DraggablePanel 2025-12-15 09:54:57 +08:00
arvinxx cbdd083c8d refactor the switch branch 2025-12-15 09:54:57 +08:00
arvinxx 73bae654e9 fix topic messages issues 2025-12-15 09:54:57 +08:00
canisminor1990 5e70f6de14 style: update auth 2025-12-15 09:54:57 +08:00
Neko e4734bf1a1 feat(userMemories): whitelist to extract users (#10626)
feat(userMemories): whitelist to extract users for
2025-12-15 09:54:56 +08:00
Shinji-Li f45ab6bc92 feat: add more acions & can modify provider and model (#10625)
* feat: add topic selectror

* feat: add model change & history change way

* feat: add more tools
2025-12-15 09:54:56 +08:00
Shinji-Li b38bf6fb2e 🔨 chore: change the settings sub router to / path (#10617)
feat: change the settings sub router to / path
2025-12-15 09:54:56 +08:00
Rene Wang 4eae151235 feat: Support blocks 2025-12-15 09:54:56 +08:00
Rene Wang 8a3158d781 fix: Update filter 2025-12-15 09:54:56 +08:00
arvinxx 9492b58531 fix topic models update issues 2025-12-15 09:54:56 +08:00
Neko fc1b56fe0b feat(userMemory): with Upstash Workflows for memory extractor (#10623)
feat(userMemory): with Upstash Workflows for memory extractor
2025-12-15 09:54:53 +08:00
arvinxx 9cb1b0fbb6 support send message 2025-12-15 09:54:36 +08:00
Neko f7c7b5ab53 ♻️ refactor(server/modules/s3): improved constructor to accept options without taking env directly (#10624)
refactor(server/modules/s3): improved constructor to accept options without taking env directly
2025-12-15 09:54:36 +08:00
Rene Wang 8049af8b24 fix: Exclude mapped document 2025-12-15 09:54:36 +08:00
Rene Wang 4d0814b536 feat: Header 2025-12-15 09:54:36 +08:00
Rene Wang 4e29a9cb38 feat: Renaming KB 2025-12-15 09:54:36 +08:00
Rene Wang 52a0333ea2 fix: Changelog dialog crashing 2025-12-15 09:54:36 +08:00
arvinxx a5dcd83431 refactor model list 2025-12-15 09:54:36 +08:00
arvinxx 5a442b1f84 update tests 2025-12-15 09:54:36 +08:00
Rene Wang 8ef4e4114e fix: Change log modal 2025-12-15 09:54:36 +08:00
Rene Wang 6756812486 fix: Update translation 2025-12-15 09:54:36 +08:00
arvinxx ca77218968 refactor chat input issue 2025-12-15 09:54:36 +08:00
Rene Wang 76f47dfe27 style: Changelog modal 2025-12-15 09:54:36 +08:00
Rene Wang 0c770cb837 feat: Download document 2025-12-15 09:54:36 +08:00
Rene Wang 14002c32e9 fix: Remove unncessary tRPC calling 2025-12-15 09:54:36 +08:00
arvinxx 5863c17793 refactor topic issues 2025-12-15 09:54:36 +08:00
canisminor1990 7272fb0b19 style: fix cursor 2025-12-15 09:54:35 +08:00
Shinji-Li 4c22c8e4af 🔨 chore: delete the url hydration & romove the pin agent way (#10616)
fix: delete the url Hydration & delete pinagent way
2025-12-15 09:54:35 +08:00
Rene Wang 5202895c8d fix: Deduplication 2025-12-15 09:54:35 +08:00
canisminor1990 6c2539b105 style: fix cursor 2025-12-15 09:54:35 +08:00
canisminor1990 3db41e0deb style: update editor style 2025-12-15 09:54:35 +08:00
canisminor1990 5c1729eb4d style: update style 2025-12-15 09:54:35 +08:00
arvinxx 6e0fdb87cf fix 2025-12-15 09:54:35 +08:00
Rene Wang 886e43b7fd feat: Discard the page editor modal 2025-12-15 09:54:35 +08:00
Rene Wang 8fd062b9af refac: Renaming files 2025-12-15 09:54:35 +08:00
Rene Wang 2856152a24 fix: Page explore 2025-12-15 09:54:35 +08:00
Rene Wang cc24f08c34 opti: Better file loading 2025-12-15 09:54:35 +08:00
canisminor1990 cc6cf4f4ab chore: add knip cli 2025-12-15 09:54:34 +08:00
arvinxx c706e0cf85 ♻️ refactor(agent): add getAgentConfigById to AgentService with default config merging
- Add getAgentConfigById method to AgentService that merges default configs
- Extract mergeDefaultConfig as private helper method for code reuse
- Update router to use agentService.getAgentConfigById instead of agentModel
- Update updateAgentConfig to return merged config via getAgentConfigById
- Add tests for getAgentConfigById merging behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 09:54:34 +08:00
arvinxx 44a2846983 fix 2025-12-15 09:54:34 +08:00
arvinxx eddbea1180 🐛 fix(agent): merge default configs for builtin agents on server side
- Add DEFAULT_AGENT_CONFIG and serverDefaultAgentConfig merging in AgentService.getBuiltinAgent
- Ensures inbox agent always has complete config with model/provider
- Update selector comment to document server-side merging behavior
- Add unit tests for config merging behavior

Closes LOBE-1447

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 09:54:34 +08:00
canisminor1990 52c526a216 style: update chat item style 2025-12-15 09:54:34 +08:00
canisminor1990 5be282b591 style: update chat item style 2025-12-15 09:54:34 +08:00
canisminor1990 4ffd13310d style: update new topic button 2025-12-15 09:54:34 +08:00
canisminor1990 5b69b2c574 style: update agent welcome and chatinput 2025-12-15 09:54:34 +08:00
canisminor1990 247028750d style: update agent welcome and chatinput 2025-12-15 09:54:34 +08:00
Rene Wang d65c169663 opti: Save on blur 2025-12-15 09:54:34 +08:00
canisminor1990 102a47ac01 fix: systemrole editor init 2025-12-15 09:54:33 +08:00
Shinji-Li ae794c9d17 🐛 fix: slove the command k jump link error (#10614)
fix: fixed command k router error
2025-12-15 09:54:33 +08:00
Rene Wang 3f3d2856b4 fix: Jump link 2025-12-15 09:54:33 +08:00
Rene Wang 671ba275fc fix: Copilot size 2025-12-15 09:54:33 +08:00
arvinxx bfd8f3d2bc update for test 2025-12-15 09:54:33 +08:00
arvinxx 5b9e3f906d update for test 2025-12-15 09:54:33 +08:00
arvinxx 40c748ec9e fix topic with inbox agent 2025-12-15 09:54:33 +08:00
arvinxx 8c3c8d712f refactor agent 2025-12-15 09:54:33 +08:00
arvinxx 98e385a3d4 refactor agent 2025-12-15 09:54:33 +08:00
canisminor1990 8f0e2f27aa style: update icon 2025-12-15 09:54:33 +08:00
canisminor1990 55a4d9a7a9 style: update create icon 2025-12-15 09:54:29 +08:00
Rene Wang abe154d9c0 style: Search in CMDK 2025-12-15 09:54:02 +08:00
Rene Wang 4a619725c2 refac: Lint code style 2025-12-15 09:54:02 +08:00
Rene Wang b4d5faff38 style: Adjust padding 2025-12-15 09:54:02 +08:00
Neko db9a99fc72 feat(userMemories): extract from user memory, add new memory-user-memory package (#10514)
* feat(userMemories): extract from user memory, add new memory-user-memory package

* chore: missing deps

* chore: missing test
2025-12-15 09:54:00 +08:00
arvinxx 07ff5acbb6 inbox 2025-12-15 09:53:37 +08:00
arvinxx 0b19fdd9bf refactor inbox agent store 2025-12-15 09:53:37 +08:00
arvinxx f0493b4cf1 support session get inbox 2025-12-15 09:53:37 +08:00
Rene Wang b25cf4c4c6 feat: Support JS rendering 2025-12-15 09:53:37 +08:00
canisminor1990 0a56d3cc90 pref: update topic count 2025-12-15 09:53:35 +08:00
Rene Wang 43de4e47f7 feat: Handle gitignore 2025-12-15 09:53:27 +08:00
arvinxx c0de93a2dd add welcome for agent 2025-12-15 09:53:27 +08:00
Rene Wang b5c7cee5fe feat: Upload folder 2025-12-15 09:53:27 +08:00
arvinxx 4c39157a29 improve conversation width 2025-12-15 09:53:27 +08:00
arvinxx 9cf7c0a7de support config plugins 2025-12-15 09:53:26 +08:00
Rene Wang 42d7f8cca8 feat: Unfiied search 2025-12-15 09:53:26 +08:00
canisminor1990 ae2f4c29e1 style: update style 2025-12-15 09:53:26 +08:00
canisminor1990 00e504d365 style: clean add button 2025-12-15 09:53:26 +08:00
canisminor1990 305483dacc feat: add agent more 2025-12-15 09:53:26 +08:00
canisminor1990 04bb8273dc feat: add agent more 2025-12-15 09:53:26 +08:00
Rene Wang 821c95d9d1 style: Turn changelog to a modal 2025-12-15 09:53:26 +08:00
canisminor1990 ab603f95c3 feat: add agent more 2025-12-15 09:53:26 +08:00
canisminor1990 870ac6aa45 feat: add topic more 2025-12-15 09:53:22 +08:00
Arvin Xu 216f367300 memory-panel (#10598)
* 🐛 fix: missing init user after user creation (#10587)

* 🌐 chore: translate non-English comments to English in python-interpreter (#10568)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* 基本完成简单的 memory 提取

finish extract

identity 提取

refactor prompts and implements

add memory layers

refactor GateKeeper generate

improve GateKeeper generate

init packages

add messages prompts

 feat(memory): add filter, search and i18n for identity panel

- Add demographic identity type support
- Implement complete i18n with memory namespace
- Add type filter (personal/professional/demographic) and search functionality
- Remove Preference and Experience features (keep only Identity and Context)
- Optimize UI layout: left-right filter/view mode layout
- Add collapsible role tag cloud
- Refactor types to use strict IdentityType enum
- Fix type definitions and imports to use @lobechat/types consistently

finish Identity ui

update

wip for memory panel

* update

* rename

* refactor memory

* update
2025-12-15 09:53:00 +08:00
canisminor1990 801c744522 style: update welcome speed 2025-12-15 09:52:56 +08:00
Rene Wang e92ea2c206 feat: Move help center to the footer 2025-12-15 09:52:43 +08:00
Rene Wang e8e1c65503 style: Add tooltip 2025-12-15 09:52:43 +08:00
Rene Wang b6616a832f feat: New AddButton 2025-12-15 09:52:43 +08:00
canisminor1990 04020fc804 style: update i18n 2025-12-15 09:52:43 +08:00
arvinxx 7519d6f3f1 fix types 2025-12-15 09:52:43 +08:00
Shinji-Li 27deea97cf style: update layout
 test: update tests (#10510)

♻️ refactor: refactor update agent config implement (#10507)

♻️ refactor: refactor session store to agent store (#10485)

♻️ refactor: refactor with new conversation store (#10483)

 feat: add editor data into market agent (#10451)

feat: Create Folder in Repo (#10352)

🔨 chore: delete editor content sql migration (#10449)

💄 style(wip): LobeHub Next UI Refactor (#10388)

 feat: change agent settings drawer to editor mode (#10392)

* 🐛 fix: Showing compatibility with both new and old versions of Plugins (#10418)

---------

Co-authored-by: Arvin Xu <arvinx@foxmail.com>
Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2025-12-15 09:52:38 +08:00
lobehubbot 3230f13817 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-14 17:22:36 +00:00
semantic-release-bot c3ca76293c 🔖 chore(release): v2.0.0-next.171 [skip ci]
## [Version&nbsp;2.0.0-next.171](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.170...v2.0.0-next.171)
<sup>Released on **2025-12-14**</sup>

#### 💄 Styles

- **misc**: Update GPT-5.2 models.

<br/>

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

#### Styles

* **misc**: Update GPT-5.2 models, closes [#10749](https://github.com/lobehub/lobe-chat/issues/10749) ([0446127](https://github.com/lobehub/lobe-chat/commit/0446127))

</details>

<div align="right">

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

</div>
2025-12-14 17:21:10 +00:00
Arvin Xu 81e56d462a ️ perf: add more sql to improve performance (#10777)
* Update sql

* fix user tests
2025-12-15 01:06:34 +08:00
sxjeru 0446127910 💄 style: Update GPT-5.2 models (#10749)
*  feat: 添加 GPT-5.2 和 GPT-5.2 Pro 模型及其推理努力滑块

*  feat: 添加 GPT-5.2 和 GPT-5.2 Pro 模型,更新 GPT-5.2 推理努力滑块的标签

* 🔨 chore: 移除 GPT-5.2 和 GPT-5.2 Pro 模型的推理努力参数及相关组件

*  feat: 更新模型设置,添加 gpt5_1ReasoningEffort 参数
2025-12-12 23:28:51 +08:00
YuTengjing 79b2afda70 revert: remove after() from image router to avoid resource waste (#10757) 2025-12-12 23:07:05 +08:00
YuTengjing 0287239975 🔨 chore: optimize not await async call (#10756) 2025-12-12 22:58:09 +08:00
lobehubbot 2da6cac673 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-12 12:25:26 +00:00
semantic-release-bot c46bd6e49a 🔖 chore(release): v2.0.0-next.170 [skip ci]
## [Version&nbsp;2.0.0-next.170](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.169...v2.0.0-next.170)
<sup>Released on **2025-12-12**</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-12-12 12:24:16 +00:00
René Wang ec3aa6ba3b ️ perf(database): optimize document queries with indexes and field exclusion (#10740)
* ️ perf(database): optimize document queries with indexes and field exclusion

- Add database indexes for source_type and compound query optimization
- Exclude large JSONB fields (content, pages, editorData) in list queries
- Improve queryDocuments performance for better list view rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* feat: Add migration

* ️ perf(database): missing user_id and user_memory_id index

* refac: Use select

* fix: migration

* fix: Test error

* Update sql

* feat(userMemories): added captured_at column

* fix: not null default now

* fix: not null default now

* Update sql

* Update sql

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Neko Ayaka <neko@ayaka.moe>
Co-authored-by: arvinxx <arvinx@foxmail.com>
2025-12-12 20:09:08 +08:00
lobehubbot 0cb8bc1179 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-12 04:39:50 +00:00
semantic-release-bot 5cb69336b2 🔖 chore(release): v2.0.0-next.169 [skip ci]
## [Version&nbsp;2.0.0-next.169](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.168...v2.0.0-next.169)
<sup>Released on **2025-12-12**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix CVE errors.

<br/>

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

#### What's fixed

* **misc**: Fix CVE errors, closes [#10748](https://github.com/lobehub/lobe-chat/issues/10748) ([6591f3c](https://github.com/lobehub/lobe-chat/commit/6591f3c))

</details>

<div align="right">

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

</div>
2025-12-12 04:38:41 +00:00
LobeHub Bot 84a6e711b8 🌐 chore: translate non-English comments to English in file-loaders (#10746)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 12:25:17 +08:00
Pleasure1234 6591f3c4ef 🐛 fix: fix CVE errors (#10748)
fix: fix cve errors
2025-12-12 12:23:10 +08:00
lobehubbot 157d9accbc 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-12 03:44:09 +00:00
semantic-release-bot 7cfc9a28e4 🔖 chore(release): v2.0.0-next.168 [skip ci]
## [Version&nbsp;2.0.0-next.168](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.167...v2.0.0-next.168)
<sup>Released on **2025-12-12**</sup>

#### 🐛 Bug Fixes

- **misc**: Slove market oidc error.

<br/>

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

#### What's fixed

* **misc**: Slove market oidc error, closes [#10715](https://github.com/lobehub/lobe-chat/issues/10715) ([108d2a7](https://github.com/lobehub/lobe-chat/commit/108d2a7))

</details>

<div align="right">

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

</div>
2025-12-12 03:42:53 +00:00
Shinji-Li 108d2a72a5 🐛 fix: slove market oidc error (#10715)
fix: slove oidc error
2025-12-12 11:27:59 +08:00
lobehubbot 05ca7b51be 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-11 10:31:14 +00:00
semantic-release-bot 2923bc676e 🔖 chore(release): v2.0.0-next.167 [skip ci]
## [Version&nbsp;2.0.0-next.167](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.166...v2.0.0-next.167)
<sup>Released on **2025-12-11**</sup>

####  Features

- **misc**: Add Replicate image provider.

<br/>

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

#### What's improved

* **misc**: Add Replicate image provider ([542f4d9](https://github.com/lobehub/lobe-chat/commit/542f4d9))

</details>

<div align="right">

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

</div>
2025-12-11 10:29:50 +00:00
YuTengjing a44224132a 🔨 chore: fix ci (#10735) 2025-12-11 17:53:54 +08:00
YuTengjing 458cbf4d5c 🔨 chore: track user last active time (#10733) 2025-12-11 17:14:25 +08:00
Oriol Rius 542f4d97dd feat: Add Replicate image provider
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 15:43:00 +08:00
lobehubbot 1c187063cd 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-09 09:33:36 +00:00
semantic-release-bot 82424887dd 🔖 chore(release): v2.0.0-next.166 [skip ci]
## [Version&nbsp;2.0.0-next.166](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.165...v2.0.0-next.166)
<sup>Released on **2025-12-09**</sup>

#### 🐛 Bug Fixes

- **Dockerfile**: Electron main typing pkg.

<br/>

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

#### What's fixed

* **Dockerfile**: Electron main typing pkg, closes [#10693](https://github.com/lobehub/lobe-chat/issues/10693) ([f3357b0](https://github.com/lobehub/lobe-chat/commit/f3357b0))

</details>

<div align="right">

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

</div>
2025-12-09 09:32:21 +00:00
Innei f3357b0b46 🔧 fix(Dockerfile): electron main typing pkg (#10693) 2025-12-09 17:17:31 +08:00
lobehubbot a8822940b3 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-09 07:18:25 +00:00
semantic-release-bot f312661166 🔖 chore(release): v2.0.0-next.165 [skip ci]
## [Version&nbsp;2.0.0-next.165](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.164...v2.0.0-next.165)
<sup>Released on **2025-12-09**</sup>

#### ♻ Code Refactoring

- **electron-main**: Client ipc decorate.

<br/>

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

#### Code refactoring

* **electron-main**: Client ipc decorate, closes [#10679](https://github.com/lobehub/lobe-chat/issues/10679) ([f74befa](https://github.com/lobehub/lobe-chat/commit/f74befa))

</details>

<div align="right">

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

</div>
2025-12-09 07:17:05 +00:00
Innei f74befadc9 ♻️ refactor(electron-main): client ipc decorate (#10679)
* refactor: client ipc

* refactor: server ipc

refactor: update IPC method names for consistency

Signed-off-by: Innei <tukon479@gmail.com>

fix: cast IPC return type to DesktopIpcServices for type safety

Signed-off-by: Innei <tukon479@gmail.com>

chore: add new workspace for desktop application in package.json

Signed-off-by: Innei <tukon479@gmail.com>

fix: export FileMetadata interface for improved accessibility

Signed-off-by: Innei <tukon479@gmail.com>

refactor: unify IPC mocking across test files for consistency

Signed-off-by: Innei <tukon479@gmail.com>

feat: enhance type-safe IPC flow with context propagation and service registry

- Introduced `getIpcContext()` and `runWithIpcContext()` for improved context management in IPC handlers.
- Updated `BrowserWindowsCtr` methods to utilize the new context handling.
- Added `McpInstallCtr` to the IPC constructors registry.
- Enhanced README with details on the new type-safe IPC features.

Signed-off-by: Innei <tukon479@gmail.com>

refactor: enhance IPC method registration for improved type safety

- Updated `registerMethod` in `IpcHandler` and `IpcService` to accept variable argument types, enhancing flexibility in method signatures.
- Simplified the `ExtractMethodSignature` type to support multiple arguments.

Signed-off-by: Innei <tukon479@gmail.com>

chore: add global type definitions and refactor import statements

- Introduced a new global type definition file to support Vite client imports.
- Refactored import statements in `App.ts` and `App.test.ts` to remove unnecessary type casting for `import.meta.glob`, improving code clarity.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: make groupName in BrowserWindowsCtr readonly for better encapsulation

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update IPC method registration and usage for improved type safety and consistency

- Replaced `@ipcClientEvent` with `@IpcMethod()` in various controllers to standardize IPC method definitions.
- Enhanced the usage of `ensureElectronIpc()` for type-safe IPC calls in service layers.
- Updated `BrowserWindowsCtr` and `NotificationCtr` to utilize the new IPC method structure, improving encapsulation and clarity.
- Refactored service methods to eliminate manual string concatenation for IPC event names, ensuring better maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2025-12-09 15:01:18 +08:00
Neko a775f6544c 🔨 chore(deps): remove @types/ioredis as ioredis now provides types by default (#10654)
chore(deps): remove @types/ioredis as ioredis now provides types by default
2025-12-09 10:34:00 +08:00
lobehubbot d9c4d672ca 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-08 10:33:09 +00:00
semantic-release-bot 8645a6db16 🔖 chore(release): v2.0.0-next.164 [skip ci]
## [Version&nbsp;2.0.0-next.164](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.163...v2.0.0-next.164)
<sup>Released on **2025-12-08**</sup>

#### 💄 Styles

- **profile**: Add mobile responsive layout and signup improvements.
- **misc**: Update link handling in PlanTag component to use react-router-dom.

<br/>

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

#### Styles

* **profile**: Add mobile responsive layout and signup improvements, closes [#10669](https://github.com/lobehub/lobe-chat/issues/10669) ([1afd471](https://github.com/lobehub/lobe-chat/commit/1afd471))
* **misc**: Update link handling in PlanTag component to use react-router-dom, closes [#10673](https://github.com/lobehub/lobe-chat/issues/10673) ([3aceeb6](https://github.com/lobehub/lobe-chat/commit/3aceeb6))

</details>

<div align="right">

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

</div>
2025-12-08 10:31:51 +00:00
YuTengjing 3aceeb6d94 💄 style: update link handling in PlanTag component to use react-router-dom (#10673) 2025-12-08 18:13:31 +08:00
YuTengjing 1afd4710e7 💄 style(profile): add mobile responsive layout and signup improvements (#10669) 2025-12-08 18:05:07 +08:00
lobehubbot b09361fbce 📝 docs(bot): Auto sync agents & plugin to readme 2025-12-06 14:55:13 +00:00
semantic-release-bot ecdda9d452 🔖 chore(release): v2.0.0-next.163 [skip ci]
## [Version&nbsp;2.0.0-next.163](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.162...v2.0.0-next.163)
<sup>Released on **2025-12-06**</sup>

#### 🐛 Bug Fixes

- **misc**: Add smooth scroll to top on 'More' button click in Title component.

<br/>

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

#### What's fixed

* **misc**: Add smooth scroll to top on 'More' button click in Title component, closes [#10178](https://github.com/lobehub/lobe-chat/issues/10178) ([5ad4f0c](https://github.com/lobehub/lobe-chat/commit/5ad4f0c))

</details>

<div align="right">

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

</div>
2025-12-06 14:53:55 +00:00
bbbugg 5ad4f0c3ad 🐛 fix: add smooth scroll to top on 'More' button click in Title component (#10178)
* 💄 style: implement smooth scroll to top functionality in Nav and Title components

* fix: update link handling in Title component for improved navigation

* fix: enhance pagination scrolling behavior for mobile responsiveness
2025-12-06 22:39:05 +08:00
2092 changed files with 133622 additions and 41860 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ alwaysApply: false
# Database Migrations Guide
## Step1: Generate migrations:
## Step1: Generate migrations
```bash
bun run db:generate
@@ -36,13 +36,13 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
1. **创建控制器 (Controller)**
- 位置:`apps/desktop/src/main/controllers/`
- 示例:创建 `NewFeatureCtr.ts`
- 规范:按 `_template.ts` 模板格式实现
- 注册:在 `apps/desktop/src/main/controllers/index.ts` 导出
- 需继承 `ControllerModule`,并设置 `static readonly groupName`(例如 `static override readonly groupName = 'newFeature';`
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors`(或 `controllerServerIpcConstructors`)中注册,保证类型推导与自动装配
2. **定义 IPC 事件处理器**
- 使用 `@ipcClientEvent('eventName')` 装饰器注册事件处理函数
- 处理函数应接收前端传递的参数并返回结果
- 处理可能的错误情况
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道,或使用 `@IpcServerMethod()` 声明仅供 Next.js 服务器调用的 IPC
- 通道名称基于 `groupName.methodName` 自动生成,不再手动拼接字符串
- 处理函数可通过 `getIpcContext()` 获取 `sender`、`event` 等上下文信息,并按照需要返回结构化结果
3. **实现业务逻辑**
- 可能需要调用 Electron API 或 Node.js 原生模块
@@ -60,15 +60,17 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
1. **创建服务层**
- 位置:`src/services/electron/`
- 添加服务方法调用 IPC
- 使用 `dispatch` 或 `invoke` 函数
- 使用 `ensureElectronIpc()` 生成的类型安全代理,避免手动拼通道名称
```typescript
// src/services/electron/newFeatureService.ts
import { dispatch } from '@lobechat/electron-client-ipc';
import { NewFeatureParams } from 'types';
import { ensureElectronIpc } from '@/utils/electron/ipc';
import type { NewFeatureParams } from '@lobechat/electron-client-ipc';
const ipc = ensureElectronIpc();
export const newFeatureService = async (params: NewFeatureParams) => {
return dispatch('newFeatureEventName', params);
return ipc.newFeature.doSomething(params);
};
```
@@ -118,36 +120,31 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
```typescript
// apps/desktop/src/main/controllers/NotificationCtr.ts
import { BrowserWindow, Notification } from 'electron';
import { ipcClientEvent } from 'electron-client-ipc';
import { Notification } from 'electron';
import { ControllerModule, IpcMethod } from '@/controllers';
import type {
DesktopNotificationResult,
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
interface ShowNotificationParams {
title: string;
body: string;
}
export default class NotificationCtr extends ControllerModule {
static override readonly groupName = 'notification';
@IpcMethod()
async showDesktopNotification(
params: ShowDesktopNotificationParams,
): Promise<DesktopNotificationResult> {
if (!Notification.isSupported()) {
return { error: 'Notifications not supported', success: false };
}
export class NotificationCtr {
@ipcClientEvent('showNotification')
async handleShowNotification({ title, body }: ShowNotificationParams) {
try {
if (!Notification.isSupported()) {
return { success: false, error: 'Notifications not supported' };
}
const notification = new Notification({
title,
body,
});
const notification = new Notification({ body: params.body, title: params.title });
notification.show();
return { success: true };
} catch (error) {
console.error('Failed to show notification:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
console.error('[NotificationCtr] Failed to show notification:', error);
return { error: error instanceof Error ? error.message : 'Unknown error', success: false };
}
}
}
@@ -51,15 +51,15 @@ alwaysApply: false
* 导入在步骤 2 中定义的 IPC 参数类型。
* 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
* 方法接收 `params` (符合 IPC 参数类型)。
* 使用从 `@lobechat/electron-client-ipc` 导入的 `dispatch` (或 `invoke`) 函数,调用与 Manifest 中 `name` 字段匹配的 IPC 事件名称,并将 `params` 传递过去。
* 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
* 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
5. **实现后端逻辑 (Controller / IPC Handler):**
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
* **操作:**
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ipcClientEvent`, 参数类型等)。
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`/`IpcServerMethod`、参数类型等)。
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
* 使用 `@ipcClientEvent('yourApiName')` 装饰器将此方法注册为对应 IPC 事件的处理器,确保 `'yourApiName'` 与 Manifest 中的 `name` Service 层调用的事件名称一致。
* 使用 `@IpcMethod()` 或 `@IpcServerMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
* 实现核心业务逻辑:
* 进行必要的输入验证。
+58 -68
View File
@@ -149,50 +149,52 @@ export const createMainWindow = () => {
1. **在主进程中注册 IPC 处理器**
```typescript
// BrowserWindowsCtr.ts
@ipcClientEvent('minimizeWindow')
handleMinimizeWindow() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.minimize();
}
return { success: true };
}
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
import { BrowserWindow } from 'electron';
import { ControllerModule, IpcMethod } from '@/controllers';
@ipcClientEvent('maximizeWindow')
handleMaximizeWindow() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
if (focusedWindow.isMaximized()) {
focusedWindow.restore();
} else {
focusedWindow.maximize();
}
}
return { success: true };
}
export default class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows';
@ipcClientEvent('closeWindow')
handleCloseWindow() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
focusedWindow.close();
@IpcMethod()
minimizeWindow() {
const focusedWindow = BrowserWindow.getFocusedWindow();
focusedWindow?.minimize();
return { success: true };
}
@IpcMethod()
maximizeWindow() {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow?.isMaximized()) focusedWindow.restore();
else focusedWindow?.maximize();
return { success: true };
}
@IpcMethod()
closeWindow() {
BrowserWindow.getFocusedWindow()?.close();
return { success: true };
}
return { success: true };
}
```
- `@IpcMethod()` 根据控制器的 `groupName` 自动将方法映射为 `windows.minimizeWindow` 形式的通道名称。
- 控制器需继承 `ControllerModule`,并在 `controllers/registry.ts` 中通过 `controllerIpcConstructors` 注册,便于类型生成。
2. **在渲染进程中调用**
```typescript
// src/services/electron/windowService.ts
import { dispatch } from '@lobechat/electron-client-ipc';
import { ensureElectronIpc } from '@/utils/electron/ipc';
const ipc = ensureElectronIpc();
export const windowService = {
minimize: () => dispatch('minimizeWindow'),
maximize: () => dispatch('maximizeWindow'),
close: () => dispatch('closeWindow'),
minimize: () => ipc.windows.minimizeWindow(),
maximize: () => ipc.windows.maximizeWindow(),
close: () => ipc.windows.closeWindow(),
};
```
- `ensureElectronIpc()` 会基于 `DesktopIpcServices` 运行时生成 Proxy,并通过 `window.electronAPI.invoke` 与主进程通信;不再直接使用 `dispatch`。
### 5. 自定义窗口控制 (无边框窗口)
@@ -252,45 +254,33 @@ export const createMainWindow = () => {
```typescript
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
import type { OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
import { ControllerModule, IpcMethod } from '@/controllers';
export default class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows';
@IpcMethod()
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
const normalizedOptions =
typeof options === 'string' || options === undefined
? { tab: typeof options === 'string' ? options : undefined }
: options;
const mainWindow = this.app.browserManager.getMainWindow();
const query = new URLSearchParams();
if (normalizedOptions.tab) query.set('active', normalizedOptions.tab);
if (normalizedOptions.searchParams) {
for (const [key, value] of Object.entries(normalizedOptions.searchParams)) {
if (value) query.set(key, value);
}
}
const fullPath = `/settings${query.size ? `?${query.toString()}` : ''}`;
await mainWindow.loadUrl(fullPath);
mainWindow.show();
@ipcClientEvent('openSettings')
handleOpenSettings() {
// 检查设置窗口是否已经存在
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
// 如果窗口已存在,将其置于前台
this.settingsWindow.focus();
return { success: true };
}
// 创建新窗口
this.settingsWindow = new BrowserWindow({
width: 800,
height: 600,
title: 'Settings',
parent: this.mainWindow, // 设置父窗口,使其成为模态窗口
modal: true,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
// 加载设置页面
if (isDev) {
this.settingsWindow.loadURL('http://localhost:3000/settings');
} else {
this.settingsWindow.loadFile(
path.join(__dirname, '../../renderer/index.html'),
{ hash: 'settings' }
);
}
// 监听窗口关闭事件
this.settingsWindow.on('closed', () => {
this.settingsWindow = null;
});
return { success: true };
}
```
+33 -17
View File
@@ -1,8 +1,9 @@
---
description:
description:
globs: src/database/schemas/*
alwaysApply: false
---
# Drizzle ORM Schema Style Guide for lobe-chat
This document outlines the conventions and best practices for defining PostgreSQL Drizzle ORM schemas within the lobe-chat project.
@@ -16,7 +17,8 @@ This document outlines the conventions and best practices for defining PostgreSQ
## Helper Functions
Commonly used column definitions, especially for timestamps, are centralized in [src/database/schemas/_helpers.ts](mdc:src/database/schemas/_helpers.ts):
Commonly used column definitions, especially for timestamps, are centralized in [src/database/schemas/\_helpers.ts](mdc:src/database/schemas/_helpers.ts):
- `timestamptz(name: string)`: Creates a timestamp column with timezone
- `createdAt()`, `updatedAt()`, `accessedAt()`: Helper functions for standard timestamp columns
- `timestamps`: An object `{ createdAt, updatedAt, accessedAt }` for easy inclusion in table definitions
@@ -29,6 +31,7 @@ Commonly used column definitions, especially for timestamps, are centralized in
## Column Definitions
### Primary Keys (PKs)
- Typically `text('id')` (or `varchar('id')` for some OIDC tables)
- Often use `.$defaultFn(() => idGenerator('table_name'))` for automatic ID generation with meaningful prefixes
- **ID Prefix Purpose**: Makes it easy for users and developers to distinguish different entity types at a glance
@@ -36,24 +39,29 @@ Commonly used column definitions, especially for timestamps, are centralized in
- Composite PKs are defined using `primaryKey({ columns: [t.colA, t.colB] })`
### Foreign Keys (FKs)
- Defined using `.references(() => otherTable.id, { onDelete: 'cascade' | 'set null' | 'no action' })`
- FK columns are usually named `related_table_singular_name_id` (e.g., `user_id` references `users.id`)
- Most tables include a `user_id` column referencing `users.id` with `onDelete: 'cascade'`
### Timestamps
- Consistently use the `...timestamps` spread from [_helpers.ts](mdc:src/database/schemas/_helpers.ts) for `created_at`, `updated_at`, and `accessed_at` columns
- Consistently use the `...timestamps` spread from [\_helpers.ts](mdc:src/database/schemas/_helpers.ts) for `created_at`, `updated_at`, and `accessed_at` columns
### Default Values
- `.$defaultFn(() => expression)` for dynamic defaults (e.g., `idGenerator()`, `randomSlug()`)
- `.default(staticValue)` for static defaults (e.g., `boolean('enabled').default(true)`)
### Indexes
- Defined in the table's second argument: `pgTable('name', {...columns}, (t) => ({ indexName: indexType().on(...) }))`
- Use `uniqueIndex()` for unique constraints and `index()` for non-unique indexes
- Naming pattern: `table_name_column(s)_idx` or `table_name_column(s)_unique`
- Many tables feature a `clientId: text('client_id')` column, often part of a composite unique index with `user_id`
### Data Types
- Common types: `text`, `varchar`, `jsonb`, `boolean`, `integer`, `uuid`, `pgTable`
- For `jsonb` fields, specify the TypeScript type using `.$type<MyType>()` for better type safety
@@ -97,9 +105,7 @@ export const agents = pgTable(
...timestamps,
},
// return array instead of object, the object style is deprecated
(t) => [
uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId),
],
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
);
export const insertAgentSchema = createInsertSchema(agents);
@@ -110,6 +116,7 @@ export type AgentItem = typeof agents.$inferSelect;
## Common Patterns
### 1. userId + clientId Pattern (Legacy)
Some existing tables include both fields for different purposes:
```typescript
@@ -129,6 +136,7 @@ clientIdUnique: uniqueIndex('agents_client_id_user_id_unique').on(t.clientId, t.
- **Note**: This pattern is being phased out for new features to simplify the schema
### 2. Junction Tables (Many-to-Many Relationships)
Use composite primary keys for relationship tables:
```typescript
@@ -136,21 +144,26 @@ Use composite primary keys for relationship tables:
export const agentsKnowledgeBases = pgTable(
'agents_knowledge_bases',
{
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }).notNull(),
knowledgeBaseId: text('knowledge_base_id').references(() => knowledgeBases.id, { onDelete: 'cascade' }).notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
agentId: text('agent_id')
.references(() => agents.id, { onDelete: 'cascade' })
.notNull(),
knowledgeBaseId: text('knowledge_base_id')
.references(() => knowledgeBases.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
enabled: boolean('enabled').default(true),
...timestamps,
},
(t) => [
primaryKey({ columns: [t.agentId, t.knowledgeBaseId] }),
],
(t) => [primaryKey({ columns: [t.agentId, t.knowledgeBaseId] })],
);
```
**Pattern**: `{entity1}Id` + `{entity2}Id` as composite PK, plus `userId` for ownership
### 3. OIDC Tables Special Patterns
OIDC tables use `varchar` IDs instead of `text` with custom generators:
```typescript
@@ -166,6 +179,7 @@ export const oidcAuthorizationCodes = pgTable('oidc_authorization_codes', {
**Reason**: OIDC standards expect specific ID formats and lengths
### 4. File Processing with Async Tasks
File-related tables reference async task IDs for background processing:
```typescript
@@ -173,17 +187,21 @@ File-related tables reference async task IDs for background processing:
export const files = pgTable('files', {
// ... other fields
chunkTaskId: uuid('chunk_task_id').references(() => asyncTasks.id, { onDelete: 'set null' }),
embeddingTaskId: uuid('embedding_task_id').references(() => asyncTasks.id, { onDelete: 'set null' }),
embeddingTaskId: uuid('embedding_task_id').references(() => asyncTasks.id, {
onDelete: 'set null',
}),
// ...
});
```
**Purpose**:
**Purpose**:
- Track file chunking progress (breaking files into smaller pieces)
- Track embedding generation progress (converting text to vectors)
- Allow querying task status and handling failures
### 5. Slug Pattern (Legacy)
Some entities include auto-generated slugs - this is legacy code:
```typescript
@@ -195,8 +213,6 @@ slug: varchar('slug', { length: 100 })
slugUserIdUnique: uniqueIndex('slug_user_id_unique').on(t.slug, t.userId),
```
**Current usage**: Only used to identify default agents/sessions (legacy pattern)
**Future refactor**: Will likely be replaced with `isDefault: boolean()` field
**Note**: Avoid using slugs for new features - prefer explicit boolean flags for status tracking
**Current usage**: Only used to identify default agents/sessions (legacy pattern) **Future refactor**: Will likely be replaced with `isDefault: boolean()` field **Note**: Avoid using slugs for new features - prefer explicit boolean flags for status tracking
By following these guidelines, maintain consistency, type safety, and maintainability across database schema definitions.
-35
View File
@@ -1,35 +0,0 @@
---
description: Explain how group chat works in LobeHub (Multi-agent orchestratoin)
globs:
alwaysApply: false
---
This rule explains how group chat (multi-agent orchestration) works. Not confused with session group, which is a organization method to manage session.
## Key points
- A supervisor will devide who and how will speak next
- Each agent will speak just like in single chat (if was asked to speak)
- Not coufused with session group
## Related Files
- src/store/chat/slices/message/supervisor.ts
- src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts
- src/prompts/groupChat/index.ts (All prompts here)
## Snippets
```tsx
// Detect whether in group chat
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
// Member actions
const addAgentsToGroup = useChatGroupStore((s) => s.addAgentsToGroup);
const removeAgentFromGroup = useChatGroupStore((s) => s.removeAgentFromGroup);
const persistReorder = useChatGroupStore((s) => s.reorderGroupMembers);
// Get group info
const groupConfig = useChatGroupStore(chatGroupSelectors.currentGroupConfig);
const currentGroupMemebers = useSessionStore(sessionSelectors.currentGroupAgents);
```
+2 -5
View File
@@ -2,6 +2,7 @@
globs: *.tsx
alwaysApply: false
---
# LobeChat Internationalization Guide
## Key Points
@@ -14,7 +15,7 @@ alwaysApply: false
## Directory Structure
```
```plaintext
src/locales/
├── default/ # Source language files (zh-CN)
│ ├── index.ts # Namespace exports
@@ -176,7 +177,3 @@ export default {
- Check if the key exists in src/locales/default/namespace.ts
- Ensure the namespace is correctly imported in the component
- Ensure new namespaces are exported in src/locales/default/index.ts
- 检查键是否存在于 src/locales/default/namespace.ts 中
- 确保在组件中正确导入命名空间
- 确保新命名空间已在 src/locales/default/index.ts 中导出
+2 -1
View File
@@ -17,6 +17,7 @@ logo emoji: 🤯
## Project Technologies Stack
- Next.js 16
- implement spa inside nextjs with `react-router-dom`
- react 19
- TypeScript
- `@lobehub/ui`, antd for component framework
@@ -31,6 +32,6 @@ logo emoji: 🤯
- dayjs for time library
- lodash-es for utility library
- TRPC for type safe backend
- PGLite for client DB and Neon PostgreSQL for backend DB
- Neon PostgreSQL for backend DB
- Drizzle ORM
- Vitest for testing
+33 -37
View File
@@ -25,22 +25,23 @@ lobe-chat/
│ └── zh-CN/
├── packages/
│ ├── agent-runtime/
│ ├── builtin-agents/
│ ├── builtin-tool-*/ # builtin tool packages
│ ├── const/
│ ├── context-engine/
│ ├── conversation-flow/
│ ├── database/
│ │ ── src/
│ │ ├── models/
│ │ ├── schemas/
│ │ └── repositories/
│ │ ── src/
│ │ ├── models/
│ │ ├── schemas/
│ │ └── repositories/
│ ├── desktop-bridge/
│ ├── electron-client-ipc/
│ ├── electron-server-ipc/
│ ├── fetch-sse/
│ ├── file-loaders/
│ ├── memory-extract/
│ ├── memory-user-memory/
│ ├── model-bank/
│ │ └── src/
│ │ └── aiModels/
│ ├── model-runtime/
│ │ └── src/
│ │ ├── core/
@@ -50,9 +51,6 @@ lobe-chat/
│ ├── python-interpreter/
│ ├── ssrf-safe-fetch/
│ ├── types/
│ │ └── src/
│ │ ├── message/
│ │ └── user/
│ ├── utils/
│ └── web-crawler/
├── public/
@@ -61,24 +59,25 @@ lobe-chat/
│ ├── app/
│ │ ├── (backend)/
│ │ │ ├── api/
│ │ │ │ ├── auth/
│ │ │ │ └── webhooks/
│ │ │ ├── f/
│ │ │ ├── market/
│ │ │ ├── middleware/
│ │ │ ├── oidc/
│ │ │ ├── trpc/
│ │ │ └── webapi/
│ │ │ ├── chat/
│ │ │ └── tts/
│ │ ├── [variants]/
│ │ │ ├── (auth)/
│ │ │ ├── (main)/
│ │ │ │ ├── chat/
│ │ │ │ └── settings/
│ │ │ └── @modal/
│ │ └── manifest.ts
│ │ │ ├── (mobile)/
│ │ │ ├── onboarding/
│ │ │ └── router/
│ │ └── desktop/
│ ├── components/
│ ├── config/
│ ├── const/
│ ├── envs/
│ ├── features/
│ └── ChatInput/
├── helpers/
│ ├── hooks/
│ ├── layout/
│ │ ├── AuthProvider/
@@ -90,23 +89,23 @@ lobe-chat/
│ ├── locales/
│ │ └── default/
│ ├── server/
│ │ ├── featureFlags/
│ │ ├── globalConfig/
│ │ ├── modules/
│ │ ├── routers/
│ │ │ ├── async/
│ │ │ ├── desktop/
│ │ │ ├── edge/
│ │ │ └── lambda/
│ │ │ ├── lambda/
│ │ │ ├── mobile/
│ │ │ └── tools/
│ │ └── services/
│ ├── services/
│ │ ├── user/
│ │ │ ├── client.ts
│ │ │ └── server.ts
│ │ └── message/
│ ├── store/
│ │ ├── agent/
│ │ ├── chat/
│ │ └── user/
│ ├── styles/
│ ├── tools/
│ ├── types/
│ └── utils/
└── package.json
```
@@ -116,25 +115,22 @@ lobe-chat/
- UI Components: `src/components`, `src/features`
- Global providers: `src/layout`
- Zustand stores: `src/store`
- Client Services: `src/services/` cross-platform services
- clientDB: `src/services/<domain>/client.ts`
- serverDB: `src/services/<domain>/server.ts`
- Client Services: `src/services/`
- API Routers:
- `src/app/(backend)/webapi` (REST)
- `src/server/routers/{edge|lambda|async|desktop|tools}` (tRPC)
- `src/server/routers/{async|lambda|mobile|tools}` (tRPC)
- Server:
- Services(can access serverDB): `src/server/services` server-used-only services
- Modules(can't access db): `src/server/modules` (Server only Third-party Service Module)
- Services (can access serverDB): `src/server/services`
- Modules (can't access db): `src/server/modules`
- Feature Flags: `src/server/featureFlags`
- Global Config: `src/server/globalConfig`
- Database:
- Schema (Drizzle): `packages/database/src/schemas`
- Model (CRUD): `packages/database/src/models`
- Repository (bff-queries): `packages/database/src/repositories`
- Third-party Integrations: `src/libs` — analytics, oidc etc.
- Builtin Tools: `src/tools`, `packages/builtin-tool-*`
## Data Flow Architecture
- **Web with ClientDB**: React UI → Client Service → Direct Model Access → PGLite (Web WASM)
- **Web with ServerDB**: React UI → Client Service → tRPC Lambda → Server Services → PostgreSQL (Remote)
- **Desktop**:
- Cloud sync disabled: Electron UI → Client Service → tRPC Lambda → Local Server Services → PGLite (Node WASM)
- Cloud sync enabled: Electron UI → Client Service → tRPC Lambda → Cloud Server Services → PostgreSQL (Remote)
React UI → Store Actions → Client Service → TRPC Lambda → Server Services -> DB Model → PostgreSQL (Remote)
-173
View File
@@ -1,173 +0,0 @@
---
description:
globs: *.tsx
alwaysApply: false
---
# react component 编写指南
- 如果要写复杂样式的话用 antd-style ,简单的话可以用 style 属性直接写内联样式
- 如果需要 flex 布局或者居中布局应该使用 react-layout-kit 的 Flexbox 和 Center 组件
- 选择组件时优先顺序应该是 src/components > 安装的组件 package > lobe-ui > antd
- 使用 selector 访问 zustand store 的数据,而不是直接从 store 获取
## antd-style token system
### 访问 token system 的两种方式
#### 使用 antd-style 的 useTheme hook
```tsx
import { useTheme } from 'antd-style';
const MyComponent = () => {
const theme = useTheme();
return (
<div
style={{
color: theme.colorPrimary,
backgroundColor: theme.colorBgContainer,
padding: theme.padding,
borderRadius: theme.borderRadius,
}}
>
使用主题 token 的组件
</div>
);
};
```
#### 使用 antd-style 的 createStyles
```tsx
const useStyles = createStyles(({ css, token }) => {
return {
container: css`
background-color: ${token.colorBgContainer};
border-radius: ${token.borderRadius}px;
padding: ${token.padding}px;
color: ${token.colorText};
`,
title: css`
font-size: ${token.fontSizeLG}px;
font-weight: ${token.fontWeightStrong};
margin-bottom: ${token.marginSM}px;
`,
content: css`
font-size: ${token.fontSize}px;
line-height: ${token.lineHeight};
`,
};
});
const Card: FC<CardProps> = ({ title, content }) => {
const { styles } = useStyles();
return (
<Flexbox className={styles.container}>
<div className={styles.title}>{title}</div>
<div className={styles.content}>{content}</div>
</Flexbox>
);
};
```
### 一些你经常会忘记使用的 token
请注意使用下面的 token 而不是 css 字面值。可以访问 https://ant.design/docs/react/customize-theme-cn 了解所有 token
- 动画类
- token.motionDurationMid
- token.motionEaseInOut
- 包围盒属性
- token.paddingSM
- token.marginLG
## Lobe UI 包含的组件
- 不知道 `@lobehub/ui` 的组件怎么用,有哪些属性,就自己搜下这个项目其它地方怎么用的,不要瞎猜,大部分组件都是在 antd 的基础上扩展了属性
- 具体用法不懂可以联网搜索,例如 ActionIcon 就爬取 https://ui.lobehub.com/components/action-icon
- 可以阅读 `node_modules/@lobehub/ui/es/index.js` 了解有哪些组件,每个组件的属性是什么
- General
- ActionIcon
- ActionIconGroup
- Block
- Button
- DownloadButton
- Icon
- Data Display
- Avatar
- AvatarGroup
- GroupAvatar
- Collapse
- FileTypeIcon
- FluentEmoji
- GuideCard
- Highlighter
- Hotkey
- Image
- List
- Markdown
- SearchResultCards
- MaterialFileTypeIcon
- Mermaid
- Typography
- Text
- Segmented
- Snippet
- SortableList
- Tag
- Tooltip
- Video
- Data Entry
- AutoComplete
- CodeEditor
- ColorSwatches
- CopyButton
- DatePicker
- EditableText
- EmojiPicker
- Form
- FormModal
- HotkeyInput
- ImageSelect
- Input
- SearchBar
- Select
- SliderWithInput
- ThemeSwitch
- Feedback
- Alert
- Drawer
- Modal
- Layout
- DraggablePanel
- DraggablePanelBody
- DraggablePanelContainer
- DraggablePanelFooter
- DraggablePanelHeader
- Footer
- Grid
- Header
- Layout
- LayoutFooter
- LayoutHeader
- LayoutMain
- LayoutSidebar
- LayoutSidebarInner
- LayoutToc
- MaskShadow
- ScrollShadow
- Navigation
- Burger
- Dropdown
- Menu
- SideNav
- Tabs
- Toc
- Theme
- ConfigProvider
- FontLoader
- ThemeProvider
+155
View File
@@ -0,0 +1,155 @@
---
description:
globs: *.tsx
alwaysApply: false
---
# React Component Writing Guide
- Use antd-style for complex styles; for simple cases, use the `style` attribute for inline styles
- Use `Flexbox` and `Center` components from react-layout-kit for flex and centered layouts
- Component selection priority: src/components > installed component packages > lobe-ui > antd
- Use selectors to access zustand store data instead of accessing the store directly
## Lobe UI Components
- If unsure how to use `@lobehub/ui` components or what props they accept, search for existing usage in this project instead of guessing. Most components extend antd components with additional props
- For specific usage, search online. For example, for ActionIcon visit <https://ui.lobehub.com/components/action-icon>
- Read `node_modules/@lobehub/ui/es/index.js` to see all available components and their props
- General
- ActionIcon
- ActionIconGroup
- Block
- Button
- Icon
- Data Display
- Accordion
- Avatar
- Collapse
- Empty
- FileTypeIcon
- FluentEmoji
- GroupAvatar
- GuideCard
- Highlighter
- Hotkey
- Image
- List
- Markdown
- MaterialFileTypeIcon
- Mermaid
- Segmented
- Skeleton
- Snippet
- SortableList
- Tag
- Tooltip
- Video
- Data Entry
- AutoComplete
- CodeEditor
- ColorSwatches
- CopyButton
- DatePicker
- DownloadButton
- EditableText
- EmojiPicker
- Form
- FormModal
- HotkeyInput
- ImageSelect
- Input
- SearchBar
- Select
- SliderWithInput
- ThemeSwitch
- Feedback
- Alert
- Drawer
- Modal
- Layout
- DraggablePanel
- Footer
- Grid
- Header
- Layout
- MaskShadow
- ScrollShadow
- Navigation
- Burger
- DraggableSideNav
- Dropdown
- Menu
- SideNav
- Tabs
- Toc
- Theme
- ConfigProvider
- FontLoader
- ThemeProvider
- Typography
- Text
## Routing Architecture
This project uses a **hybrid routing architecture**: Next.js App Router for static pages + React Router DOM for the main SPA.
### Route Types
```plaintext
+------------------+--------------------------------+--------------------------------+
| Route Type | Use Case | Implementation |
+------------------+--------------------------------+--------------------------------+
| Next.js App | Auth pages (login, signup, | page.tsx file convention |
| Router | oauth, reset-password, etc.) | src/app/[variants]/(auth)/ |
+------------------+--------------------------------+--------------------------------+
| React Router | Main SPA features | BrowserRouter + Routes |
| DOM | (chat, discover, settings) | desktopRouter.config.tsx |
| | | mobileRouter.config.tsx |
+------------------+--------------------------------+--------------------------------+
```
### Key Files
- Entry point: `src/app/[variants]/page.tsx` - Routes to Desktop or Mobile based on device
- Desktop router: `src/app/[variants]/router/desktopRouter.config.tsx`
- Mobile router: `src/app/[variants]/(mobile)/router/mobileRouter.config.tsx`
- Router utilities: `src/utils/router.tsx`
### Router Utilities
```tsx
import { dynamicElement, redirectElement, ErrorBoundary, RouteConfig } from '@/utils/router';
// Lazy load a page component
element: dynamicElement(() => import('./chat'), 'Desktop > Chat')
// Create a redirect
element: redirectElement('/settings/profile')
// Error boundary for route
errorElement: <ErrorBoundary resetPath="/chat" />
```
### Adding New Routes
1. Add route config to `desktopRouter.config.tsx` or `mobileRouter.config.tsx`
2. Create page component in the corresponding directory under `(main)/`
3. Use `dynamicElement()` for lazy loading
### Navigation
```tsx
// In components - use react-router-dom hooks
import { useNavigate, useParams } from 'react-router-dom';
const navigate = useNavigate();
navigate('/chat');
// From stores - use global navigate
import { useGlobalStore } from '@/store/global';
const navigate = useGlobalStore.getState().navigate;
navigate?.('/settings');
```
+1 -1
View File
@@ -14,7 +14,7 @@ All following rules are saved under `.cursor/rules/` directory:
## Frontend
- `react-component.mdc` React component style guide and conventions
- `react.mdc` React component style guide and conventions
- `i18n.mdc` Internationalization guide using react-i18next
- `typescript.mdc` TypeScript code style guide
- `packages/react-layout-kit.mdc` Usage guide for react-layout-kit
@@ -0,0 +1,285 @@
# Agent Runtime E2E 测试指南
本文档描述 Agent Runtime 端到端测试的核心原则和实施方法。
## 核心原则
### 1. 最小化 Mock 原则
E2E 测试的目标是尽可能接近真实运行环境。因此,我们只 Mock **三个外部依赖**:
| 依赖 | Mock 方式 | 说明 |
| --- | --- | --- |
| **Database** | PGLite | 使用 `@lobechat/database/test-utils` 提供的内存数据库 |
| **Redis** | InMemoryAgentStateManager | Mock `AgentStateManager` 使用内存实现 |
| **Redis** | InMemoryStreamEventManager | Mock `StreamEventManager` 使用内存实现 |
**不 Mock 的部分:**
- `model-bank` - 使用真实的模型配置数据
- `Mecha` (AgentToolsEngine, ContextEngineering) - 使用真实逻辑
- `AgentRuntimeService` - 使用真实逻辑
- `AgentRuntimeCoordinator` - 使用真实逻辑
### 2. 使用 vi.spyOn 而非 vi.mock
不同测试场景需要不同的 LLM 响应。使用 `vi.spyOn` 可以:
- 在每个测试中灵活控制返回值
- 便于测试不同场景(纯文本、tool calls、错误等)
- 避免全局 mock 导致的测试隔离问题
### 3. 默认模型使用 gpt-5
- `model-bank` 中肯定有该模型的数据
- 避免短期内因模型更新需要修改测试
## 技术实现
### 数据库设置
```typescript
import { LobeChatDatabase } from '@lobechat/database';
import { getTestDB } from '@lobechat/database/test-utils';
let testDB: LobeChatDatabase;
beforeEach(async () => {
testDB = await getTestDB();
});
```
### OpenAI Response Mock Helper
创建一个 helper 函数来生成 OpenAI 格式的流式响应:
```typescript
/**
* 创建 OpenAI 格式的流式响应
*/
export const createOpenAIStreamResponse = (options: {
content?: string;
toolCalls?: Array<{
id: string;
name: string;
arguments: string;
}>;
finishReason?: 'stop' | 'tool_calls';
}) => {
const { content, toolCalls, finishReason = 'stop' } = options;
return new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 发送内容 chunk
if (content) {
const chunk = {
id: 'chatcmpl-mock',
object: 'chat.completion.chunk',
model: 'gpt-5',
choices: [
{
index: 0,
delta: { content },
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
// 发送 tool_calls chunk
if (toolCalls) {
for (const tool of toolCalls) {
const chunk = {
id: 'chatcmpl-mock',
object: 'chat.completion.chunk',
model: 'gpt-5',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: tool.id,
type: 'function',
function: {
name: tool.name,
arguments: tool.arguments,
},
},
],
},
finish_reason: null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
}
// 发送完成 chunk
const finishChunk = {
id: 'chatcmpl-mock',
object: 'chat.completion.chunk',
model: 'gpt-5',
choices: [
{
index: 0,
delta: {},
finish_reason: finishReason,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
}),
{ headers: { 'content-type': 'text/event-stream' } },
);
};
```
### 内存状态管理
使用依赖注入替代 Redis
```typescript
import {
InMemoryAgentStateManager,
InMemoryStreamEventManager,
} from '@/server/modules/AgentRuntime';
import { AgentRuntimeService } from '@/server/services/agentRuntime';
const stateManager = new InMemoryAgentStateManager();
const streamEventManager = new InMemoryStreamEventManager();
const service = new AgentRuntimeService(serverDB, userId, {
coordinatorOptions: {
stateManager,
streamEventManager,
},
queueService: null, // 禁用 QStash 队列,使用 executeSync
streamEventManager,
});
```
### Mock OpenAI API
在测试中使用 `vi.spyOn` mock fetch
```typescript
import { vi } from 'vitest';
// 在测试文件顶部或 beforeEach 中
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 在具体测试中设置返回值
it('should handle text response', async () => {
fetchSpy.mockResolvedValueOnce(createOpenAIStreamResponse({ content: '杭州今天天气晴朗' }));
// ... 执行测试
});
it('should handle tool calls', async () => {
fetchSpy.mockResolvedValueOnce(
createOpenAIStreamResponse({
toolCalls: [
{
id: 'call_123',
name: 'lobe-web-browsing____search____builtin',
arguments: JSON.stringify({ query: '杭州天气' }),
},
],
finishReason: 'tool_calls',
}),
);
// ... 执行测试
});
```
## 测试场景
### 1. 基本对话测试
```typescript
describe('Basic Chat', () => {
it('should complete a simple conversation', async () => {
fetchSpy.mockResolvedValueOnce(
createOpenAIStreamResponse({ content: 'Hello! How can I help you?' }),
);
const result = await service.createOperation({
agentConfig: { model: 'gpt-5', provider: 'openai' },
initialMessages: [{ role: 'user', content: 'Hi' }],
// ...
});
const finalState = await service.executeSync(result.operationId);
expect(finalState.status).toBe('done');
});
});
```
### 2. Tool 调用测试
```typescript
describe('Tool Calls', () => {
it('should execute web-browsing tool', async () => {
// 第一次调用:LLM 返回 tool_calls
fetchSpy.mockResolvedValueOnce(
createOpenAIStreamResponse({
toolCalls: [
{
id: 'call_123',
name: 'lobe-web-browsing____search____builtin',
arguments: JSON.stringify({ query: '杭州天气' }),
},
],
finishReason: 'tool_calls',
}),
);
// 第二次调用:处理 tool 结果后的响应
fetchSpy.mockResolvedValueOnce(
createOpenAIStreamResponse({ content: '根据搜索结果,杭州今天...' }),
);
// ... 执行测试
});
});
```
### 3. 错误处理测试
```typescript
describe('Error Handling', () => {
it('should handle API errors gracefully', async () => {
fetchSpy.mockRejectedValueOnce(new Error('API rate limit exceeded'));
// ... 执行测试并验证错误处理
});
});
```
## 文件组织
```
src/server/routers/lambda/__tests__/integration/
├── setup.ts # 测试设置工具
├── aiAgent.integration.test.ts # 现有集成测试
├── aiAgent.e2e.test.ts # E2E 测试
└── helpers/
└── openaiMock.ts # OpenAI mock helper
```
## 注意事项
1. **测试隔离**:每个测试后清理 `InMemoryAgentStateManager` 和 `InMemoryStreamEventManager`
2. **超时设置**:E2E 测试可能需要更长的超时时间
3. **调试**:使用 `DEBUG=lobe-server:*` 环境变量查看详细日志
@@ -1,6 +1,6 @@
---
description: Best practices for testing Zustand store actions
globs: "src/store/**/*.test.ts"
globs: 'src/store/**/*.test.ts'
alwaysApply: false
---
@@ -15,6 +15,7 @@ import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { messageService } from '@/services/message';
import { useChatStore } from '../../store';
// Keep zustand mock as it's needed globally
@@ -229,8 +230,7 @@ it('should handle topic creation flow', async () => {
const { result } = renderHook(() => useChatStore());
// Spy on action dependencies
const createTopicSpy = vi.spyOn(result.current, 'createTopic')
.mockResolvedValue('new-topic-id');
const createTopicSpy = vi.spyOn(result.current, 'createTopic').mockResolvedValue('new-topic-id');
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleMessageLoading');
// Execute
@@ -251,9 +251,7 @@ When testing streaming responses, simulate the flow properly:
```typescript
it('should handle streaming chunks', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [
{ id: 'msg-1', role: 'user', content: 'Hello', sessionId: 'test-session' },
];
const messages = [{ id: 'msg-1', role: 'user', content: 'Hello', sessionId: 'test-session' }];
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
@@ -287,9 +285,7 @@ Always test error scenarios:
it('should handle errors gracefully', async () => {
const { result } = renderHook(() => useChatStore());
vi.spyOn(messageService, 'createMessage').mockRejectedValue(
new Error('create message error'),
);
vi.spyOn(messageService, 'createMessage').mockRejectedValue(new Error('create message error'));
await act(async () => {
try {
@@ -330,8 +326,7 @@ it('should test something', async () => {
it('should call internal methods', async () => {
const { result } = renderHook(() => useChatStore());
const internalMethodSpy = vi.spyOn(result.current, 'internal_method')
.mockResolvedValue();
const internalMethodSpy = vi.spyOn(result.current, 'internal_method').mockResolvedValue();
await act(async () => {
await result.current.publicMethod();
@@ -456,6 +451,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { discoverService } from '@/services/discover';
import { globalHelpers } from '@/store/global/helpers';
import { useDiscoverStore as useStore } from '../../store';
vi.mock('zustand/traditional');
@@ -486,6 +482,7 @@ describe('SWR Hook Actions', () => {
```
**Key points**:
- **DO NOT mock useSWR** - let it use the real implementation
- Only mock the **service methods** (fetchers)
- Use `waitFor` from `@testing-library/react` to wait for async operations
@@ -559,21 +556,19 @@ it('should not fetch when required parameter is missing', () => {
7. **Type assertions**: Use `as any` for test mock data where type definitions are strict
**Why this matters**:
- The fetcher (service method) is what we're testing - it must be called
- Hardcoding the return value bypasses the actual fetcher logic
- SWR returns Promises in real usage, tests should mirror this behavior
## Benefits of This Approach
✅ **Clear test layers** - Each test only spies on direct dependencies
✅ **Correct mocks** - Mocks match actual implementation
✅ **Better maintainability** - Changes to implementation require fewer test updates
✅ **Improved coverage** - Structured approach ensures all branches are tested
✅ **Reduced coupling** - Tests are independent and can run in any order
✅ **Clear test layers** - Each test only spies on direct dependencies ✅ **Correct mocks** - Mocks match actual implementation ✅ **Better maintainability** - Changes to implementation require fewer test updates ✅ **Improved coverage** - Structured approach ensures all branches are tested ✅ **Reduced coupling** - Tests are independent and can run in any order
## Reference
See example implementation in:
- `src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts` (Regular actions)
- `src/store/discover/slices/plugin/action.test.ts` (SWR hooks)
- `src/store/discover/slices/mcp/action.test.ts` (SWR hooks)
-4
View File
@@ -16,10 +16,6 @@ alwaysApply: false
- prefer `@ts-expect-error` over `@ts-ignore` over `as any`
- Avoid meaningless null/undefined parameters; design strict function contracts.
## Imports and Modules
- When importing a directory module, prefer the explicit index path like `@/db/index` instead of `@/db`.
## Asynchronous Patterns and Concurrency
- Prefer `async`/`await` over callbacks or chained `.then` promises.
+136 -168
View File
@@ -1,137 +1,126 @@
---
description:
description:
globs: src/store/**
alwaysApply: false
---
# LobeChat Zustand Action 组织模式
本文档详细说明了 LobeChat 项目中 Zustand Action 的组织方式、命名规范和实现模式,特别关注乐观更新与后端服务的集成。
# LobeChat Zustand Action Patterns
## Action 类型分层
## Action Type Hierarchy
LobeChat Action 采用分层架构,明确区分不同职责:
LobeChat Actions use a layered architecture with clear separation of responsibilities:
### 1. Public Actions
对外暴露的主要接口,供 UI 组件调用:
- 命名:动词形式(`createTopic`, `sendMessage`, `updateTopicTitle`
- 职责:参数验证、流程编排、调用 internal actions
- 示例:[src/store/chat/slices/topic/action.ts](mdc:src/store/chat/slices/topic/action.ts)
Main interfaces exposed for UI component consumption:
- Naming: Verb form (`createTopic`, `sendMessage`, `updateTopicTitle`)
- Responsibilities: Parameter validation, flow orchestration, calling internal actions
- Example: [src/store/chat/slices/topic/action.ts](mdc:src/store/chat/slices/topic/action.ts)
```typescript
// Public Action 示例
// Public Action example
createTopic: async () => {
const { activeId, internal_createTopic } = get();
const messages = chatSelectors.activeBaseChats(get());
if (messages.length === 0) return;
const topicId = await internal_createTopic({
sessionId: activeId,
title: t('defaultTitle', { ns: 'topic' }),
messages: messages.map((m) => m.id),
});
// ...
return topicId;
},
```
### 2. Internal Actions (`internal_*`)
内部实现细节,处理核心业务逻辑:
- 命名:`internal_` 前缀 + 动词(`internal_createTopic`, `internal_updateMessageContent`
- 职责:乐观更新、服务调用、错误处理、状态同步
- 不应该被 UI 组件直接调用
Internal implementation details handling core business logic:
- Naming: `internal_` prefix + verb (`internal_createTopic`, `internal_updateMessageContent`)
- Responsibilities: Optimistic updates, service calls, error handling, state synchronization
- Should not be called directly by UI components
```typescript
// Internal Action 示例 - 乐观更新模式
// Internal Action example - Optimistic update pattern
internal_createTopic: async (params) => {
const tmpId = Date.now().toString();
// 1. 立即更新前端状态(乐观更新)
// 1. Immediately update frontend state (optimistic update)
get().internal_dispatchTopic(
{ type: 'addTopic', value: { ...params, id: tmpId } },
'internal_createTopic',
);
get().internal_updateTopicLoading(tmpId, true);
// 2. 调用后端服务
// 2. Call backend service
const topicId = await topicService.createTopic(params);
get().internal_updateTopicLoading(tmpId, false);
// 3. 刷新数据确保一致性
// 3. Refresh data to ensure consistency
get().internal_updateTopicLoading(topicId, true);
await get().refreshTopic();
get().internal_updateTopicLoading(topicId, false);
return topicId;
},
```
### 3. Dispatch Methods (`internal_dispatch*`)
专门处理状态更新的方法:
- 命名:`internal_dispatch` + 实体名(`internal_dispatchTopic`, `internal_dispatchMessage`
- 职责:调用 reducer、更新 Zustand store、处理状态对比
Methods dedicated to handling state updates:
- Naming: `internal_dispatch` + entity name (`internal_dispatchTopic`, `internal_dispatchMessage`)
- Responsibilities: Calling reducers, updating Zustand store, handling state comparison
```typescript
// Dispatch Method 示例
// Dispatch Method example
internal_dispatchTopic: (payload, action) => {
const nextTopics = topicReducer(topicSelectors.currentTopics(get()), payload);
const nextMap = { ...get().topicMaps, [get().activeId]: nextTopics };
if (isEqual(nextMap, get().topicMaps)) return;
set({ topicMaps: nextMap }, false, action ?? n(`dispatchTopic/${payload.type}`));
},
```
## 何时使用 Reducer 模式 vs. 简单 `set`
## When to Use Reducer Pattern vs. Simple `set`
### 使用 Reducer 模式的场景
### Use Reducer Pattern When
适用于复杂的数据结构管理,特别是:
- 管理对象列表或映射(如 `messagesMap`, `topicMaps`
- 需要乐观更新的场景
- 状态转换逻辑复杂
- 需要类型安全的 action payload
Suitable for complex data structure management, especially:
- Managing object lists or maps (e.g., `messagesMap`, `topicMaps`)
- Scenarios requiring optimistic updates
- Complex state transition logic
- Type-safe action payloads needed
```typescript
// Reducer 模式示例 - 复杂消息状态管理
// Reducer pattern example - Complex message state management
export const messagesReducer = (state: ChatMessage[], payload: MessageDispatch): ChatMessage[] => {
switch (payload.type) {
case 'updateMessage': {
return produce(state, (draftState) => {
const index = draftState.findIndex((i) => i.id === payload.id);
if (index < 0) return;
draftState[index] = merge(draftState[index], {
...payload.value,
updatedAt: Date.now()
draftState[index] = merge(draftState[index], {
...payload.value,
updatedAt: Date.now(),
});
});
}
case 'createMessage': {
return produce(state, (draftState) => {
draftState.push({
...payload.value,
id: payload.id,
createdAt: Date.now(),
updatedAt: Date.now(),
meta: {}
});
});
// ...
}
// ...其他复杂状态转换
// ...other complex state transitions
}
};
```
### 使用简单 `set` 的场景
### Use Simple `set` When
适用于简单状态更新:
- 切换布尔值
- 更新简单字符串/数字
- 设置单一状态字段
Suitable for simple state updates:
- Toggling boolean values
- Updating simple strings/numbers
- Setting single state fields
```typescript
// 简单 set 示例
// Simple set example
updateInputMessage: (message) => {
if (isEqual(message, get().inputMessage)) return;
set({ inputMessage: message }, false, n('updateInputMessage'));
@@ -142,45 +131,45 @@ togglePortal: (open?: boolean) => {
},
```
## 乐观更新实现模式
## Optimistic Update Implementation Patterns
乐观更新是 LobeChat 中的核心模式,用于提供流畅的用户体验:
Optimistic updates are a core pattern in LobeChat for providing smooth user experience:
### 标准乐观更新流程
### Standard Optimistic Update Flow
```typescript
// 完整的乐观更新示例
// Complete optimistic update example
internal_updateMessageContent: async (id, content, extra) => {
const { internal_dispatchMessage, refreshMessages } = get();
// 1. 立即更新前端状态(乐观更新)
// 1. Immediately update frontend state (optimistic update)
internal_dispatchMessage({
id,
type: 'updateMessage',
value: { content },
});
// 2. 调用后端服务
// 2. Call backend service
await messageService.updateMessage(id, {
content,
tools: extra?.toolCalls ? internal_transformToolCalls(extra.toolCalls) : undefined,
// ...其他字段
// ...other fields
});
// 3. 刷新确保数据一致性
// 3. Refresh to ensure data consistency
await refreshMessages();
},
```
### 创建操作的乐观更新
### Optimistic Update for Create Operations
```typescript
internal_createMessage: async (message, context) => {
const { internal_createTmpMessage, refreshMessages, internal_toggleMessageLoading } = get();
let tempId = context?.tempMessageId;
if (!tempId) {
// 创建临时消息用于乐观更新
// Create temporary message for optimistic update
tempId = internal_createTmpMessage(message);
internal_toggleMessageLoading(true, tempId);
}
@@ -194,7 +183,7 @@ internal_createMessage: async (message, context) => {
return id;
} catch (e) {
internal_toggleMessageLoading(false, tempId);
// 错误处理:更新消息错误状态
// Error handling: update message error state
internal_dispatchMessage({
id: tempId,
type: 'updateMessage',
@@ -204,96 +193,77 @@ internal_createMessage: async (message, context) => {
},
```
### 删除操作模式(不使用乐观更新)
### Delete Operation Pattern (No Optimistic Update)
删除操作通常不适合乐观更新,因为:
- 删除是破坏性操作,错误恢复复杂
- 用户对删除操作的即时反馈期望较低
- 删除失败时恢复原状态会造成困惑
Delete operations typically don't suit optimistic updates because:
- Deletion is destructive; error recovery is complex
- Users have lower expectations for immediate feedback on deletions
- Restoring state on deletion failure causes confusion
```typescript
// 删除操作的标准模式 - 无乐观更新
// Standard delete operation pattern - No optimistic update
removeGenerationTopic: async (id: string) => {
const { internal_removeGenerationTopic } = get();
await internal_removeGenerationTopic(id);
},
internal_removeGenerationTopic: async (id: string) => {
// 1. 显示加载状态
// 1. Show loading state
get().internal_updateGenerationTopicLoading(id, true);
try {
// 2. 直接调用后端服务
// 2. Directly call backend service
await generationTopicService.deleteTopic(id);
// 3. 刷新数据获取最新状态
// 3. Refresh data to get latest state
await get().refreshGenerationTopics();
} finally {
// 4. 确保清除加载状态(无论成功或失败)
// 4. Ensure loading state is cleared (whether success or failure)
get().internal_updateGenerationTopicLoading(id, false);
}
},
```
删除操作的特点:
- 直接调用服务,不预先更新状态
- 依赖 loading 状态提供用户反馈
- 操作完成后刷新整个列表确保一致性
- 使用 `try/finally` 确保 loading 状态总是被清理
Delete operation characteristics:
## 加载状态管理模式
- Directly call service without pre-updating state
- Rely on loading state for user feedback
- Refresh entire list after operation to ensure consistency
- Use `try/finally` to ensure loading state is always cleaned up
LobeChat 使用统一的加载状态管理模式:
## Loading State Management Pattern
### 数组式加载状态
LobeChat uses a unified loading state management pattern:
### Array-based Loading State
```typescript
// initialState.ts 中定义
// Define in initialState.ts
export interface ChatMessageState {
messageLoadingIds: string[]; // 消息加载状态
messageEditingIds: string[]; // 消息编辑状态
chatLoadingIds: string[]; // 对话生成状态
messageEditingIds: string[]; // Message editing state
}
// action 中管理
internal_toggleMessageLoading: (loading, id) => {
set({
messageLoadingIds: toggleBooleanList(get().messageLoadingIds, id, loading),
}, false, `internal_toggleMessageLoading/${loading ? 'start' : 'end'}`);
},
// Manage in action
{
toggleMessageEditing: (id, editing) => {
set(
{ messageEditingIds: toggleBooleanList(get().messageEditingIds, id, editing) },
false,
'toggleMessageEditing',
);
};
}
```
### 统一的加载状态工具
## SWR Integration Pattern
LobeChat uses SWR for data fetching and cache management:
### Hook-based Data Fetching
```typescript
// 通用的加载状态切换工具
internal_toggleLoadingArrays: (key, loading, id, action) => {
const abortControllerKey = `${key}AbortController`;
if (loading) {
const abortController = new AbortController();
set({
[abortControllerKey]: abortController,
[key]: toggleBooleanList(get()[key] as string[], id!, loading),
}, false, action);
return abortController;
} else {
set({
[abortControllerKey]: undefined,
[key]: id ? toggleBooleanList(get()[key] as string[], id, loading) : [],
}, false, action);
}
},
```
## SWR 集成模式
LobeChat 使用 SWR 进行数据获取和缓存管理:
### Hook 式数据获取
```typescript
// 在 action.ts 中定义 SWR hook
// Define SWR hook in action.ts
useFetchMessages: (enable, sessionId, activeTopicId) =>
useClientDataSWR<ChatMessage[]>(
enable ? [SWR_USE_FETCH_MESSAGES, sessionId, activeTopicId] : null,
@@ -304,57 +274,55 @@ useFetchMessages: (enable, sessionId, activeTopicId) =>
...get().messagesMap,
[messageMapKey(sessionId, activeTopicId)]: messages,
};
if (get().messagesInit && isEqual(nextMap, get().messagesMap)) return;
set({ messagesInit: true, messagesMap: nextMap }, false, n('useFetchMessages'));
},
},
),
```
### 缓存失效和刷新
### Cache Invalidation and Refresh
```typescript
// 刷新数据的标准模式
// Standard data refresh pattern
refreshMessages: async () => {
await mutate([SWR_USE_FETCH_MESSAGES, get().activeId, get().activeTopicId]);
},
refreshTopic: async () => {
return mutate([SWR_USE_FETCH_TOPIC, get().activeId]);
},
};
```
## 命名规范总结
## Naming Convention Summary
### Action 命名模式
- Public Actions: 动词形式,描述用户意图
### Action Naming Patterns
- Public Actions: Verb form, describing user intent
- `createTopic`, `sendMessage`, `regenerateMessage`
- Internal Actions: `internal_` + 动词,描述内部操作
- Internal Actions: `internal_` + verb, describing internal operation
- `internal_createTopic`, `internal_updateMessageContent`
- Dispatch Methods: `internal_dispatch` + 实体名
- Dispatch Methods: `internal_dispatch` + entity name
- `internal_dispatchTopic`, `internal_dispatchMessage`
- Toggle Methods: `internal_toggle` + 状态名
- Toggle Methods: `internal_toggle` + state name
- `internal_toggleMessageLoading`, `internal_toggleChatLoading`
### 状态命名模式
- ID 数组: `[entity]LoadingIds`, `[entity]EditingIds`
- 映射结构: `[entity]Maps`, `[entity]Map`
- 当前激活: `active[Entity]Id`
- 初始化标记: `[entity]sInit`
### State Naming Patterns
## 最佳实践
- ID arrays: `[entity]LoadingIds`, `[entity]EditingIds`
- Map structures: `[entity]Maps`, `[entity]Map`
- Currently active: `active[Entity]Id`
- Initialization flags: `[entity]sInit`
1. 合理使用乐观更新:
- ✅ 适用:创建、更新操作(用户交互频繁)
- ❌ 避免:删除操作(破坏性操作,错误恢复复杂)
2. 加载状态管理:使用统一的加载状态数组管理并发操作
3. 类型安全:为所有 action payload 定义 TypeScript 接口
4. SWR 集成:使用 SWR 管理数据获取和缓存失效
5. AbortController:为长时间运行的操作提供取消能力
6. 操作模式选择:
- 创建/更新:乐观更新 + 最终一致性
- 删除:加载状态 + 服务调用 + 数据刷新
## Best Practices
这套 Action 组织模式确保了代码的一致性、可维护性,并提供了优秀的用户体验。
1. Use optimistic updates appropriately:
- ✅ Suitable: Create, update operations (frequent user interaction)
- ❌ Avoid: Delete operations (destructive, complex error recovery)
2. Loading state management: Use unified loading state arrays to manage concurrent operations
3. Type safety: Define TypeScript interfaces for all action payloads
4. SWR integration: Use SWR to manage data fetching and cache invalidation
5. AbortController: Provide cancellation capability for long-running operations
6. Operation mode selection:
- Create/Update: Optimistic update + eventual consistency
- Delete: Loading state + service call + data refresh
This Action organization pattern ensures code consistency, maintainability, and provides excellent user experience.
+16 -8
View File
@@ -1,8 +1,9 @@
---
description:
description:
globs: src/store/**
alwaysApply: false
---
# LobeChat Zustand Store Slice 组织架构
本文档描述了 LobeChat 项目中 Zustand Store 的模块化 Slice 组织方式,展示如何通过分片架构管理复杂的应用状态。
@@ -69,7 +70,7 @@ export const useChatStore = createWithEqualityFn<ChatStore>()(
每个 slice 位于 `src/store/chat/slices/[sliceName]/` 目录下:
```
```plaintext
src/store/chat/slices/
└── [sliceName]/ # 例如 message, topic, aiChat, builtinTool
├── action.ts # 定义 actions (或者是一个 actions/ 目录)
@@ -159,15 +160,16 @@ export const topicReducer = (state: ChatTopic[] = [], payload: ChatTopicDispatch
// 典型的 selectors.ts 结构
import { ChatStoreState } from '../../initialState';
const currentTopics = (s: ChatStoreState): ChatTopic[] | undefined =>
s.topicMaps[s.activeId];
const currentTopics = (s: ChatStoreState): ChatTopic[] | undefined => s.topicMaps[s.activeId];
const currentActiveTopic = (s: ChatStoreState): ChatTopic | undefined => {
return currentTopics(s)?.find((topic) => topic.id === s.activeTopicId);
};
const getTopicById = (id: string) => (s: ChatStoreState): ChatTopic | undefined =>
currentTopics(s)?.find((topic) => topic.id === id);
const getTopicById =
(id: string) =>
(s: ChatStoreState): ChatTopic | undefined =>
currentTopics(s)?.find((topic) => topic.id === id);
// 核心模式:使用 xxxSelectors 聚合导出
export const topicSelectors = {
@@ -219,13 +221,15 @@ src/store/chat/slices/builtinTool/
## 状态设计模式
### 1. Map 结构用于关联数据
```typescript
// 以 sessionId 为 key,管理多个会话的数据
topicMaps: Record<string, ChatTopic[]>
messagesMap: Record<string, ChatMessage[]>
topicMaps: Record<string, ChatTopic[]>;
messagesMap: Record<string, ChatMessage[]>;
```
### 2. 数组用于加载状态管理
```typescript
// 管理多个并发操作的加载状态
messageLoadingIds: string[]
@@ -234,6 +238,7 @@ chatLoadingIds: string[]
```
### 3. 可选字段用于当前活动项
```typescript
// 当前激活的实体 ID
activeId: string
@@ -244,6 +249,7 @@ activeThreadId?: string
## Slice 集成到顶层 Store
### 1. 状态聚合
```typescript
// 在 initialState.ts 中
export type ChatStoreState = ChatTopicState &
@@ -253,6 +259,7 @@ export type ChatStoreState = ChatTopicState &
```
### 2. Action 接口聚合
```typescript
// 在 store.ts 中
export interface ChatStoreAction
@@ -263,6 +270,7 @@ export interface ChatStoreAction
```
### 3. Selector 统一导出
```typescript
// 在 selectors.ts 中 - 统一聚合 selectors
export { chatSelectors } from './slices/message/selectors';
+30
View File
@@ -0,0 +1,30 @@
name: Setup Node and Bun
description: Setup Node.js and Bun for workflows
inputs:
node-version:
description: Node.js version
required: true
bun-version:
description: Bun version
required: true
package-manager-cache:
description: Pass-through to actions/setup-node package-manager-cache
required: false
default: 'false'
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: ${{ inputs.package-manager-cache }}
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ inputs.bun-version }}
@@ -0,0 +1,27 @@
name: Setup Node and pnpm
description: Setup Node.js and pnpm for workflows
inputs:
node-version:
description: Node.js version
required: true
package-manager-cache:
description: Pass-through to actions/setup-node package-manager-cache
required: false
default: 'false'
runs:
using: composite
steps:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: ${{ inputs.package-manager-cache }}
@@ -0,0 +1,85 @@
name: Desktop Next Build
on:
workflow_dispatch:
push:
branches:
- next
pull_request:
paths:
- 'apps/desktop/**'
- 'scripts/electronWorkflow/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'bun.lockb'
- 'src/**'
- 'packages/**'
- '.github/workflows/desktop-build-electron.yml'
concurrency:
group: desktop-electron-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
NODE_VERSION: 24.11.1
BUN_VERSION: 1.2.23
jobs:
build-next:
name: Build desktop Next bundle
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=6144
UPDATE_CHANNEL: nightly
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID || 'dummy-desktop-project' }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL || 'https://analytics.example.com' }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-
${{ runner.os }}-pnpm-store-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install desktop dependencies
run: |
cd apps/desktop
bun run install-isolated
- name: Build desktop Next.js bundle
run: bun run desktop:build-electron
+341
View File
@@ -0,0 +1,341 @@
name: Desktop Manual Build
on:
workflow_dispatch:
inputs:
channel:
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
required: true
default: nightly
type: choice
options:
- nightly
- beta
- stable
build_macos:
description: 'Build macOS artifacts'
required: true
default: true
type: boolean
build_windows:
description: 'Build Windows artifacts'
required: true
default: true
type: boolean
build_linux:
description: 'Build Linux artifacts'
required: true
default: true
type: boolean
version:
description: 'Override desktop version (e.g. 1.2.3). Leave empty to auto-generate.'
required: false
default: ''
concurrency:
group: manual-${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true
permissions:
contents: read
env:
NODE_VERSION: 24.11.1
BUN_VERSION: 1.2.23
jobs:
test:
name: Code quality check
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node & Bun
uses: ./.github/actions/setup-node-bun
with:
node-version: ${{ env.NODE_VERSION }}
bun-version: ${{ env.BUN_VERSION }}
package-manager-cache: 'false'
- name: Install deps
run: bun i
env:
NODE_OPTIONS: --max-old-space-size=6144
- name: Lint
run: bun run lint
env:
NODE_OPTIONS: --max-old-space-size=6144
version:
name: Determine version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set_version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: false
- name: Set version
id: set_version
env:
INPUT_VERSION: ${{ inputs.version }}
CHANNEL: ${{ inputs.channel }}
run: |
base_version=$(node -p "require('./apps/desktop/package.json').version")
if [ -n "$INPUT_VERSION" ]; then
version="$INPUT_VERSION"
echo "📦 Using provided version: ${version} (base: ${base_version})"
else
ci_build_number="${{ github.run_number }}"
version="0.0.0-${CHANNEL}.manual.${ci_build_number}"
echo "📦 Generated version: ${version} (base: ${base_version})"
fi
echo "version=${version}" >> $GITHUB_OUTPUT
- name: Version Summary
run: |
echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}"
build-macos:
needs: [version, test]
name: Build Desktop App (macOS)
if: inputs.build_macos
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: 'false'
# node-linker=hoisted 模式将可以确保 asar 压缩可用
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
- name: Build artifact on macOS
run: npm run desktop:build
env:
UPDATE_CHANNEL: ${{ inputs.channel }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
CSC_FOR_PULL_REQUEST: true
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Rename macOS latest-mac.yml for multi-architecture support
if: runner.os == 'macOS'
run: |
cd apps/desktop/release
if [ -f "latest-mac.yml" ]; then
SYSTEM_ARCH=$(uname -m)
if [[ "$SYSTEM_ARCH" == "arm64" ]]; then
ARCH_SUFFIX="arm64"
else
ARCH_SUFFIX="x64"
fi
mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml"
echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml (detected: $SYSTEM_ARCH)"
ls -la latest-mac-*.yml
else
echo "⚠️ latest-mac.yml not found, skipping rename"
ls -la latest*.yml || echo "No latest*.yml files found"
fi
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: release-${{ matrix.os }}
path: |
apps/desktop/release/latest*
apps/desktop/release/*.dmg*
apps/desktop/release/*.zip*
apps/desktop/release/*.exe*
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb*
apps/desktop/release/*.snap*
apps/desktop/release/*.rpm*
apps/desktop/release/*.tar.gz*
retention-days: 5
build-windows:
needs: [version, test]
name: Build Desktop App (Windows)
if: inputs.build_windows
runs-on: windows-2025
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: 'false'
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
- name: Build artifact on Windows
run: npm run desktop:build
env:
UPDATE_CHANNEL: ${{ inputs.channel }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
TEMP: C:\temp
TMP: C:\temp
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: release-windows-2025
path: |
apps/desktop/release/latest*
apps/desktop/release/*.dmg*
apps/desktop/release/*.zip*
apps/desktop/release/*.exe*
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb*
apps/desktop/release/*.snap*
apps/desktop/release/*.rpm*
apps/desktop/release/*.tar.gz*
retention-days: 5
build-linux:
needs: [version, test]
name: Build Desktop App (Linux)
if: inputs.build_linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: 'false'
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
- name: Build artifact on Linux
run: npm run desktop:build
env:
UPDATE_CHANNEL: ${{ inputs.channel }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: release-ubuntu-latest
path: |
apps/desktop/release/latest*
apps/desktop/release/*.dmg*
apps/desktop/release/*.zip*
apps/desktop/release/*.exe*
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb*
apps/desktop/release/*.snap*
apps/desktop/release/*.rpm*
apps/desktop/release/*.tar.gz*
retention-days: 5
merge-mac-files:
needs: [build-macos, version]
name: Merge macOS Release Files
runs-on: ubuntu-latest
permissions:
contents: read
if: inputs.build_macos
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node & Bun
uses: ./.github/actions/setup-node-bun
with:
node-version: ${{ env.NODE_VERSION }}
bun-version: ${{ env.BUN_VERSION }}
package-manager-cache: 'false'
- name: Download artifacts
uses: actions/download-artifact@v6
with:
path: release
pattern: release-*
merge-multiple: true
- name: List downloaded artifacts
run: ls -R release
- name: Install yaml only for merge step
run: |
cd scripts/electronWorkflow
if [ ! -f package.json ]; then
echo '{"name":"merge-mac-release","private":true}' > package.json
fi
bun add --no-save yaml@2.8.1
- name: Merge latest-mac.yml files
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
- name: Upload artifacts with merged macOS files
uses: actions/upload-artifact@v5
with:
name: merged-release-manual
path: release/
retention-days: 1
+18 -31
View File
@@ -29,16 +29,12 @@ jobs:
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
- name: Setup Node & Bun
uses: ./.github/actions/setup-node-bun
with:
node-version: 24.11.1
package-manager-cache: false
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.23
package-manager-cache: 'false'
- name: Install deps
run: bun i
@@ -103,16 +99,11 @@ jobs:
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: 24.11.1
package-manager-cache: false
package-manager-cache: 'false'
# node-linker=hoisted 模式将可以确保 asar 压缩可用
- name: Install dependencies
@@ -132,11 +123,11 @@ jobs:
run: npm run desktop:build
env:
# 设置更新通道,PR构建为nightly,否则为stable
UPDATE_CHANNEL: "nightly"
UPDATE_CHANNEL: 'nightly'
APP_URL: http://localhost:3015
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
# 默认添加一个加密 SECRET
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
# macOS 签名和公证配置(fork 的 PR 访问不到 secrets,会跳过签名)
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -156,10 +147,10 @@ jobs:
run: npm run desktop:build
env:
# 设置更新通道,PR构建为nightly,否则为stable
UPDATE_CHANNEL: "nightly"
UPDATE_CHANNEL: 'nightly'
APP_URL: http://localhost:3015
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
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 目录设置到 C 盘
@@ -172,10 +163,10 @@ jobs:
run: npm run desktop:build
env:
# 设置更新通道,PR构建为nightly,否则为stable
UPDATE_CHANNEL: "nightly"
UPDATE_CHANNEL: 'nightly'
APP_URL: http://localhost:3015
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
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 }}
@@ -229,16 +220,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
- name: Setup Node & Bun
uses: ./.github/actions/setup-node-bun
with:
node-version: 24.11.1
package-manager-cache: false
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.23
package-manager-cache: 'false'
# 下载所有平台的构建产物
- name: Download artifacts
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=6144
- name: Typecheck Desktop
run: pnpm typecheck
run: pnpm type-check
working-directory: apps/desktop
- name: Test Desktop Client
+2 -1
View File
@@ -93,7 +93,6 @@ robots.txt
.husky/prepare-commit-msg
# Documents and media
*.patch
*.pdf
# Cloud service keys
@@ -116,3 +115,5 @@ CLAUDE.local.md
*.xls*
e2e/reports
out
+2 -1
View File
@@ -26,6 +26,7 @@ The project follows a well-organized monorepo structure:
- `src/` - Main source code
- `docs/` - Documentation
- `.cursor/rules/` - Development rules and guidelines
- PR titles starting with `✨ feat/` or `🐛 fix` will trigger the release workflow upon merge. Only use these prefixes for significant user-facing feature changes or bug fixes
## Development Workflow
@@ -83,7 +84,7 @@ All following rules are saved under `.cursor/rules/` directory:
### Frontend
- `react-component.mdc` React component style guide and conventions
- `react.mdc` React component style guide and conventions
- `i18n.mdc` Internationalization guide using react-i18next
- `typescript.mdc` TypeScript code style guide
- `packages/react-layout-kit.mdc` Usage guide for react-layout-kit
+219
View File
@@ -2,6 +2,225 @@
# Changelog
## [Version 2.0.0-next.171](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.170...v2.0.0-next.171)
<sup>Released on **2025-12-14**</sup>
#### 💄 Styles
- **misc**: Update GPT-5.2 models.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Update GPT-5.2 models, closes [#10749](https://github.com/lobehub/lobe-chat/issues/10749) ([0446127](https://github.com/lobehub/lobe-chat/commit/0446127))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.170](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.169...v2.0.0-next.170)
<sup>Released on **2025-12-12**</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>
## [Version 2.0.0-next.169](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.168...v2.0.0-next.169)
<sup>Released on **2025-12-12**</sup>
#### 🐛 Bug Fixes
- **misc**: Fix CVE errors.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Fix CVE errors, closes [#10748](https://github.com/lobehub/lobe-chat/issues/10748) ([6591f3c](https://github.com/lobehub/lobe-chat/commit/6591f3c))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.168](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.167...v2.0.0-next.168)
<sup>Released on **2025-12-12**</sup>
#### 🐛 Bug Fixes
- **misc**: Slove market oidc error.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Slove market oidc error, closes [#10715](https://github.com/lobehub/lobe-chat/issues/10715) ([108d2a7](https://github.com/lobehub/lobe-chat/commit/108d2a7))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.167](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.166...v2.0.0-next.167)
<sup>Released on **2025-12-11**</sup>
#### ✨ Features
- **misc**: Add Replicate image provider.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **misc**: Add Replicate image provider ([542f4d9](https://github.com/lobehub/lobe-chat/commit/542f4d9))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.166](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.165...v2.0.0-next.166)
<sup>Released on **2025-12-09**</sup>
#### 🐛 Bug Fixes
- **Dockerfile**: Electron main typing pkg.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **Dockerfile**: Electron main typing pkg, closes [#10693](https://github.com/lobehub/lobe-chat/issues/10693) ([f3357b0](https://github.com/lobehub/lobe-chat/commit/f3357b0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.165](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.164...v2.0.0-next.165)
<sup>Released on **2025-12-09**</sup>
#### ♻ Code Refactoring
- **electron-main**: Client ipc decorate.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Code refactoring
- **electron-main**: Client ipc decorate, closes [#10679](https://github.com/lobehub/lobe-chat/issues/10679) ([f74befa](https://github.com/lobehub/lobe-chat/commit/f74befa))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.164](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.163...v2.0.0-next.164)
<sup>Released on **2025-12-08**</sup>
#### 💄 Styles
- **profile**: Add mobile responsive layout and signup improvements.
- **misc**: Update link handling in PlanTag component to use react-router-dom.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **profile**: Add mobile responsive layout and signup improvements, closes [#10669](https://github.com/lobehub/lobe-chat/issues/10669) ([1afd471](https://github.com/lobehub/lobe-chat/commit/1afd471))
- **misc**: Update link handling in PlanTag component to use react-router-dom, closes [#10673](https://github.com/lobehub/lobe-chat/issues/10673) ([3aceeb6](https://github.com/lobehub/lobe-chat/commit/3aceeb6))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.163](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.162...v2.0.0-next.163)
<sup>Released on **2025-12-06**</sup>
#### 🐛 Bug Fixes
- **misc**: Add smooth scroll to top on 'More' button click in Title component.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Add smooth scroll to top on 'More' button click in Title component, closes [#10178](https://github.com/lobehub/lobe-chat/issues/10178) ([5ad4f0c](https://github.com/lobehub/lobe-chat/commit/5ad4f0c))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.162](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.161...v2.0.0-next.162)
<sup>Released on **2025-12-05**</sup>
+4 -1
View File
@@ -19,6 +19,7 @@ read @.cursor/rules/project-structure.mdc
- git commit message should prefix with gitmoji
- git branch name format example: tj/feat/feature-name
- use .github/PULL_REQUEST_TEMPLATE.md to generate pull request description
- PR titles starting with `✨ feat/` or `🐛 fix` will trigger the release workflow upon merge. Only use these prefixes for significant user-facing feature changes or bug fixes
### Package Management
@@ -44,6 +45,8 @@ see @.cursor/rules/typescript.mdc
- wrap the file path in single quotes to avoid shell expansion
- Never run `bun run test` etc to run tests, this will run all tests and cost about 10mins
- If trying to fix the same test twice, but still failed, stop and ask for help.
- **Prefer `vi.spyOn` over `vi.mock`**: When mocking modules or functions, prefer using `vi.spyOn` to mock specific functions rather than `vi.mock` to mock entire modules. This approach is more targeted, easier to maintain, and allows for better control over mock behavior in individual tests.
- **Tests must pass type check**: After writing or modifying tests, run `bun run type-check` to ensure there are no type errors. Tests should pass both runtime execution and TypeScript type checking.
### Typecheck
@@ -55,7 +58,7 @@ see @.cursor/rules/typescript.mdc
- **Dev**: Translate `locales/zh-CN/namespace.json` and `locales/en-US/namespace.json` locales file only for dev preview
- DON'T run `pnpm i18n`, let CI auto handle it
## Linear Issue Management
## Linear Issue Management (ignore if not installed linear mcp)
When working with Linear issues:
+58 -51
View File
@@ -8,29 +8,24 @@ ARG USE_CN_MIRROR
ENV DEBIAN_FRONTEND="noninteractive"
RUN \
# If you want to build docker in China, build with --build-arg USE_CN_MIRROR=true
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"; \
fi \
# Add required package
&& apt update \
&& apt install ca-certificates proxychains-ng -qy \
# Prepare required package to distroless
&& mkdir -p /distroless/bin /distroless/etc /distroless/etc/ssl/certs /distroless/lib \
# Copy proxychains to distroless
&& cp /usr/lib/$(arch)-linux-gnu/libproxychains.so.4 /distroless/lib/libproxychains.so.4 \
&& cp /usr/lib/$(arch)-linux-gnu/libdl.so.2 /distroless/lib/libdl.so.2 \
&& cp /usr/bin/proxychains4 /distroless/bin/proxychains \
&& cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf \
# Copy node to distroless
&& cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6 \
&& cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1 \
&& cp /usr/local/bin/node /distroless/bin/node \
# Copy CA certificates to distroless
&& cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt \
# Cleanup temp files
&& rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
RUN <<'EOF'
set -e
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"
fi
apt update
apt install ca-certificates proxychains-ng -qy
mkdir -p /distroless/bin /distroless/etc /distroless/etc/ssl/certs /distroless/lib
cp /usr/lib/$(arch)-linux-gnu/libproxychains.so.4 /distroless/lib/libproxychains.so.4
cp /usr/lib/$(arch)-linux-gnu/libdl.so.2 /distroless/lib/libdl.so.2
cp /usr/bin/proxychains4 /distroless/bin/proxychains
cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf
cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6
cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1
cp /usr/local/bin/node /distroless/bin/node
cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
EOF
## Builder image, install all the dependencies and build the app
FROM base AS builder
@@ -86,35 +81,45 @@ WORKDIR /app
COPY package.json pnpm-workspace.yaml ./
COPY .npmrc ./
COPY packages ./packages
# bring in desktop workspace manifest so pnpm can resolve it
COPY apps/desktop/src/main/package.json ./apps/desktop/src/main/package.json
RUN \
# If you want to build docker in China, build with --build-arg USE_CN_MIRROR=true
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
export SENTRYCLI_CDNURL="https://npmmirror.com/mirrors/sentry-cli"; \
npm config set registry "https://registry.npmmirror.com/"; \
echo 'canvas_binary_host_mirror=https://npmmirror.com/mirrors/canvas' >> .npmrc; \
fi \
# Set the registry for corepack
&& export COREPACK_NPM_REGISTRY=$(npm config get registry | sed 's/\/$//') \
# Update corepack to latest (nodejs/corepack#612)
&& npm i -g corepack@latest \
# Enable corepack
&& corepack enable \
# Use pnpm for corepack
&& corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json) \
# Install the dependencies
&& pnpm i \
# Add db migration dependencies
&& mkdir -p /deps \
&& cd /deps \
&& pnpm init \
&& pnpm add pg drizzle-orm
RUN <<'EOF'
set -e
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then
export SENTRYCLI_CDNURL="https://npmmirror.com/mirrors/sentry-cli"
npm config set registry "https://registry.npmmirror.com/"
echo 'canvas_binary_host_mirror=https://npmmirror.com/mirrors/canvas' >> .npmrc
fi
export COREPACK_NPM_REGISTRY=$(npm config get registry | sed 's/\/$//')
npm i -g corepack@latest
corepack enable
corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json)
pnpm i
mkdir -p /deps
cd /deps
pnpm init
pnpm add pg drizzle-orm
EOF
COPY . .
# run build standalone for docker version
RUN npm run build:docker
# Prepare desktop export assets for Electron packaging (if generated)
RUN <<'EOF'
set -e
if [ -d "/app/out" ]; then
mkdir -p /app/apps/desktop/dist/next
cp -a /app/out/. /app/apps/desktop/dist/next/
echo "✅ Copied Next export output into /app/apps/desktop/dist/next"
else
echo "️ No Next export output found at /app/out, creating empty directory"
mkdir -p /app/apps/desktop/dist/next
fi
EOF
## Application image, copy all the files for production
FROM busybox:latest AS app
@@ -123,6 +128,8 @@ COPY --from=base /distroless/ /
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/.next/standalone /app/
# Copy Next export output for desktop renderer
COPY --from=builder /app/apps/desktop/dist/next /app/apps/desktop/dist/next
# Copy database migrations
COPY --from=builder /app/packages/database/migrations /app/migrations
@@ -137,12 +144,12 @@ COPY --from=builder /deps/node_modules/drizzle-orm /app/node_modules/drizzle-orm
# Copy server launcher
COPY --from=builder /app/scripts/serverLauncher/startServer.js /app/startServer.js
RUN \
# Add nextjs:nodejs to run the app
addgroup -S -g 1001 nodejs \
&& adduser -D -G nodejs -H -S -h /app -u 1001 nextjs \
# Set permission for nextjs:nodejs
&& chown -R nextjs:nodejs /app /etc/proxychains4.conf
RUN <<'EOF'
set -e
addgroup -S -g 1001 nodejs
adduser -D -G nodejs -H -S -h /app -u 1001 nextjs
chown -R nextjs:nodejs /app /etc/proxychains4.conf
EOF
## Production image, copy all the files and run next
FROM scratch
+1
View File
@@ -18,6 +18,7 @@ read @.cursor/rules/project-structure.mdc
- git commit message should prefix with gitmoji
- git branch name format example: tj/feat/feature-name
- use .github/PULL_REQUEST_TEMPLATE.md to generate pull request description
- PR titles starting with `✨ feat/` or `🐛 fix` will trigger the release workflow upon merge. Only use these prefixes for significant user-facing feature changes or bug fixes
### Package Management
+7 -7
View File
@@ -345,12 +345,12 @@ In addition, these plugins are not limited to news aggregation, but can also ext
<!-- PLUGIN LIST -->
| Recent Submits | Description |
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-11-28**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
| [SEO](https://lobechat.com/discover/plugin/SEO)<br/><sup>By **orrenprunckun** on **2025-11-14**</sup> | Enter any URL and keyword and get an On-Page SEO analysis & insights!<br/>`seo` |
| [Shopping tools](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2025-10-27**</sup> | Search for products on eBay & AliExpress, find eBay events & coupons. Get prompt examples.<br/>`shopping` `e-bay` `ali-express` `coupons` |
| [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` |
| Recent Submits | Description |
| ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Video Captions](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | Convert Youtube links into transcribed text, enable asking questions, create chapters, and summarize its content.<br/>`video-to-text` `youtube` |
| [WeatherGPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | Get current weather information for a specific location.<br/>`weather` |
| [Git OSS Stats](https://lobechat.com/discover/plugin/gitUserRepoStats)<br/><sup>By **yunwei37** on **2025-12-13**</sup> | Dynamically generate and analyze stats and history for OSS repos and developers.<br/>`github` `oss` |
| [Questmate Forms](https://lobechat.com/discover/plugin/questmate)<br/><sup>By **questmate** on **2025-12-13**</sup> | Create forms, checklists and workflows (we call 'em Quests!) that you can assign, schedule or make public.<br/>`forms` `checklists` `productivity` |
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
@@ -387,8 +387,8 @@ Our marketplace is not just a showcase platform but also a collaborative space.
| Recent Submits | Description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Turtle Soup Host](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | A turtle soup host needs to provide the scenario, the complete story (truth of the event), and the key point (the condition for guessing correctly).<br/>`turtle-soup` `reasoning` `interaction` `puzzle` `role-playing` |
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
| [Academic Writing Assistant](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | Expert in academic research paper writing and formal documentation<br/>`academic-writing` `research` `formal-style` |
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
| [Minecraft Senior Developer](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | Expert in advanced Java development and Minecraft mod and server plugin development<br/>`development` `programming` `minecraft` `java` |
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
+7 -7
View File
@@ -338,12 +338,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
<!-- PLUGIN LIST -->
| 最近新增 | 描述 |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-11-28**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
| [SEO](https://lobechat.com/discover/plugin/SEO)<br/><sup>By **orrenprunckun** on **2025-11-14**</sup> | 输入任何 URL 和关键词,获取页面 SEO 分析和见解!<br/>`seo` |
| [购物工具](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2025-10-27**</sup> | 在 eBay 和 AliExpress 上搜索产品,查找 eBay 活动和优惠券。获取快速示例。<br/>`购物` `e-bay` `ali-express` `优惠券` |
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
| 最近新增 | 描述 |
| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| [视频字幕](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | 将 Youtube 链接转换为转录文本,使其能够提问,创建章节,并总结其内容。<br/>`视频转文字` `you-tube` |
| [天气 GPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | 获取特定位置的当前天气信息。<br/>`天气` |
| [Git OSS Stats](https://lobechat.com/discover/plugin/gitUserRepoStats)<br/><sup>By **yunwei37** on **2025-12-13**</sup> | 动态生成和分析开源软件仓库和开发者的统计数据和历史记录。<br/>`github` `oss` |
| [Questmate Forms](https://lobechat.com/discover/plugin/questmate)<br/><sup>By **questmate** on **2025-12-13**</sup> | 创建表单、清单和工作流程(我们称之为任务!),您可以分配、安排或公开。<br/>`表单` `清单` `生产力` |
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
@@ -376,8 +376,8 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
| 最近新增 | 描述 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| [海龟汤主持人](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | 一个海龟汤主持人,需要自己提供汤面,汤底与关键点(猜中的判定条件)。<br/>`海龟汤` `推理` `互动` `谜题` `角色扮演` |
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
| [学术写作助手](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | 专业的学术研究论文写作和正式文档编写专家<br/>`学术写作` `研究` `正式风格` |
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
| [Minecraft 资深开发者](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | 擅长高级 Java 开发及 Minecraft 开发<br/>`开发` `编程` `minecraft` `java` |
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
+16
View File
@@ -4,3 +4,19 @@ ignore-workspace-root-check=true
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
public-hoist-pattern[]=*@umijs/lint*
public-hoist-pattern[]=*unicorn*
public-hoist-pattern[]=*changelog*
public-hoist-pattern[]=*commitlint*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*remark*
public-hoist-pattern[]=*semantic-release*
public-hoist-pattern[]=*stylelint*
public-hoist-pattern[]=@auth/core
public-hoist-pattern[]=@clerk/backend
public-hoist-pattern[]=@clerk/types
public-hoist-pattern[]=pdfjs-dist
+62
View File
@@ -0,0 +1,62 @@
# Prettierignore for LobeHub
################################################################
# general
.DS_Store
.editorconfig
.idea
.history
.temp
.env.local
.husky
.npmrc
.gitkeep
venv
temp
tmp
LICENSE
# dependencies
node_modules
*.log
*.lock
package-lock.json
# ci
coverage
.coverage
.eslintcache
.stylelintcache
test-output
__snapshots__
*.snap
# production
dist
es
lib
logs
# umi
.umi
.umi-production
.umi-test
.dumi/tmp*
# ignore files
.*ignore
# docker
docker
Dockerfile*
# image
*.webp
*.gif
*.png
*.jpg
*.svg
# misc
# add other ignore file below
.next
+1
View File
@@ -0,0 +1 @@
module.exports = require('@lobehub/lint').prettier;
+1
View File
@@ -0,0 +1 @@
module.exports = require('@lobehub/lint').remarklint;
+39
View File
@@ -0,0 +1,39 @@
# Stylelintignore for LobeHub
################################################################
# dependencies
node_modules
# ci
coverage
.coverage
# production
dist
es
lib
logs
# framework specific
.next
.umi
.umi-production
.umi-test
.dumi/tmp*
# temporary directories
tmp
temp
.temp
.local
docs/.local
# cache directories
.cache
# AI coding tools directories
.claude
.serena
# MCP tools
/.serena/**
+9
View File
@@ -0,0 +1,9 @@
const config = require('@lobehub/lint').stylelint;
module.exports = {
...config,
rules: {
'selector-id-pattern': null,
...config.rules,
},
};
+38 -42
View File
@@ -156,24 +156,26 @@ apps/desktop/src/main/
- 事件广播:向渲染进程通知授权状态变化
```typescript
// 认证流程示例
@ipcClientEvent('requestAuthorization')
async requestAuthorization(config: DataSyncConfig) {
// 生成状态参数防止 CSRF 攻击
this.authRequestState = crypto.randomBytes(16).toString('hex');
import { ControllerModule, IpcMethod } from '@/controllers';
// 构建授权 URL
const authUrl = new URL('/oidc/auth', remoteUrl);
authUrl.search = querystring.stringify({
client_id: 'lobe-chat',
response_type: 'code',
redirect_uri: `${protocolPrefix}://auth/callback`,
scope: 'openid profile',
state: this.authRequestState,
});
export default class AuthCtr extends ControllerModule {
static override groupName = 'auth';
// 在默认浏览器中打开授权 URL
await shell.openExternal(authUrl.toString());
@IpcMethod()
async requestAuthorization(config: DataSyncConfig) {
this.authRequestState = crypto.randomBytes(16).toString('hex');
const authUrl = new URL('/oidc/auth', remoteUrl);
authUrl.search = querystring.stringify({
client_id: 'lobe-chat',
redirect_uri: `${protocolPrefix}://auth/callback`,
response_type: 'code',
scope: 'openid profile',
state: this.authRequestState,
});
await shell.openExternal(authUrl.toString());
}
}
```
@@ -267,20 +269,27 @@ export class ShortcutManager {
- 注入 App 实例
```typescript
// 控制器基类和装饰器
import { ControllerModule, IpcMethod, IpcServerMethod } from '@/controllers'
export class ControllerModule implements IControllerModule {
constructor(public app: App) {
this.app = app;
this.app = app
}
}
// IPC 客户端事件装饰器
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
ipcDecorator(method, 'client');
export class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows' // must be readonly
// IPC 服务器事件装饰器
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
ipcDecorator(method, 'server');
@IpcMethod()
openSettingsWindow(params?: OpenSettingsWindowOptions) {
// ...
}
@IpcServerMethod()
handleServerCommand(payload: any) {
// ...
}
}
```
2. **IoC 容器**
@@ -346,26 +355,13 @@ makeSureDirExist(storagePath);
- 自动映射控制器方法到 IPC 事件
```typescript
// IPC 事件初始化
private initializeIPCEvents() {
// 注册客户端事件处理程序
this.ipcClientEventMap.forEach((eventInfo, key) => {
ipcMain.handle(key, async (e, ...data) => {
return await eventInfo.controller[eventInfo.methodName](...data);
});
});
import { ensureElectronIpc } from '@/utils/electron/ipc';
// 注册服务器事件处理程序
const ipcServerEvents = {} as ElectronIPCEventHandler;
this.ipcServerEventMap.forEach((eventInfo, key) => {
ipcServerEvents[key] = async (payload) => {
return await eventInfo.controller[eventInfo.methodName](payload);
};
});
// 渲染进程中使用 type-safe proxy 调用主进程方法
const ipc = ensureElectronIpc();
// 创建 IPC 服务器
this.ipcServer = new ElectronIPCServer(name, ipcServerEvents);
}
await ipc.localSystem.readLocalFile({ path });
await ipc.system.updateLocale('en-US');
```
2. **事件广播**
+42 -6
View File
@@ -32,7 +32,7 @@ pnpm install-isolated
pnpm electron:dev
# Type checking
pnpm typecheck
pnpm type-check
# Run tests
pnpm test
@@ -66,9 +66,9 @@ cp .env.desktop .env
pnpm electron:dev # Start with hot reload
# 2. Code Quality
pnpm lint # ESLint checking
pnpm format # Prettier formatting
pnpm typecheck # TypeScript validation
pnpm lint # ESLint checking
pnpm format # Prettier formatting
pnpm type-check # TypeScript validation
# 3. Testing
pnpm test # Run Vitest tests
@@ -183,10 +183,18 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
#### 🔌 Dependency Injection & Event System
- **IoC Container** - WeakMap-based container for decorated controller methods
- **Decorator Registration** - `@ipcClientEvent` and `@ipcServerEvent` decorators
- **Typed IPC Decorators** - `@IpcMethod` and `@IpcServerMethod` wire controller methods into type-safe channels
- **Automatic Event Mapping** - Events registered during controller loading
- **Service Locator** - Type-safe service and controller retrieval
##### 🧠 Type-Safe IPC Flow
- **Async Context Propagation** - `src/main/utils/ipc/base.ts` captures the `IpcContext` with `AsyncLocalStorage`, so controller logic can call `getIpcContext()` anywhere inside an IPC handler without explicitly threading arguments.
- **Service Constructors Registry** - `src/main/controllers/registry.ts` exports `controllerIpcConstructors`, `DesktopIpcServices`, and `DesktopServerIpcServices`, enabling automatic typing of both renderer and server IPC proxies.
- **Renderer Proxy Helper** - `src/utils/electron/ipc.ts` exposes `ensureElectronIpc()` which lazily builds a proxy on top of `window.electronAPI.invoke`, giving React/Next.js code a type-safe API surface without exposing raw proxies in preload.
- **Server Proxy Helper** - `src/server/modules/ElectronIPCClient/index.ts` mirrors the same typing strategy for the Next.js server runtime, providing a dedicated proxy for `@IpcServerMethod` handlers.
- **Shared Typings Package** - `apps/desktop/src/main/exports.d.ts` augments `@lobechat/electron-client-ipc` so every package can consume `DesktopIpcServices` without importing desktop business code directly.
#### 🪟 Window Management
- **Theme-Aware Windows** - Automatic adaptation to system dark/light mode
@@ -235,6 +243,7 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
#### 🎮 Controller Pattern
- **Typed IPC Decorators** - Controllers extend `ControllerModule` and expose renderer methods via `@IpcMethod`
- **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
@@ -256,6 +265,33 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
- **Context Awareness** - Events include sender context for window-specific operations
- **Error Propagation** - Centralized error handling with proper status codes
##### 🧩 Renderer IPC Helper
Renderer code uses a lightweight proxy generated at runtime to keep IPC calls type-safe without exposing raw Electron objects through `contextBridge`. Use the helper exported from `src/utils/electron/ipc.ts` to access the main-process services:
```ts
import { ensureElectronIpc } from '@/utils/electron/ipc';
const ipc = ensureElectronIpc();
await ipc.windows.openSettingsWindow({ tab: 'provider' });
```
The helper internally builds a proxy on top of `window.electronAPI.invoke`, so no proxy objects need to be cloned across the preload boundary.
##### 🖥️ Server IPC Helper
Next.js (Node) modules use the same proxy pattern via `ensureElectronServerIpc` from `src/server/modules/ElectronIPCClient`. It lazily wraps the socket-based `ElectronIpcClient` so server code can call controllers with full type safety:
```ts
import { ensureElectronServerIpc } from '@/server/modules/ElectronIPCClient';
const ipc = ensureElectronServerIpc();
const dbPath = await ipc.system.getDatabasePath();
await ipc.upload.deleteFiles(['foo.txt']);
```
All server methods are declared via `@IpcServerMethod` and live in dedicated controller classes, keeping renderer typings clean.
#### 🛡️ Security Features
- **OAuth 2.0 + PKCE** - Secure authentication with state parameter validation
@@ -277,7 +313,7 @@ tests/ # Integration tests
```bash
pnpm test # Run all tests
pnpm test:watch # Watch mode
pnpm typecheck # Type validation
pnpm type-check # Type validation
```
### Test Coverage
+31 -6
View File
@@ -32,7 +32,7 @@ pnpm install-isolated
pnpm electron:dev
# 类型检查
pnpm typecheck
pnpm type-check
# 运行测试
pnpm test
@@ -66,9 +66,9 @@ cp .env.desktop .env
pnpm electron:dev # 启动热重载开发服务器
# 2. 代码质量
pnpm lint # ESLint 检查
pnpm format # Prettier 格式化
pnpm typecheck # TypeScript 验证
pnpm lint # ESLint 检查
pnpm format # Prettier 格式化
pnpm type-check # TypeScript 验证
# 3. 测试
pnpm test # 运行 Vitest 测试
@@ -183,7 +183,7 @@ src/main/core/
#### 🔌 依赖注入和事件系统
- **IoC 容器** - 基于 WeakMap 的装饰控制器方法容器
- **装饰器注册** - `@ipcClientEvent``@ipcServerEvent` 装饰器
- **装饰器注册** - `@IpcMethod``@IpcServerMethod` 装饰器
- **自动事件映射** - 控制器加载期间注册的事件
- **服务定位器** - 类型安全的服务和控制器检索
@@ -256,6 +256,31 @@ src/main/core/
- **上下文感知** - 事件包含用于窗口特定操作的发送者上下文
- **错误传播** - 具有适当状态码的集中错误处理
##### 🧩 渲染器 IPC 助手
渲染端通过 `src/utils/electron/ipc.ts` 提供的 `ensureElectronIpc` 获得一个运行时代理,无需在 preload 中暴露 Proxy 对象即可获得类型安全的调用体验:
```ts
import { ensureElectronIpc } from '@/utils/electron/ipc';
const ipc = ensureElectronIpc();
await ipc.windows.openSettingsWindow({ tab: 'provider' });
```
##### 🖥️ Server IPC 助手
Next.js 服务端模块可通过 `ensureElectronServerIpc`(位于 `src/server/modules/ElectronIPCClient`)获得同样的类型安全代理,并复用 socket IPC 通道:
```ts
import { ensureElectronServerIpc } from '@/server/modules/ElectronIPCClient';
const ipc = ensureElectronServerIpc();
const path = await ipc.system.getDatabasePath();
await ipc.upload.deleteFiles(['foo.txt']);
```
所有 `@IpcServerMethod` 方法都放在独立的控制器中,这样渲染端的类型推导不会包含这些仅供服务器调用的通道。
#### 🛡️ 安全功能
- **OAuth 2.0 + PKCE** - 具有状态参数验证的安全认证
@@ -277,7 +302,7 @@ tests/ # 集成测试
```bash
pnpm test # 运行所有测试
pnpm test:watch # 监视模式
pnpm typecheck # 类型验证
pnpm type-check # 类型验证
```
### 测试覆盖
+37 -1
View File
@@ -17,6 +17,10 @@ console.log(`🏗️ Building for architecture: ${arch}`);
const isNightly = channel === 'nightly';
const isBeta = packageJSON.name.includes('beta');
// Keep only these Electron Framework localization folders (*.lproj)
// (aligned with previous Electron Forge build config)
const keepLanguages = new Set(['en', 'en_GB', 'en-US', 'en_US']);
// https://www.electron.build/code-signing-mac#how-to-disable-code-signing-during-the-build-process-on-macos
if (!hasAppleCertificate) {
// Disable auto discovery to keep electron-builder from searching unavailable signing identities
@@ -54,7 +58,7 @@ const config = {
*/
afterPack: async (context) => {
// Only process macOS builds
if (context.electronPlatformName !== 'darwin') {
if (!['darwin', 'mas'].includes(context.electronPlatformName)) {
return;
}
@@ -68,6 +72,36 @@ const config = {
);
const assetsCarDest = path.join(resourcesPath, 'Assets.car');
// Remove unused Electron Framework localizations to reduce app size
// Equivalent to:
// ../../Frameworks/Electron Framework.framework/Versions/A/Resources/*.lproj
const frameworkResourcePath = path.join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Versions',
'A',
'Resources',
);
try {
const entries = await fs.readdir(frameworkResourcePath);
await Promise.all(
entries.map(async (file) => {
if (!file.endsWith('.lproj')) return;
const lang = file.split('.')[0];
if (keepLanguages.has(lang)) return;
await fs.rm(path.join(frameworkResourcePath, file), { force: true, recursive: true });
}),
);
} catch {
// Non-critical: folder may not exist depending on packaging details
}
try {
await fs.access(assetsCarSource);
await fs.copyFile(assetsCarSource, assetsCarDest);
@@ -106,6 +140,8 @@ const config = {
files: [
'dist',
'resources',
// Ensure Next export assets are packaged
'dist/next/**/*',
'!resources/locales',
'!dist/next/docs',
'!dist/next/packages',
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig({
resolve: {
alias: {
'~common': resolve(__dirname, 'src/common'),
'@': resolve(__dirname, 'src/main'),
},
},
},
+23 -7
View File
@@ -11,21 +11,30 @@
"author": "LobeHub",
"main": "./dist/main/index.js",
"scripts": {
"build": "npm run typecheck && electron-vite build",
"build": "electron-vite build",
"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",
"dev": "electron-vite dev",
"electron:dev": "electron-vite dev",
"electron:run-unpack": "electron .",
"format": "prettier --write ",
"i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n",
"postinstall": "electron-builder install-app-deps",
"install-isolated": "pnpm install",
"lint": "eslint --cache ",
"lint": "npm run lint:ts && npm run lint:style && npm run type-check && npm run lint:circular",
"lint:circular": "npm run lint:circular:main && npm run lint:circular:packages",
"lint:circular:main": "dpdm src/**/*.ts --no-warning --no-tree --exit-code circular:1 --no-progress -T true --skip-dynamic-imports circular",
"lint:circular:packages": "dpdm packages/**/src/**/*.ts --no-warning --no-tree --exit-code circular:1 --no-progress -T true --skip-dynamic-imports circular",
"lint:md": "remark . --silent --output",
"lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
"lint:ts": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
"start": "electron-vite preview",
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "vitest --run",
"type-check": "tsgo --noEmit -p tsconfig.json",
"typecheck": "tsgo --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -33,7 +42,8 @@
"electron-window-state": "^5.0.3",
"fetch-socks": "^1.3.2",
"get-port-please": "^3.2.0",
"pdfjs-dist": "4.10.38"
"pdfjs-dist": "4.10.38",
"superjson": "^2.2.6"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
@@ -41,16 +51,18 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/tsconfig": "^2.0.0",
"@electron-toolkit/utils": "^4.0.0",
"@lobechat/desktop-bridge": "workspace:*",
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
"@types/async-retry": "^1.4.9",
"@types/lodash": "^4.17.21",
"@types/resolve": "^1.20.6",
"@types/semver": "^7.7.1",
"@types/set-cookie-parser": "^2.4.10",
"@typescript/native-preview": "7.0.0-dev.20250711.1",
"@typescript/native-preview": "7.0.0-dev.20251210.1",
"async-retry": "^1.3.3",
"consola": "^3.4.2",
"cookie": "^1.1.1",
@@ -61,24 +73,28 @@
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-vite": "^4.0.1",
"eslint": "^8.57.1",
"execa": "^9.6.1",
"fast-glob": "^3.3.3",
"fix-path": "^5.0.0",
"happy-dom": "^20.0.11",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"i18next": "^25.6.3",
"i18next": "^25.7.2",
"just-diff": "^6.0.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"prettier": "^3.7.4",
"remark-cli": "^12.0.1",
"resolve": "^1.22.11",
"semver": "^7.7.3",
"set-cookie-parser": "^2.7.2",
"tsx": "^4.20.6",
"stylelint": "^15.11.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"undici": "^7.16.0",
"uuid": "^13.0.0",
"vite": "^7.2.4",
"vite": "^7.2.7",
"vitest": "^3.2.4"
},
"pnpm": {
+1
View File
@@ -2,4 +2,5 @@ packages:
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '.'
+7 -5
View File
@@ -1,4 +1,5 @@
import { app } from 'electron';
import { pathExistsSync } from 'fs-extra';
import { join } from 'node:path';
export const mainDir = join(__dirname);
@@ -11,7 +12,12 @@ export const buildDir = join(mainDir, '../../build');
const appPath = app.getAppPath();
export const nextStandaloneDir = join(appPath, 'dist', 'next');
const nextExportOutDir = join(appPath, 'dist', 'next', 'out');
const nextExportDefaultDir = join(appPath, 'dist', 'next');
export const nextExportDir = pathExistsSync(nextExportOutDir)
? nextExportOutDir
: nextExportDefaultDir;
export const userDataDir = app.getPath('userData');
@@ -19,10 +25,6 @@ export const appStorageDir = join(userDataDir, 'lobehub-storage');
// ------ Application storage directory ---- //
// db schema hash
export const DB_SCHEMA_HASH_FILENAME = 'lobehub-local-db-schema-hash';
// pglite database dir
export const LOCAL_DATABASE_DIR = 'lobehub-local-db';
// 本地存储文件(模拟 S3
export const FILE_STORAGE_DIR = 'file-storage';
// Plugin 安装目录
+1
View File
@@ -0,0 +1 @@
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
+1 -1
View File
@@ -25,7 +25,7 @@ export const defaultProxySettings: NetworkProxySettings = {
* 存储默认值
*/
export const STORE_DEFAULTS: ElectronMainStore = {
dataSyncConfig: { storageMode: 'local' },
dataSyncConfig: { storageMode: 'cloud' },
encryptedTokens: {},
locale: 'auto',
networkProxy: defaultProxySettings,
+5 -4
View File
@@ -7,7 +7,7 @@ import { URL } from 'node:url';
import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
// Create logger
const logger = createLogger('controllers:AuthCtr');
@@ -17,6 +17,7 @@ const logger = createLogger('controllers:AuthCtr');
* Implements OAuth authorization flow using intermediate page + polling mechanism
*/
export default class AuthCtr extends ControllerModule {
static override readonly groupName = 'auth';
/**
* Remote server configuration controller
*/
@@ -56,7 +57,7 @@ export default class AuthCtr extends ControllerModule {
/**
* Request OAuth authorization
*/
@ipcClientEvent('requestAuthorization')
@IpcMethod()
async requestAuthorization(config: DataSyncConfig) {
// Clear any old authorization state
this.clearAuthorizationState();
@@ -119,7 +120,7 @@ export default class AuthCtr extends ControllerModule {
/**
* Request Market OAuth authorization (desktop)
*/
@ipcClientEvent('requestMarketAuthorization')
@IpcMethod()
async requestMarketAuthorization(params: MarketAuthorizationParams) {
const { authUrl } = params;
@@ -562,7 +563,7 @@ export default class AuthCtr extends ControllerModule {
// Hash codeVerifier using SHA-256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const digest = await crypto.subtle.digest('SHA-256', data as unknown as NodeJS.BufferSource);
// Convert hash result to base64url encoding
const challenge = Buffer.from(digest)
@@ -1,22 +1,21 @@
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
import { findMatchingRoute } from '~common/routes';
import {
AppBrowsersIdentifiers,
WindowTemplateIdentifiers,
} from '@/appBrowsers';
import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { AppBrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
import { getIpcContext } from '@/utils/ipc';
import { ControllerModule, ipcClientEvent, shortcut } from './index';
import { ControllerModule, IpcMethod, shortcut } from './index';
export default class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows';
@shortcut('showApp')
async toggleMainWindow() {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.toggleVisible();
}
@ipcClientEvent('openSettingsWindow')
@IpcMethod()
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
const normalizedOptions: OpenSettingsWindowOptions =
typeof options === 'string' || options === undefined
@@ -43,8 +42,8 @@ export default class BrowserWindowsCtr extends ControllerModule {
const fullPath = `/settings${subPath}${queryString ? `?${queryString}` : ''}`;
const mainWindow = this.app.browserManager.getMainWindow();
await mainWindow.loadUrl(fullPath);
mainWindow.show();
mainWindow.broadcast('navigate', { path: fullPath });
return { success: true };
} catch (error) {
@@ -53,26 +52,32 @@ export default class BrowserWindowsCtr extends ControllerModule {
}
}
@ipcClientEvent('closeWindow')
closeWindow(data: undefined, sender: IpcClientEventSender) {
this.app.browserManager.closeWindow(sender.identifier);
@IpcMethod()
closeWindow() {
this.withSenderIdentifier((identifier) => {
this.app.browserManager.closeWindow(identifier);
});
}
@ipcClientEvent('minimizeWindow')
minimizeWindow(data: undefined, sender: IpcClientEventSender) {
this.app.browserManager.minimizeWindow(sender.identifier);
@IpcMethod()
minimizeWindow() {
this.withSenderIdentifier((identifier) => {
this.app.browserManager.minimizeWindow(identifier);
});
}
@ipcClientEvent('maximizeWindow')
maximizeWindow(data: undefined, sender: IpcClientEventSender) {
this.app.browserManager.maximizeWindow(sender.identifier);
@IpcMethod()
maximizeWindow() {
this.withSenderIdentifier((identifier) => {
this.app.browserManager.maximizeWindow(identifier);
});
}
/**
* Handle route interception requests
* Responsible for handling route interception requests from the renderer process
*/
@ipcClientEvent('interceptRoute')
@IpcMethod()
async interceptRoute(params: InterceptRouteParams) {
const { path, source } = params;
console.log(
@@ -115,7 +120,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
/**
* Create a new multi-instance window
*/
@ipcClientEvent('createMultiInstanceWindow')
@IpcMethod()
async createMultiInstanceWindow(params: {
path: string;
templateId: WindowTemplateIdentifiers;
@@ -149,7 +154,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
/**
* Get all windows by template
*/
@ipcClientEvent('getWindowsByTemplate')
@IpcMethod()
async getWindowsByTemplate(templateId: string) {
try {
const windowIds = this.app.browserManager.getWindowsByTemplate(templateId);
@@ -169,7 +174,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
/**
* Close all windows by template
*/
@ipcClientEvent('closeWindowsByTemplate')
@IpcMethod()
async closeWindowsByTemplate(templateId: string) {
try {
this.app.browserManager.closeWindowsByTemplate(templateId);
@@ -191,4 +196,12 @@ export default class BrowserWindowsCtr extends ControllerModule {
const browser = this.app.browserManager.retrieveByIdentifier(targetWindow);
browser.show();
}
private withSenderIdentifier(fn: (identifier: string) => void) {
const context = getIpcContext();
if (!context) return;
const identifier = this.app.browserManager.getIdentifierByWebContents(context.sender);
if (!identifier) return;
fn(identifier);
}
}
@@ -1,7 +1,9 @@
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
export default class DevtoolsCtr extends ControllerModule {
@ipcClientEvent('openDevtools')
static override readonly groupName = 'devtools';
@IpcMethod()
async openDevtools() {
const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
devtoolsBrowser.show();
@@ -30,19 +30,20 @@ import { FileResult, SearchOptions } from '@/types/fileSearch';
import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
// Create logger
const logger = createLogger('controllers:LocalFileCtr');
export default class LocalFileCtr extends ControllerModule {
static override readonly groupName = 'localSystem';
private get searchService() {
return this.app.getService(FileSearchService);
}
// ==================== File Operation ====================
@ipcClientEvent('openLocalFile')
@IpcMethod()
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
error?: string;
success: boolean;
@@ -59,7 +60,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('openLocalFolder')
@IpcMethod()
async handleOpenLocalFolder({ path: targetPath, isDirectory }: OpenLocalFolderParams): Promise<{
error?: string;
success: boolean;
@@ -77,7 +78,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('readLocalFiles')
@IpcMethod()
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
@@ -94,7 +95,7 @@ export default class LocalFileCtr extends ControllerModule {
return results;
}
@ipcClientEvent('readLocalFile')
@IpcMethod()
async readFile({
path: filePath,
loc,
@@ -192,7 +193,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('listLocalFiles')
@IpcMethod()
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
logger.debug('Listing directory contents:', { dirPath });
@@ -250,7 +251,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('moveLocalFiles')
@IpcMethod()
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
@@ -355,7 +356,7 @@ export default class LocalFileCtr extends ControllerModule {
return results;
}
@ipcClientEvent('renameLocalFile')
@IpcMethod()
async handleRenameFile({
path: currentPath,
newName,
@@ -440,7 +441,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('writeLocalFile')
@IpcMethod()
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
const logPrefix = `[Writing file ${filePath}]`;
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
@@ -485,7 +486,7 @@ export default class LocalFileCtr extends ControllerModule {
/**
* Handle IPC event for local file search
*/
@ipcClientEvent('searchLocalFiles')
@IpcMethod()
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
logger.debug('Received file search request:', {
directory: params.directory,
@@ -523,7 +524,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('grepContent')
@IpcMethod()
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
const {
pattern,
@@ -639,7 +640,7 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@ipcClientEvent('globLocalFiles')
@IpcMethod()
async handleGlobFiles({
path: searchPath = process.cwd(),
pattern,
@@ -680,7 +681,7 @@ export default class LocalFileCtr extends ControllerModule {
// ==================== File Editing ====================
@ipcClientEvent('editLocalFile')
@IpcMethod()
async handleEditFile({
file_path: filePath,
new_string,
+579
View File
@@ -0,0 +1,579 @@
import { exec } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import path from 'node:path';
import { promisify } from 'node:util';
import superjson from 'superjson';
import FileService from '@/services/fileSrv';
import { createLogger } from '@/utils/logger';
import { MCPClient } from '../libs/mcp/client';
import type { MCPClientParams, ToolCallContent, ToolCallResult } from '../libs/mcp/types';
import { ControllerModule, IpcMethod } from './index';
const execPromise = promisify(exec);
const logger = createLogger('controllers:McpCtr');
/**
* Desktop-only copy of `@lobechat/types`'s `CheckMcpInstallResult`.
*
* We intentionally keep it local to avoid pulling the web app's path-alias
* expectations (e.g. `@/config/*`) into the desktop `tsgo` typecheck.
*/
interface CheckMcpInstallResult {
allDependenciesMet?: boolean;
allOptions?: Array<{
allDependenciesMet?: boolean;
connection?: {
args?: string[];
command?: string;
installationMethod: string;
packageName?: string;
repositoryUrl?: string;
};
isRecommended?: boolean;
packageInstalled?: boolean;
systemDependencies?: Array<{
error?: string;
installed: boolean;
meetRequirement: boolean;
name: string;
version?: string;
}>;
}>;
configSchema?: any;
connection?: {
args?: string[];
command?: string;
type: 'stdio' | 'http';
url?: string;
};
error?: string;
isRecommended?: boolean;
needsConfig?: boolean;
packageInstalled?: boolean;
platform: string;
success: boolean;
systemDependencies?: Array<{
error?: string;
installed: boolean;
meetRequirement: boolean;
name: string;
version?: string;
}>;
}
interface CustomPluginMetadata {
avatar?: string;
description?: string;
name?: string;
}
interface GetStdioMcpServerManifestInput {
args?: string[];
command: string;
env?: Record<string, string>;
metadata?: CustomPluginMetadata;
name: string;
type?: 'stdio';
}
interface GetStreamableMcpServerManifestInput {
auth?: {
accessToken?: string;
token?: string;
type: 'none' | 'bearer' | 'oauth2';
};
headers?: Record<string, string>;
identifier: string;
metadata?: CustomPluginMetadata;
url: string;
}
interface CallToolInput {
args: any;
env: any;
params: GetStdioMcpServerManifestInput;
toolName: string;
}
interface SuperJSONSerialized<T = unknown> {
json: T;
meta?: any;
}
const isSuperJSONSerialized = (value: unknown): value is SuperJSONSerialized => {
if (!value || typeof value !== 'object') return false;
return 'json' in value;
};
const deserializePayload = <T>(payload: unknown): T => {
// Keep backward compatibility for older renderer builds that might not serialize yet
if (isSuperJSONSerialized(payload)) return superjson.deserialize(payload as any) as T;
return payload as T;
};
const serializePayload = <T>(payload: T): SuperJSONSerialized =>
superjson.serialize(payload) as any;
const safeParseToRecord = (value: unknown): Record<string, unknown> => {
if (value && typeof value === 'object' && !Array.isArray(value))
return value as Record<string, unknown>;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
return parsed as Record<string, unknown>;
} catch {
// ignore
}
}
return {};
};
const getFileExtensionFromMimeType = (mimeType: string, fallback: string) => {
const [, ext] = mimeType.split('/');
return ext || fallback;
};
const todayShard = () => new Date().toISOString().split('T')[0];
const toMarkdown = async (
blocks: ToolCallContent[] | null | undefined,
getHTTPURL: (key: string) => Promise<string>,
) => {
if (!blocks) return '';
const parts = await Promise.all(
blocks.map(async (item) => {
switch (item.type) {
case 'text': {
return item.text;
}
case 'image': {
const url = await getHTTPURL(item.data);
return `![](${url})`;
}
case 'audio': {
const url = await getHTTPURL(item.data);
return `<resource type="${item.type}" url="${url}" />`;
}
case 'resource': {
return `<resource type="${item.type}">${JSON.stringify(item.resource)}</resource>}`;
}
default: {
return '';
}
}
}),
);
return parts.filter(Boolean).join('\n\n');
};
/**
* MCP Controller (Desktop Main Process)
* Implements the same routes as `src/server/routers/desktop/mcp.ts`, but via IPC.
*/
export default class McpCtr extends ControllerModule {
static override readonly groupName = 'mcp';
private get fileService() {
return this.app.getService(FileService);
}
private async createClient(params: MCPClientParams) {
const client = new MCPClient(params);
await client.initialize();
return client;
}
private async processContentBlocks(blocks: ToolCallContent[]): Promise<ToolCallContent[]> {
return Promise.all(
blocks.map(async (block) => {
if (block.type !== 'image' && block.type !== 'audio') return block;
const ext = getFileExtensionFromMimeType(
block.mimeType,
block.type === 'image' ? 'png' : 'mp3',
);
const base64 = block.data;
const buffer = Buffer.from(base64, 'base64');
const hash = createHash('sha256').update(buffer).digest('hex');
const id = randomUUID();
const filePath = path.posix.join('mcp', `${block.type}s`, todayShard(), `${id}.${ext}`);
const { metadata } = await this.fileService.uploadFile({
content: base64,
filename: `${id}.${ext}`,
hash,
path: filePath,
type: block.mimeType,
});
return { ...block, data: metadata.path };
}),
);
}
@IpcMethod()
async getStdioMcpServerManifest(payload: SuperJSONSerialized<GetStdioMcpServerManifestInput>) {
const input = deserializePayload<GetStdioMcpServerManifestInput>(payload);
const params: MCPClientParams = {
args: input.args || [],
command: input.command,
env: input.env,
name: input.name,
type: 'stdio',
};
const client = await this.createClient(params);
try {
const manifest = await client.listManifests();
const identifier = input.name;
const tools = manifest.tools || [];
return serializePayload({
api: tools.map((item) => ({
description: item.description,
name: item.name,
parameters: item.inputSchema as any,
})),
identifier,
meta: {
avatar: input.metadata?.avatar || 'MCP_AVATAR',
description:
input.metadata?.description ||
`${identifier} MCP server has ` +
Object.entries(manifest)
.filter(([key]) => ['tools', 'prompts', 'resources'].includes(key))
.map(([key, item]) => `${(item as Array<any>)?.length} ${key}`)
.join(','),
title: input.metadata?.name || identifier,
},
...manifest,
mcpParams: params,
type: 'mcp' as any,
});
} finally {
await client.disconnect();
}
}
@IpcMethod()
async getStreamableMcpServerManifest(
payload: SuperJSONSerialized<GetStreamableMcpServerManifestInput>,
) {
const input = deserializePayload<GetStreamableMcpServerManifestInput>(payload);
const params: MCPClientParams = {
auth: input.auth,
headers: input.headers,
name: input.identifier,
type: 'http',
url: input.url,
};
const client = await this.createClient(params);
try {
const tools = await client.listTools();
const identifier = input.identifier;
return serializePayload({
api: tools.map((item) => ({
description: item.description,
name: item.name,
parameters: item.inputSchema as any,
})),
identifier,
mcpParams: params,
meta: {
avatar: input.metadata?.avatar || 'MCP_AVATAR',
description:
input.metadata?.description ||
`${identifier} MCP server has ${tools.length} tools, like "${tools[0]?.name}"`,
title: identifier,
},
type: 'mcp' as any,
});
} finally {
await client.disconnect();
}
}
@IpcMethod()
async callTool(payload: SuperJSONSerialized<CallToolInput>) {
const input = deserializePayload<CallToolInput>(payload);
const params: MCPClientParams = {
args: input.params.args || [],
command: input.params.command,
env: input.env,
name: input.params.name,
type: 'stdio',
};
const client = await this.createClient(params);
try {
const args = safeParseToRecord(input.args);
const raw = (await client.callTool(input.toolName, args)) as ToolCallResult;
const processed = raw.isError ? raw.content : await this.processContentBlocks(raw.content);
const content = await toMarkdown(processed, (key) => this.fileService.getFileHTTPURL(key));
return serializePayload({
content,
state: { ...raw, content: processed },
success: true,
});
} catch (error) {
logger.error('callTool failed:', error);
throw error;
} finally {
await client.disconnect();
}
}
// ---------- MCP Install Check (local system) ----------
private getInstallInstructions(installInstructions: any) {
if (!installInstructions) return undefined;
let current: string | undefined;
switch (process.platform) {
case 'darwin': {
current = installInstructions.macos;
break;
}
case 'linux': {
current = installInstructions.linux_debian || installInstructions.linux;
break;
}
case 'win32': {
current = installInstructions.windows;
break;
}
}
return { current, manual: installInstructions.manual };
}
private async checkSystemDependency(dependency: any) {
try {
const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
const { stdout, stderr } = await execPromise(checkCommand);
if (stderr && !stdout) {
return {
error: stderr,
installInstructions: this.getInstallInstructions(dependency.installInstructions),
installed: false,
meetRequirement: false,
name: dependency.name,
requiredVersion: dependency.requiredVersion,
};
}
const output = String(stdout || '').trim();
let version = output;
if (dependency.versionParsingRequired) {
const versionMatch = output.match(/[Vv]?(\d+(\.\d+)*)/);
if (versionMatch) version = versionMatch[0];
}
let meetRequirement = true;
if (dependency.requiredVersion) {
const currentVersion = String(version).replace(/^[Vv]/, '');
const currentNum = Number.parseFloat(currentVersion);
const requirementMatch = String(dependency.requiredVersion).match(/([<=>]+)?(\d+(\.\d+)*)/);
if (requirementMatch) {
const [, operator = '=', requiredVersion] = requirementMatch;
const requiredNum = Number.parseFloat(requiredVersion);
switch (operator) {
case '>=': {
meetRequirement = currentNum >= requiredNum;
break;
}
case '>': {
meetRequirement = currentNum > requiredNum;
break;
}
case '<=': {
meetRequirement = currentNum <= requiredNum;
break;
}
case '<': {
meetRequirement = currentNum < requiredNum;
break;
}
default: {
meetRequirement = currentNum === requiredNum;
break;
}
}
}
}
return {
installInstructions: this.getInstallInstructions(dependency.installInstructions),
installed: true,
meetRequirement,
name: dependency.name,
requiredVersion: dependency.requiredVersion,
version,
};
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
installInstructions: this.getInstallInstructions(dependency.installInstructions),
installed: false,
meetRequirement: false,
name: dependency.name,
requiredVersion: dependency.requiredVersion,
};
}
}
private async checkPackageInstalled(installationMethod: string, details: any) {
if (installationMethod === 'npm') {
const packageName = details?.packageName;
if (!packageName) return { installed: false };
try {
const { stdout } = await execPromise(`npm list -g ${packageName} --depth=0`);
if (!stdout.includes('(empty)') && stdout.includes(packageName)) return { installed: true };
} catch {
// ignore
}
try {
await execPromise(`npx -y ${packageName} --version`);
return { installed: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
installed: false,
};
}
}
if (installationMethod === 'python') {
const packageName = details?.packageName;
if (!packageName) return { installed: false };
const pythonCommand = details?.pythonCommand || 'python';
try {
const command = `${pythonCommand} -m pip list | grep -i "${packageName}"`;
const { stdout } = await execPromise(command);
if (stdout.trim() && stdout.toLowerCase().includes(String(packageName).toLowerCase())) {
return { installed: true };
}
} catch {
// ignore
}
try {
const importCommand = `${pythonCommand} -c "import ${String(packageName).replace('-', '_')}; print('Package installed')"`;
const { stdout } = await execPromise(importCommand);
if (stdout.includes('Package installed')) return { installed: true };
} catch {
// ignore
}
return { installed: false };
}
// manual or unknown
return { installed: false };
}
private async checkDeployOption(option: any) {
const systemDependenciesResults = [];
if (Array.isArray(option.systemDependencies) && option.systemDependencies.length > 0) {
for (const dep of option.systemDependencies) {
systemDependenciesResults.push(await this.checkSystemDependency(dep));
}
}
const packageResult = await this.checkPackageInstalled(
option.installationMethod,
option.installationDetails,
);
const packageInstalled = Boolean((packageResult as any).installed);
const allDependenciesMet = systemDependenciesResults.every((dep: any) => dep.meetRequirement);
const configSchema = option.connection?.configSchema;
const needsConfig = Boolean(
configSchema &&
((Array.isArray(configSchema.required) && configSchema.required.length > 0) ||
(configSchema.properties &&
Object.values(configSchema.properties).some((prop: any) => prop.required === true))),
);
const connection = option.connection?.url
? { ...option.connection, type: 'http' }
: { ...option.connection, type: 'stdio' };
return {
allDependenciesMet,
configSchema,
connection,
isRecommended: option.isRecommended,
needsConfig,
packageInstalled,
systemDependencies: systemDependenciesResults,
};
}
@IpcMethod()
async validMcpServerInstallable(
payload: SuperJSONSerialized<{
deploymentOptions: any[];
}>,
) {
const input = deserializePayload<{ deploymentOptions: any[] }>(payload);
try {
const options = input.deploymentOptions || [];
const results = [];
for (const option of options) {
results.push(await this.checkDeployOption(option));
}
const recommendedResult = results.find((r: any) => r.isRecommended && r.allDependenciesMet);
const firstInstallableResult = results.find((r: any) => r.allDependenciesMet);
const bestResult = recommendedResult || firstInstallableResult || results[0];
const checkResult: CheckMcpInstallResult = {
...(bestResult || {}),
allOptions: results as any,
platform: process.platform,
success: true,
};
if (bestResult?.needsConfig) {
checkResult.needsConfig = true;
checkResult.configSchema = bestResult.configSchema;
}
return serializePayload(checkResult);
} catch (error) {
return serializePayload({
error:
error instanceof Error
? error.message
: 'Unknown error when checking MCP plugin installation status',
platform: process.platform,
success: false,
});
}
}
}
+5 -4
View File
@@ -1,10 +1,11 @@
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
export default class MenuController extends ControllerModule {
static override readonly groupName = 'menu';
/**
* Refresh menu
*/
@ipcClientEvent('refreshAppMenu')
@IpcMethod()
refreshAppMenu() {
// Note: May need to decide whether to allow renderer process to refresh all menus based on specific circumstances
return this.app.menuManager.refreshMenus();
@@ -13,7 +14,7 @@ export default class MenuController extends ControllerModule {
/**
* Show context menu
*/
@ipcClientEvent('showContextMenu')
@IpcMethod()
showContextMenu(params: { data?: any; type: string }) {
return this.app.menuManager.showContextMenu(params.type, params.data);
}
@@ -21,7 +22,7 @@ export default class MenuController extends ControllerModule {
/**
* Set development menu visibility
*/
@ipcClientEvent('setDevMenuVisibility')
@IpcMethod()
setDevMenuVisibility(visible: boolean) {
// Call MenuManager method to rebuild application menu
return this.app.menuManager.rebuildAppMenu({ showDevItems: visible });
@@ -11,7 +11,7 @@ import {
ProxyDispatcherManager,
ProxyTestResult,
} from '../modules/networkProxy';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
// Create logger
const logger = createLogger('controllers:NetworkProxyCtr');
@@ -21,10 +21,11 @@ const logger = createLogger('controllers:NetworkProxyCtr');
* 处理桌面应用的网络代理相关功能
*/
export default class NetworkProxyCtr extends ControllerModule {
static override readonly groupName = 'networkProxy';
/**
* 获取代理设置
*/
@ipcClientEvent('getProxySettings')
@IpcMethod()
async getDesktopSettings(): Promise<NetworkProxySettings> {
try {
const settings = this.app.storeManager.get(
@@ -45,32 +46,30 @@ export default class NetworkProxyCtr extends ControllerModule {
/**
* 设置代理配置
*/
@ipcClientEvent('setProxySettings')
async setProxySettings(config: NetworkProxySettings): Promise<void> {
@IpcMethod()
async setProxySettings(config: Partial<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)) {
// 合并配置并验证
const newConfig = merge({}, currentConfig, config);
const validation = ProxyConfigValidator.validate(newConfig);
if (!validation.isValid) {
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (isEqual(currentConfig, newConfig)) {
logger.debug('Proxy settings unchanged, skipping update');
return;
}
// 合并配置
const newConfig = merge({}, currentConfig, config);
// 应用代理设置
await ProxyDispatcherManager.applyProxySettings(newConfig);
@@ -92,7 +91,7 @@ export default class NetworkProxyCtr extends ControllerModule {
/**
* 测试代理连接
*/
@ipcClientEvent('testProxyConnection')
@IpcMethod()
async testProxyConnection(url: string): Promise<{ message?: string; success: boolean }> {
try {
const result = await ProxyConnectionTester.testConnection(url);
@@ -112,7 +111,7 @@ export default class NetworkProxyCtr extends ControllerModule {
/**
* 测试指定代理配置
*/
@ipcClientEvent('testProxyConfig')
@IpcMethod()
async testProxyConfig({
config,
testUrl,
@@ -2,16 +2,66 @@ import {
DesktopNotificationResult,
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { Notification, app } from 'electron';
import { Notification, app, systemPreferences } from 'electron';
import { macOS, windows } from 'electron-is';
import { getIpcContext } from '@/utils/ipc';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:NotificationCtr');
export default class NotificationCtr extends ControllerModule {
static override readonly groupName = 'notification';
@IpcMethod()
async getNotificationPermissionStatus(): Promise<string> {
if (!Notification.isSupported()) return 'denied';
// Keep a stable status string for renderer-side UI mapping.
// Screen3 expects macOS to return 'authorized' when granted.
if (!macOS()) return 'authorized';
// Electron 38 no longer exposes `systemPreferences.getNotificationSettings()` in types,
// and some runtimes don't provide it at all. Use the renderer's Notification.permission
// as a reliable fallback.
const context = getIpcContext();
const sender = context?.sender;
if (!sender) return 'notDetermined';
const permission = await sender.executeJavaScript('Notification.permission', true);
return permission === 'granted' ? 'authorized' : 'denied';
}
@IpcMethod()
async requestNotificationPermission(): Promise<void> {
logger.debug('Requesting notification permission by sending a test notification');
if (!Notification.isSupported()) {
logger.warn('System does not support desktop notifications');
return;
}
// On macOS, ask permission via Web Notification API first when possible.
// This helps keep `Notification.permission` in sync for subsequent status checks.
if (macOS()) {
try {
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
await mainWindow.webContents.executeJavaScript('Notification.requestPermission()', true);
} catch (error) {
logger.debug(
'Notification.requestPermission() failed or is unavailable, continuing with test notification',
error,
);
}
}
const notification = new Notification({
body: 'LobeHub can now send you notifications.',
title: 'Notification Permission',
});
notification.show();
}
/**
* Set up desktop notifications after the application is ready
*/
@@ -51,7 +101,7 @@ export default class NotificationCtr extends ControllerModule {
/**
* Show system desktop notification (only when window is hidden)
*/
@ipcClientEvent('showDesktopNotification')
@IpcMethod()
async showDesktopNotification(
params: ShowDesktopNotificationParams,
): Promise<DesktopNotificationResult> {
@@ -126,7 +176,7 @@ export default class NotificationCtr extends ControllerModule {
/**
* Check if the main window is hidden
*/
@ipcClientEvent('isMainWindowHidden')
@IpcMethod()
isMainWindowHidden(): boolean {
try {
const mainWindow = this.app.browserManager.getMainWindow();
@@ -7,7 +7,7 @@ import { URL } from 'node:url';
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
/**
* Non-retryable OIDC error codes
@@ -39,32 +39,52 @@ const logger = createLogger('controllers:RemoteServerConfigCtr');
* Used to manage custom remote LobeChat server configuration
*/
export default class RemoteServerConfigCtr extends ControllerModule {
static override readonly groupName = 'remoteServer';
/**
* Key used to store encrypted tokens in electron-store.
*/
private readonly encryptedTokensKey = 'encryptedTokens';
/**
* Normalize legacy config that used local storageMode.
* Local mode has been removed; fall back to cloud.
*/
private normalizeConfig = (config: DataSyncConfig): DataSyncConfig => {
if (config.storageMode !== 'local') return config;
const nextConfig: DataSyncConfig = {
...config,
remoteServerUrl: config.remoteServerUrl || OFFICIAL_CLOUD_SERVER,
storageMode: 'cloud',
};
this.app.storeManager.set('dataSyncConfig', nextConfig);
return nextConfig;
};
/**
* Get remote server configuration
*/
@ipcClientEvent('getRemoteServerConfig')
@IpcMethod()
async getRemoteServerConfig() {
logger.debug('Getting remote server configuration');
const { storeManager } = this.app;
const config: DataSyncConfig = storeManager.get('dataSyncConfig');
const normalized = this.normalizeConfig(config);
logger.debug(
`Remote server config: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
`Remote server config: active=${normalized.active}, storageMode=${normalized.storageMode}, url=${normalized.remoteServerUrl}`,
);
return config;
return normalized;
}
/**
* Set remote server configuration
*/
@ipcClientEvent('setRemoteServerConfig')
@IpcMethod()
async setRemoteServerConfig(config: Partial<DataSyncConfig>) {
logger.info(
`Setting remote server storageMode: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
@@ -72,8 +92,9 @@ export default class RemoteServerConfigCtr extends ControllerModule {
const { storeManager } = this.app;
const prev: DataSyncConfig = storeManager.get('dataSyncConfig');
// Save configuration
storeManager.set('dataSyncConfig', { ...prev, ...config });
// Save configuration with legacy local storage fallback
const merged = this.normalizeConfig({ ...prev, ...config });
storeManager.set('dataSyncConfig', merged);
return true;
}
@@ -81,13 +102,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
/**
* Clear remote server configuration
*/
@ipcClientEvent('clearRemoteServerConfig')
@IpcMethod()
async clearRemoteServerConfig() {
logger.info('Clearing remote server configuration');
const { storeManager } = this.app;
// Clear instance configuration
storeManager.set('dataSyncConfig', { storageMode: 'local' });
storeManager.set('dataSyncConfig', { active: false, storageMode: 'cloud' });
// Clear tokens (if any)
await this.clearTokens();
@@ -467,7 +488,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
}
async getRemoteServerUrl(config?: DataSyncConfig) {
const dataConfig = config ? config : await this.getRemoteServerConfig();
const dataConfig = this.normalizeConfig(config ? config : await this.getRemoteServerConfig());
return dataConfig.storageMode === 'cloud' ? OFFICIAL_CLOUD_SERVER : dataConfig.remoteServerUrl;
}
@@ -1,8 +1,4 @@
import {
ProxyTRPCRequestParams,
ProxyTRPCRequestResult,
ProxyTRPCStreamRequestParams,
} from '@lobechat/electron-client-ipc';
import { ProxyTRPCStreamRequestParams } from '@lobechat/electron-client-ipc';
import { IpcMainEvent, WebContents, ipcMain } from 'electron';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
@@ -15,7 +11,7 @@ import { defaultProxySettings } from '@/const/store';
import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule } from './index';
// Create logger
const logger = createLogger('controllers:RemoteServerSyncCtr');
@@ -25,6 +21,7 @@ const logger = createLogger('controllers:RemoteServerSyncCtr');
* For handling data synchronization with remote servers via IPC.
*/
export default class RemoteServerSyncCtr extends ControllerModule {
static override readonly groupName = 'remoteServerSync';
/**
* Cached instance of RemoteServerConfigCtr
*/
@@ -173,129 +170,12 @@ export default class RemoteServerSyncCtr extends ControllerModule {
});
if (requestBody) {
clientReq.write(Buffer.from(requestBody));
clientReq.write(Buffer.from(requestBody as string));
}
clientReq.end();
}
/**
* Helper function to perform the actual request forwarding to the remote server.
* Accepts arguments from IPC and returns response details.
*/
private async forwardRequest(args: {
accessToken: string | null;
body?: string | ArrayBuffer;
headers: Record<string, string>;
method: string;
remoteServerUrl: string;
urlPath: string; // Pass the base URL
}): Promise<{
// Node headers type
body: Buffer;
headers: Record<string, string | string[] | undefined>;
status: number;
statusText: string; // Return body as Buffer
}> {
const {
urlPath,
method,
headers: originalHeaders,
body: requestBody,
accessToken,
remoteServerUrl,
} = args;
const pathname = new URL(urlPath, remoteServerUrl).pathname; // Extract pathname from URL
const logPrefix = `[ForwardRequest ${method} ${pathname}]`; // Add prefix for easier correlation
if (!accessToken) {
logger.error(`${logPrefix} No access token provided`); // Enhanced log
return {
body: Buffer.from(''),
headers: {},
status: 401,
statusText: 'Authentication required, missing token',
};
}
// 1. Determine target URL and prepare request options
const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
const { requestOptions, requester } = this.createRequester({
accessToken,
headers: originalHeaders,
method,
url: targetUrl,
});
// 2. Make the request and capture response
return new Promise((resolve) => {
const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
const chunks: Buffer[] = [];
clientRes.on('data', (chunk) => {
chunks.push(chunk);
});
clientRes.on('end', () => {
const responseBody = Buffer.concat(chunks);
resolve({
// These are IncomingHttpHeaders
body: responseBody,
headers: clientRes.headers,
status: clientRes.statusCode || 500,
statusText: clientRes.statusMessage || 'Unknown Status',
});
});
clientRes.on('error', (error) => {
// Error during response streaming
logger.error(
`${logPrefix} Error reading response stream from ${targetUrl.toString()}:`,
error,
); // Enhanced log
// Rejecting might be better, but we need to resolve the outer promise for proxyTRPCRequest
resolve({
body: Buffer.from(`Error reading response stream: ${error.message}`),
headers: {},
status: 502,
// Bad Gateway
statusText: 'Error reading response stream',
});
});
});
clientReq.on('error', (error) => {
logger.error(`${logPrefix} Error forwarding request to ${targetUrl.toString()}:`, error); // Enhanced log
// Reject or resolve with error status for the outer promise
resolve({
body: Buffer.from(`Error forwarding request: ${error.message}`),
headers: {},
status: 502,
// Bad Gateway
statusText: 'Error forwarding request',
});
});
// 3. Send request body if present
if (requestBody) {
if (typeof requestBody === 'string') {
clientReq.write(requestBody, 'utf8'); // Specify encoding for strings
} else if (requestBody instanceof ArrayBuffer) {
clientReq.write(Buffer.from(requestBody)); // Convert ArrayBuffer to Buffer
} else {
// Should not happen based on type, but handle defensively
logger.warn(`${logPrefix} Unsupported request body type received:`, typeof requestBody); // Enhanced log
}
}
clientReq.end(); // Finalize the request
});
}
private createRequester({
headers,
accessToken,
@@ -340,144 +220,4 @@ export default class RemoteServerSyncCtr extends ControllerModule {
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.
*/
@ipcClientEvent('proxyTRPCRequest')
public async proxyTRPCRequest(args: ProxyTRPCRequestParams): Promise<ProxyTRPCRequestResult> {
logger.debug('Received proxyTRPCRequest IPC call:', {
headers: args.headers,
method: args.method,
urlPath: args.urlPath, // Log headers too for context
});
const url = new URL(args.urlPath, 'http://a.b');
const logPrefix = `[ProxyTRPC ${args.method} ${url.pathname}]`; // Prefix for this specific request
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. Rejecting proxy request.`,
); // Enhanced log
return {
body: Buffer.from('Remote server sync not active or configured').buffer,
headers: {},
status: 503,
// Service Unavailable
statusText: 'Remote server sync not active or configured', // Return ArrayBuffer
};
}
const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
// Get initial token
let token = await this.remoteServerConfigCtr.getAccessToken();
logger.debug(
`${logPrefix} Initial token check: ${token ? 'Token exists' : 'No token found'}`,
); // Added log
logger.info(`${logPrefix} Attempting to forward request...`); // Added log
let response = await this.forwardRequest({ ...args, accessToken: token, remoteServerUrl });
// Handle 401: Refresh token and retry if necessary
if (response.status === 401) {
logger.info(`${logPrefix} Received 401 from forwarded request. Attempting token refresh.`); // Enhanced log
const refreshed = await this.refreshTokenIfNeeded(logPrefix); // Pass prefix for context
if (refreshed) {
const newToken = await this.remoteServerConfigCtr.getAccessToken();
if (newToken) {
logger.info(`${logPrefix} Token refreshed successfully, retrying the request.`); // Enhanced log
response = await this.forwardRequest({
...args,
accessToken: newToken,
remoteServerUrl,
});
} else {
logger.error(
`${logPrefix} Token refresh reported success, but failed to retrieve new token. Keeping original 401 response.`,
); // Enhanced log
// Keep the original 401 response
}
} else {
logger.error(`${logPrefix} Token refresh failed. Keeping original 401 response.`); // Enhanced log
// Keep the original 401 response
}
}
// Convert headers and body to format defined in IPC event
const responseHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(response.headers)) {
if (value !== undefined) {
responseHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
}
}
// Return the final response, ensuring body is serializable (string or ArrayBuffer)
const responseBody = response.body; // Buffer
// IMPORTANT: Check IPC limits. Large bodies might fail. Consider chunking if needed.
// Convert Buffer to ArrayBuffer for IPC
const finalBody = responseBody.buffer.slice(
responseBody.byteOffset,
responseBody.byteOffset + responseBody.byteLength,
);
logger.debug(`${logPrefix} Forwarding successful. Status: ${response.status}`); // Added log
return {
body: finalBody as ArrayBuffer,
headers: responseHeaders,
status: response.status,
statusText: response.statusText, // Return ArrayBuffer
};
} catch (error) {
logger.error(`${logPrefix} Unhandled error processing proxyTRPCRequest:`, error); // Enhanced log
// Ensure a serializable error response is returned
return {
body: Buffer.from(
`Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
).buffer,
headers: {},
status: 500,
statusText: 'Internal Server Error during proxy', // Return ArrayBuffer
};
}
}
/**
* Attempts to refresh the access token by calling the RemoteServerConfigCtr.
* @returns Whether token refresh was successful
*/
private async refreshTokenIfNeeded(callerLogPrefix: string = '[RefreshToken]'): Promise<boolean> {
// Added prefix parameter
const logPrefix = `${callerLogPrefix} [RefreshTrigger]`; // Updated prefix
logger.debug(`${logPrefix} Entered refreshTokenIfNeeded.`);
try {
logger.info(`${logPrefix} Triggering refreshAccessToken in RemoteServerConfigCtr.`);
const result = await this.remoteServerConfigCtr.refreshAccessToken();
if (result.success) {
logger.info(`${logPrefix} refreshAccessToken call completed successfully.`);
return true;
} else {
logger.error(`${logPrefix} refreshAccessToken call failed: ${result.error}`);
return false;
}
} catch (error) {
logger.error(`${logPrefix} Exception occurred while calling refreshAccessToken:`, error);
return false;
}
}
/**
* Clean up resources - No protocol handler to unregister anymore
*/
destroy() {
logger.info('Destroying RemoteServerSyncCtr');
// Nothing specific to clean up here regarding request handling now
}
}
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ShellCommandCtr');
@@ -24,10 +24,11 @@ interface ShellProcess {
}
export default class ShellCommandCtr extends ControllerModule {
static override readonly groupName = 'shellCommand';
// Shell process management
private shellProcesses = new Map<string, ShellProcess>();
@ipcClientEvent('runCommand')
@IpcMethod()
async handleRunCommand({
command,
description,
@@ -153,7 +154,7 @@ export default class ShellCommandCtr extends ControllerModule {
}
}
@ipcClientEvent('getCommandOutput')
@IpcMethod()
async handleGetCommandOutput({
filter,
shell_id,
@@ -212,7 +213,7 @@ export default class ShellCommandCtr extends ControllerModule {
};
}
@ipcClientEvent('killCommand')
@IpcMethod()
async handleKillCommand({ shell_id }: KillCommandParams): Promise<KillCommandResult> {
const logPrefix = `[killCommand: ${shell_id}]`;
logger.debug(`${logPrefix} Attempting to kill shell`);
@@ -1,12 +1,13 @@
import { ShortcutUpdateResult } from '@/core/ui/ShortcutManager';
import { ControllerModule, ipcClientEvent } from '.';
import { ControllerModule, IpcMethod } from '.';
export default class ShortcutController extends ControllerModule {
static override readonly groupName = 'shortcut';
/**
* Get all shortcut configurations
*/
@ipcClientEvent('getShortcutsConfig')
@IpcMethod()
getShortcutsConfig() {
return this.app.shortcutManager.getShortcutsConfig();
}
@@ -14,7 +15,7 @@ export default class ShortcutController extends ControllerModule {
/**
* Update a single shortcut configuration
*/
@ipcClientEvent('updateShortcutConfig')
@IpcMethod()
updateShortcutConfig({
id,
accelerator,
+50 -38
View File
@@ -1,18 +1,16 @@
import { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
import { app, nativeTheme, shell, systemPreferences } from 'electron';
import { macOS } from 'electron-is';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';
import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:SystemCtr');
export default class SystemController extends ControllerModule {
static override readonly groupName = 'system';
private systemThemeListenerInitialized = false;
/**
@@ -26,7 +24,7 @@ export default class SystemController extends ControllerModule {
* Handles the 'getDesktopAppState' IPC request.
* Gathers essential application and system information.
*/
@ipcClientEvent('getDesktopAppState')
@IpcMethod()
async getAppState(): Promise<ElectronAppState> {
const platform = process.platform;
const arch = process.arch;
@@ -53,16 +51,45 @@ export default class SystemController extends ControllerModule {
};
}
/**
* 检查可用性
*/
@ipcClientEvent('checkSystemAccessibility')
checkAccessibilityForMacOS() {
if (!macOS()) return;
@IpcMethod()
requestAccessibilityAccess() {
if (!macOS()) return true;
return systemPreferences.isTrustedAccessibilityClient(true);
}
@ipcClientEvent('openExternalLink')
@IpcMethod()
getAccessibilityStatus() {
if (!macOS()) return true;
return systemPreferences.isTrustedAccessibilityClient(false);
}
@IpcMethod()
async getMediaAccessStatus(mediaType: 'microphone' | 'screen'): Promise<string> {
if (!macOS()) return 'granted';
return systemPreferences.getMediaAccessStatus(mediaType);
}
@IpcMethod()
async requestMicrophoneAccess(): Promise<boolean> {
if (!macOS()) return true;
return systemPreferences.askForMediaAccess('microphone');
}
@IpcMethod()
async requestScreenAccess(): Promise<void> {
if (!macOS()) return;
shell.openExternal(
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
);
}
@IpcMethod()
openFullDiskAccessSettings() {
if (!macOS()) return;
shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles');
}
@IpcMethod()
openExternalLink(url: string) {
return shell.openExternal(url);
}
@@ -70,7 +97,7 @@ export default class SystemController extends ControllerModule {
/**
* 更新应用语言设置
*/
@ipcClientEvent('updateLocale')
@IpcMethod()
async updateLocale(locale: string) {
// 保存语言设置
this.app.storeManager.set('locale', locale);
@@ -82,41 +109,26 @@ export default class SystemController extends ControllerModule {
return { success: true };
}
@ipcClientEvent('updateThemeMode')
@IpcMethod()
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();
// Set app theme mode to the system theme mode
this.setSystemThemeMode(themeMode);
}
@ipcServerEvent('getDatabasePath')
async getDatabasePath() {
return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
@IpcMethod()
async getSystemThemeMode() {
return nativeTheme.themeSource;
}
@ipcServerEvent('getDatabaseSchemaHash')
async getDatabaseSchemaHash() {
try {
return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
} catch {
return undefined;
}
}
@ipcServerEvent('getUserDataPath')
async getUserDataPath() {
return userDataDir;
}
@ipcServerEvent('setDatabaseSchemaHash')
async setDatabaseSchemaHash(hash: string) {
writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8');
}
private get DB_SCHEMA_HASH_PATH() {
return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME);
@IpcMethod()
async setSystemThemeMode(themeMode: ThemeMode) {
nativeTheme.themeSource = themeMode === 'auto' ? 'system' : themeMode;
}
/**
@@ -6,12 +6,13 @@ import {
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
// Create logger
const logger = createLogger('controllers:TrayMenuCtr');
export default class TrayMenuCtr extends ControllerModule {
static override readonly groupName = 'tray';
async toggleMainWindow() {
logger.debug('Toggle main window visibility via shortcut');
const mainWindow = this.app.browserManager.getMainWindow();
@@ -23,7 +24,7 @@ export default class TrayMenuCtr extends ControllerModule {
* @param options Balloon options
* @returns Operation result
*/
@ipcClientEvent('showTrayNotification')
@IpcMethod()
async showNotification(options: ShowTrayNotificationParams) {
logger.debug('Show tray balloon notification');
@@ -52,7 +53,7 @@ export default class TrayMenuCtr extends ControllerModule {
* @param options Icon options
* @returns Operation result
*/
@ipcClientEvent('updateTrayIcon')
@IpcMethod()
async updateTrayIcon(options: UpdateTrayIconParams) {
logger.debug('Update tray icon');
@@ -84,7 +85,7 @@ export default class TrayMenuCtr extends ControllerModule {
* @param options Tooltip text options
* @returns Operation result
*/
@ipcClientEvent('updateTrayTooltip')
@IpcMethod()
async updateTrayTooltip(options: UpdateTrayTooltipParams) {
logger.debug('Update tray tooltip text');
@@ -1,14 +1,15 @@
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:UpdaterCtr');
export default class UpdaterCtr extends ControllerModule {
static override readonly groupName = 'autoUpdate';
/**
* Check for updates
*/
@ipcClientEvent('checkUpdate')
@IpcMethod()
async checkForUpdates() {
logger.info('Check for updates requested');
await this.app.updaterManager.checkForUpdates();
@@ -17,7 +18,7 @@ export default class UpdaterCtr extends ControllerModule {
/**
* Download update
*/
@ipcClientEvent('downloadUpdate')
@IpcMethod()
async downloadUpdate() {
logger.info('Download update requested');
await this.app.updaterManager.downloadUpdate();
@@ -26,7 +27,7 @@ export default class UpdaterCtr extends ControllerModule {
/**
* Quit application and install update
*/
@ipcClientEvent('installNow')
@IpcMethod()
quitAndInstallUpdate() {
logger.info('Quit and install update requested');
this.app.updaterManager.installNow();
@@ -35,7 +36,7 @@ export default class UpdaterCtr extends ControllerModule {
/**
* Install update on next startup
*/
@ipcClientEvent('installLater')
@IpcMethod()
installLater() {
logger.info('Install later requested');
this.app.updaterManager.installLater();
@@ -1,39 +1,17 @@
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';
import { ControllerModule, IpcMethod } from './index';
export default class UploadFileCtr extends ControllerModule {
static override readonly groupName = 'upload';
private get fileService() {
return this.app.getService(FileService);
}
@ipcClientEvent('createFile')
@IpcMethod()
async uploadFile(params: UploadFileParams) {
return this.fileService.uploadFile(params);
}
// ======== server event
@ipcServerEvent('getStaticFilePath')
async getFileUrlById(id: string) {
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,33 @@
import { CreateFileParams } from '@lobechat/electron-server-ipc';
import FileService from '@/services/fileSrv';
import { ControllerModule, IpcServerMethod } from './index';
export default class UploadFileServerCtr extends ControllerModule {
static override readonly groupName = 'upload';
private get fileService() {
return this.app.getService(FileService);
}
@IpcServerMethod()
async getFileUrlById(id: string) {
return this.fileService.getFilePath(id);
}
@IpcServerMethod()
async getFileHTTPURL(path: string) {
return this.fileService.getFileHTTPURL(path);
}
@IpcServerMethod()
async deleteFiles(paths: string[]) {
return this.fileService.deleteFiles(paths);
}
@IpcServerMethod()
async createFile(params: CreateFileParams) {
return this.fileService.uploadFile(params);
}
}
@@ -18,11 +18,18 @@ vi.mock('@/utils/logger', () => ({
}),
}));
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
// Mock electron
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: vi.fn(() => []),
},
ipcMain: {
handle: ipcMainHandleMock,
},
shell: {
openExternal: vi.fn().mockResolvedValue(undefined),
},
@@ -99,6 +106,7 @@ describe('AuthCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
randomBytesCounter = 0; // Reset counter for each test
// Reset shell.openExternal to default successful behavior
@@ -123,7 +131,7 @@ describe('AuthCtr', () => {
afterEach(() => {
// Clean up authCtr intervals (using real timers, not fake timers)
authCtr.cleanup();
authCtr?.cleanup?.();
// Clean up any fake timers if used
vi.clearAllTimers();
});
@@ -3,23 +3,39 @@ import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
import type { App } from '@/core/App';
import type { IpcClientEventSender } from '@/types/ipcClientEvent';
import type { IpcContext } from '@/utils/ipc';
import { runWithIpcContext } from '@/utils/ipc';
import BrowserWindowsCtr from '../BrowserWindowsCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// 模拟 App 及其依赖项
const mockToggleVisible = vi.fn();
const mockLoadUrl = vi.fn();
const mockShow = vi.fn();
const mockBroadcast = vi.fn();
const mockRedirectToPage = vi.fn();
const mockCloseWindow = vi.fn();
const mockMinimizeWindow = vi.fn();
const mockMaximizeWindow = vi.fn();
const mockRetrieveByIdentifier = vi.fn();
const testSenderIdentifierString: string = 'test-window-event-id';
const mockGetIdentifierByWebContents = vi.fn(() => testSenderIdentifierString);
const mockGetMainWindow = vi.fn(() => ({
toggleVisible: mockToggleVisible,
loadUrl: mockLoadUrl,
show: mockShow,
broadcast: mockBroadcast,
}));
const mockShowOther = vi.fn();
@@ -32,6 +48,7 @@ const { findMatchingRoute } = await import('~common/routes');
const mockApp = {
browserManager: {
getIdentifierByWebContents: mockGetIdentifierByWebContents,
getMainWindow: mockGetMainWindow,
redirectToPage: mockRedirectToPage,
closeWindow: mockCloseWindow,
@@ -53,6 +70,7 @@ describe('BrowserWindowsCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
browserWindowsCtr = new BrowserWindowsCtr(mockApp);
});
@@ -69,41 +87,49 @@ describe('BrowserWindowsCtr', () => {
const tab = 'appearance';
const result = await browserWindowsCtr.openSettingsWindow(tab);
expect(mockGetMainWindow).toHaveBeenCalled();
expect(mockLoadUrl).toHaveBeenCalledWith('/settings?active=appearance');
expect(mockShow).toHaveBeenCalled();
expect(mockBroadcast).toHaveBeenCalledWith('navigate', {
path: '/settings?active=appearance',
});
expect(result).toEqual({ success: true });
});
it('should return error if navigation fails', async () => {
const errorMessage = 'Failed to navigate';
mockLoadUrl.mockRejectedValueOnce(new Error(errorMessage));
mockBroadcast.mockImplementationOnce(() => {
throw new Error(errorMessage);
});
const result = await browserWindowsCtr.openSettingsWindow('display');
expect(result).toEqual({ error: errorMessage, success: false });
});
});
const testSenderIdentifierString: string = 'test-window-event-id';
const sender: IpcClientEventSender = {
identifier: testSenderIdentifierString,
};
describe('closeWindow', () => {
it('should close the window with the given sender identifier', () => {
browserWindowsCtr.closeWindow(undefined, sender);
const sender = {} as any;
const context = { sender, event: { sender } as any } as IpcContext;
runWithIpcContext(context, () => browserWindowsCtr.closeWindow());
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
expect(mockCloseWindow).toHaveBeenCalledWith(testSenderIdentifierString);
});
});
describe('minimizeWindow', () => {
it('should minimize the window with the given sender identifier', () => {
browserWindowsCtr.minimizeWindow(undefined, sender);
const sender = {} as any;
const context = { sender, event: { sender } as any } as IpcContext;
runWithIpcContext(context, () => browserWindowsCtr.minimizeWindow());
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
expect(mockMinimizeWindow).toHaveBeenCalledWith(testSenderIdentifierString);
});
});
describe('maximizeWindow', () => {
it('should maximize the window with the given sender identifier', () => {
browserWindowsCtr.maximizeWindow(undefined, sender);
const sender = {} as any;
const context = { sender, event: { sender } as any } as IpcContext;
runWithIpcContext(context, () => browserWindowsCtr.maximizeWindow());
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
expect(mockMaximizeWindow).toHaveBeenCalledWith(testSenderIdentifierString);
});
});
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
import DevtoolsCtr from '../DevtoolsCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// 模拟 App 及其依赖项
const mockShow = vi.fn();
const mockRetrieveByIdentifier = vi.fn(() => ({
@@ -24,10 +34,9 @@ describe('DevtoolsCtr', () => {
beforeEach(() => {
vi.clearAllMocks(); // 只清除 vi.fn() 创建的模拟函数的记录,不影响 IoCContainer 状态
ipcMainHandleMock.mockClear();
// 实例化 DevtoolsCtr。
// 它将继承自真实的 ControllerModule。
// 其 @ipcClientEvent 装饰器会执行并与真实的 IoCContainer 交互。
// 实例化 DevtoolsCtr。其 @IpcMethod 装饰器会执行并与真实的 IoCContainer 交互。
devtoolsCtr = new DevtoolsCtr(mockApp);
});
@@ -4,6 +4,10 @@ import type { App } from '@/core/App';
import LocalFileCtr from '../LocalFileCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -22,6 +26,9 @@ vi.mock('@lobechat/file-loaders', () => ({
// Mock electron
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
shell: {
openPath: vi.fn(),
},
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
import MenuController from '../MenuCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// 模拟 App 及其依赖项
const mockRefreshMenus = vi.fn();
const mockShowContextMenu = vi.fn();
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
import NetworkProxyCtr from '../NetworkProxyCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -54,6 +58,7 @@ describe('NetworkProxyCtr', () => {
beforeEach(async () => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
// 动态导入 undici Mock
mockUndici = await import('undici');
@@ -418,3 +423,8 @@ describe('NetworkProxyCtr', () => {
});
});
});
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
import NotificationCtr from '../NotificationCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -25,6 +29,9 @@ vi.mock('electron', () => {
MockNotification.isSupported = vi.fn(() => true);
return {
ipcMain: {
handle: ipcMainHandleMock,
},
Notification: MockNotification,
app: {
setAppUserModelId: vi.fn(),
@@ -65,6 +72,7 @@ describe('NotificationCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
vi.useFakeTimers();
controller = new NotificationCtr(mockApp);
});
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -17,6 +21,9 @@ vi.mock('@/utils/logger', () => ({
// Mock electron
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
safeStorage: {
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
encryptString: vi.fn((str: string) => Buffer.from(str)),
@@ -45,6 +52,7 @@ describe('RemoteServerConfigCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
mockStoreManager.get.mockReturnValue({
active: false,
storageMode: 'local',
@@ -97,7 +105,10 @@ describe('RemoteServerConfigCtr', () => {
const result = await controller.clearRemoteServerConfig();
expect(result).toBe(true);
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', { storageMode: 'local' });
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', {
active: false,
storageMode: 'cloud',
});
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
});
});
@@ -1,372 +0,0 @@
import { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import RemoteServerSyncCtr from '../RemoteServerSyncCtr';
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
// Mock electron
vi.mock('electron', () => ({
app: {
getAppPath: vi.fn(() => '/mock/app/path'),
getPath: vi.fn(() => '/mock/user/data'),
},
ipcMain: {
on: vi.fn(),
},
}));
// Mock electron-is
vi.mock('electron-is', () => ({
dev: vi.fn(() => false),
linux: vi.fn(() => false),
macOS: vi.fn(() => false),
windows: vi.fn(() => false),
}));
// Mock http and https modules
vi.mock('node:http', () => ({
default: {
request: vi.fn(),
},
}));
vi.mock('node:https', () => ({
default: {
request: vi.fn(),
},
}));
// Mock proxy agents
vi.mock('http-proxy-agent', () => ({
HttpProxyAgent: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('https-proxy-agent', () => ({
HttpsProxyAgent: vi.fn().mockImplementation(() => ({})),
}));
// Mock RemoteServerConfigCtr
const mockRemoteServerConfigCtr = {
getRemoteServerConfig: vi.fn(),
getRemoteServerUrl: vi.fn(),
getAccessToken: vi.fn(),
refreshAccessToken: vi.fn(),
};
const mockStoreManager = {
get: vi.fn().mockReturnValue({
enableProxy: false,
proxyServer: '',
proxyPort: '',
proxyType: 'http',
}),
};
const mockApp = {
getController: vi.fn(() => mockRemoteServerConfigCtr),
storeManager: mockStoreManager,
} as unknown as App;
describe('RemoteServerSyncCtr', () => {
let controller: RemoteServerSyncCtr;
beforeEach(() => {
vi.clearAllMocks();
controller = new RemoteServerSyncCtr(mockApp);
});
describe('proxyTRPCRequest', () => {
const baseParams: ProxyTRPCRequestParams = {
urlPath: '/trpc/test.query',
method: 'GET',
headers: { 'content-type': 'application/json' },
};
it('should return 503 when remote server sync is not active', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: false,
storageMode: 'cloud',
});
const result = await controller.proxyTRPCRequest(baseParams);
expect(result.status).toBe(503);
expect(result.statusText).toBe('Remote server sync not active or configured');
});
it('should return 503 when selfHost mode without remoteServerUrl', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'selfHost',
remoteServerUrl: '',
});
const result = await controller.proxyTRPCRequest(baseParams);
expect(result.status).toBe(503);
expect(result.statusText).toBe('Remote server sync not active or configured');
});
it('should return 401 when no access token is available', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'cloud',
});
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue(null);
// Mock https.request to simulate the forwardRequest behavior
const https = await import('node:https');
const mockRequest = vi.fn().mockImplementation((options, callback) => {
// Simulate response
const mockResponse = {
statusCode: 401,
statusMessage: 'Authentication required, missing token',
headers: {},
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from(''));
}
if (event === 'end') {
handler();
}
}),
};
callback(mockResponse);
return {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
});
vi.mocked(https.default.request).mockImplementation(mockRequest);
const result = await controller.proxyTRPCRequest(baseParams);
expect(result.status).toBe(401);
});
it('should forward request successfully when configured properly', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'cloud',
});
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
const https = await import('node:https');
const mockRequest = vi.fn().mockImplementation((options, callback) => {
const mockResponse = {
statusCode: 200,
statusMessage: 'OK',
headers: { 'content-type': 'application/json' },
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from('{"success":true}'));
}
if (event === 'end') {
handler();
}
}),
};
callback(mockResponse);
return {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
});
vi.mocked(https.default.request).mockImplementation(mockRequest);
const result = await controller.proxyTRPCRequest(baseParams);
expect(result.status).toBe(200);
expect(result.statusText).toBe('OK');
});
it('should retry request after token refresh on 401', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'cloud',
});
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
mockRemoteServerConfigCtr.getAccessToken
.mockResolvedValueOnce('expired-token')
.mockResolvedValueOnce('new-valid-token');
mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({ success: true });
const https = await import('node:https');
let callCount = 0;
const mockRequest = vi.fn().mockImplementation((options, callback) => {
callCount++;
const mockResponse = {
statusCode: callCount === 1 ? 401 : 200,
statusMessage: callCount === 1 ? 'Unauthorized' : 'OK',
headers: { 'content-type': 'application/json' },
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from(callCount === 1 ? '' : '{"success":true}'));
}
if (event === 'end') {
handler();
}
}),
};
callback(mockResponse);
return {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
});
vi.mocked(https.default.request).mockImplementation(mockRequest);
const result = await controller.proxyTRPCRequest(baseParams);
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
expect(result.status).toBe(200);
});
it('should keep 401 response when token refresh fails', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'cloud',
});
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('expired-token');
mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({
success: false,
error: 'Refresh failed',
});
const https = await import('node:https');
const mockRequest = vi.fn().mockImplementation((options, callback) => {
const mockResponse = {
statusCode: 401,
statusMessage: 'Unauthorized',
headers: {},
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from(''));
}
if (event === 'end') {
handler();
}
}),
};
callback(mockResponse);
return {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
});
vi.mocked(https.default.request).mockImplementation(mockRequest);
const result = await controller.proxyTRPCRequest(baseParams);
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
expect(result.status).toBe(401);
});
it('should handle request error gracefully', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'cloud',
});
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
const https = await import('node:https');
const mockRequest = vi.fn().mockImplementation((options, callback) => {
return {
on: vi.fn((event, handler) => {
if (event === 'error') {
handler(new Error('Network error'));
}
}),
write: vi.fn(),
end: vi.fn(),
};
});
vi.mocked(https.default.request).mockImplementation(mockRequest);
const result = await controller.proxyTRPCRequest(baseParams);
expect(result.status).toBe(502);
expect(result.statusText).toBe('Error forwarding request');
});
it('should include request body when provided', async () => {
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
active: true,
storageMode: 'cloud',
});
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
const https = await import('node:https');
const mockWrite = vi.fn();
const mockRequest = vi.fn().mockImplementation((options, callback) => {
const mockResponse = {
statusCode: 200,
statusMessage: 'OK',
headers: {},
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from('{"success":true}'));
}
if (event === 'end') {
handler();
}
}),
};
callback(mockResponse);
return {
on: vi.fn(),
write: mockWrite,
end: vi.fn(),
};
});
vi.mocked(https.default.request).mockImplementation(mockRequest);
const paramsWithBody: ProxyTRPCRequestParams = {
...baseParams,
method: 'POST',
body: '{"data":"test"}',
};
await controller.proxyTRPCRequest(paramsWithBody);
expect(mockWrite).toHaveBeenCalledWith('{"data":"test"}', 'utf8');
});
});
describe('afterAppReady', () => {
it('should register stream:start IPC handler', async () => {
const { ipcMain } = await import('electron');
controller.afterAppReady();
expect(ipcMain.on).toHaveBeenCalledWith('stream:start', expect.any(Function));
});
});
describe('destroy', () => {
it('should clean up resources', () => {
// destroy method doesn't throw
expect(() => controller.destroy()).not.toThrow();
});
});
});
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
import ShellCommandCtr from '../ShellCommandCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
import ShortcutController from '../ShortcutCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// 模拟 App 及其依赖项
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
toggleMainWindow: 'CommandOrControl+Shift+L',
@@ -26,6 +36,7 @@ describe('ShortcutController', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
shortcutController = new ShortcutController(mockApp);
});
@@ -2,9 +2,38 @@ import { ThemeMode } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import type { IpcContext } from '@/utils/ipc';
import { IpcHandler } from '@/utils/ipc/base';
import SystemController from '../SystemCtr';
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
const handle = vi.fn((channel: string, handler: any) => {
handlers.set(channel, handler);
});
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
});
const invokeIpc = async <T = any>(
channel: string,
payload?: any,
context?: Partial<IpcContext>,
): Promise<T> => {
const handler = ipcHandlers.get(channel);
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
const fakeEvent = {
sender: context?.sender ?? ({ id: 'test' } as any),
};
if (payload === undefined) {
return handler(fakeEvent);
}
return handler(fakeEvent, payload);
};
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -21,6 +50,9 @@ vi.mock('electron', () => ({
getLocale: vi.fn(() => 'en-US'),
getPath: vi.fn((name: string) => `/mock/path/${name}`),
},
ipcMain: {
handle: ipcMainHandleMock,
},
nativeTheme: {
on: vi.fn(),
shouldUseDarkColors: false,
@@ -38,19 +70,6 @@ vi.mock('electron-is', () => ({
macOS: vi.fn(() => true),
}));
// Mock node:fs
vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
// Mock @/const/dir
vi.mock('@/const/dir', () => ({
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
LOCAL_DATABASE_DIR: 'database',
userDataDir: '/mock/user/data',
}));
// Mock browserManager
const mockBrowserManager = {
broadcastToAllWindows: vi.fn(),
@@ -80,12 +99,15 @@ describe('SystemController', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
ipcMainHandleMock.mockClear();
(IpcHandler.getInstance() as any).registeredChannels?.clear();
controller = new SystemController(mockApp);
});
describe('getAppState', () => {
it('should return app state with system info', async () => {
const result = await controller.getAppState();
const result = await invokeIpc('system.getAppState');
expect(result).toMatchObject({
arch: expect.any(String),
@@ -108,7 +130,7 @@ describe('SystemController', () => {
const { nativeTheme } = await import('electron');
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
const result = await controller.getAppState();
const result = await invokeIpc('system.getAppState');
expect(result.systemAppearance).toBe('dark');
@@ -117,22 +139,24 @@ describe('SystemController', () => {
});
});
describe('checkAccessibilityForMacOS', () => {
it('should check accessibility on macOS', async () => {
describe('accessibility', () => {
it('should request accessibility access on macOS', async () => {
const { systemPreferences } = await import('electron');
controller.checkAccessibilityForMacOS();
await invokeIpc('system.requestAccessibilityAccess');
expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
});
it('should return undefined on non-macOS', async () => {
it('should return true on non-macOS when requesting accessibility access', async () => {
const { macOS } = await import('electron-is');
const { systemPreferences } = await import('electron');
vi.mocked(macOS).mockReturnValue(false);
const result = controller.checkAccessibilityForMacOS();
const result = await invokeIpc('system.requestAccessibilityAccess');
expect(result).toBeUndefined();
expect(result).toBe(true);
expect(systemPreferences.isTrustedAccessibilityClient).not.toHaveBeenCalled();
// Reset
vi.mocked(macOS).mockReturnValue(true);
@@ -143,7 +167,7 @@ describe('SystemController', () => {
it('should open external link', async () => {
const { shell } = await import('electron');
await controller.openExternalLink('https://example.com');
await invokeIpc('system.openExternalLink', 'https://example.com');
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com');
});
@@ -151,7 +175,7 @@ describe('SystemController', () => {
describe('updateLocale', () => {
it('should update locale and broadcast change', async () => {
const result = await controller.updateLocale('zh-CN');
const result = await invokeIpc('system.updateLocale', 'zh-CN');
expect(mockStoreManager.set).toHaveBeenCalledWith('locale', 'zh-CN');
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('zh-CN');
@@ -162,7 +186,7 @@ describe('SystemController', () => {
});
it('should use system locale when set to auto', async () => {
await controller.updateLocale('auto');
await invokeIpc('system.updateLocale', 'auto');
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('en-US');
});
@@ -172,7 +196,7 @@ describe('SystemController', () => {
it('should update theme mode and broadcast change', async () => {
const themeMode: ThemeMode = 'dark';
await controller.updateThemeModeHandler(themeMode);
await invokeIpc('system.updateThemeModeHandler', themeMode);
expect(mockStoreManager.set).toHaveBeenCalledWith('themeMode', 'dark');
expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('themeChanged', {
@@ -182,58 +206,6 @@ describe('SystemController', () => {
});
});
describe('getDatabasePath', () => {
it('should return database path', async () => {
const result = await controller.getDatabasePath();
expect(result).toBe('/mock/storage/database');
});
});
describe('getDatabaseSchemaHash', () => {
it('should return schema hash when file exists', async () => {
const { readFileSync } = await import('node:fs');
vi.mocked(readFileSync).mockReturnValue('abc123');
const result = await controller.getDatabaseSchemaHash();
expect(result).toBe('abc123');
});
it('should return undefined when file does not exist', async () => {
const { readFileSync } = await import('node:fs');
vi.mocked(readFileSync).mockImplementation(() => {
throw new Error('File not found');
});
const result = await controller.getDatabaseSchemaHash();
expect(result).toBeUndefined();
});
});
describe('getUserDataPath', () => {
it('should return user data path', async () => {
const result = await controller.getUserDataPath();
expect(result).toBe('/mock/user/data');
});
});
describe('setDatabaseSchemaHash', () => {
it('should write schema hash to file', async () => {
const { writeFileSync } = await import('node:fs');
await controller.setDatabaseSchemaHash('newhash123');
expect(writeFileSync).toHaveBeenCalledWith(
'/mock/storage/db-schema-hash.txt',
'newhash123',
'utf8',
);
});
});
describe('afterAppReady', () => {
it('should initialize system theme listener', async () => {
const { nativeTheme } = await import('electron');
@@ -1,12 +1,24 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
import {
ShowTrayNotificationParams,
UpdateTrayIconParams,
UpdateTrayTooltipParams
UpdateTrayTooltipParams,
} from '@lobechat/electron-client-ipc';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import TrayMenuCtr from '../TrayMenuCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -15,8 +27,6 @@ vi.mock('@/utils/logger', () => ({
}),
}));
import TrayMenuCtr from '../TrayMenuCtr';
// 保存原始平台,确保测试结束后能恢复
const originalPlatform = process.platform;
@@ -45,6 +55,7 @@ describe('TrayMenuCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
// 为每个测试重置 mockedTray
mockGetMainTray.mockReset();
trayMenuCtr = new TrayMenuCtr(mockApp);
@@ -69,7 +80,7 @@ describe('TrayMenuCtr', () => {
it('should display balloon notification on Windows platform', async () => {
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
displayBalloon: mockDisplayBalloon,
};
@@ -125,9 +136,9 @@ describe('TrayMenuCtr', () => {
expect(mockGetMainTray).toHaveBeenCalled();
expect(mockDisplayBalloon).not.toHaveBeenCalled();
expect(result).toEqual({
expect(result).toEqual({
error: 'Tray notifications are only supported on Windows platform',
success: false
success: false,
});
});
});
@@ -136,7 +147,7 @@ describe('TrayMenuCtr', () => {
it('should update tray icon on Windows platform', async () => {
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
updateIcon: mockUpdateIcon,
};
@@ -156,7 +167,7 @@ describe('TrayMenuCtr', () => {
it('should handle errors when updating icon', async () => {
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const error = new Error('Failed to update icon');
const mockedTray = {
updateIcon: vi.fn().mockImplementation(() => {
@@ -198,7 +209,7 @@ describe('TrayMenuCtr', () => {
it('should update tray tooltip on Windows platform', async () => {
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
updateTooltip: mockUpdateTooltip,
};
@@ -234,7 +245,7 @@ describe('TrayMenuCtr', () => {
it('should return error when tooltip is not provided', async () => {
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
updateTooltip: mockUpdateTooltip,
};
@@ -253,4 +264,4 @@ describe('TrayMenuCtr', () => {
});
});
});
});
});
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import UpdaterCtr from '../UpdaterCtr';
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -9,7 +11,15 @@ vi.mock('@/utils/logger', () => ({
}),
}));
import UpdaterCtr from '../UpdaterCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// 模拟 App 及其依赖项
const mockCheckForUpdates = vi.fn();
@@ -31,6 +41,7 @@ describe('UpdaterCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
updaterCtr = new UpdaterCtr(mockApp);
});
@@ -79,4 +90,4 @@ describe('UpdaterCtr', () => {
await expect(updaterCtr.downloadUpdate()).rejects.toThrow(error);
});
});
});
});
@@ -1,9 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import { IpcHandler } from '@/utils/ipc/base';
import UploadFileCtr from '../UploadFileCtr';
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
const handle = vi.fn((channel: string, handler: any) => {
handlers.set(channel, handler);
});
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
});
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
const handler = ipcHandlers.get(channel);
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
const fakeEvent = { sender: { id: 'test' } as any };
if (payload === undefined) return handler(fakeEvent);
return handler(fakeEvent, payload);
};
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
// Mock FileService module to prevent electron dependency issues
vi.mock('@/services/fileSrv', () => ({
default: class MockFileService {},
@@ -12,9 +36,6 @@ vi.mock('@/services/fileSrv', () => ({
// Mock FileService instance methods
const mockFileService = {
uploadFile: vi.fn(),
getFilePath: vi.fn(),
getFileHTTPURL: vi.fn(),
deleteFiles: vi.fn(),
};
const mockApp = {
@@ -26,6 +47,9 @@ describe('UploadFileCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
ipcMainHandleMock.mockClear();
(IpcHandler.getInstance() as any).registeredChannels?.clear();
controller = new UploadFileCtr(mockApp);
});
@@ -41,7 +65,7 @@ describe('UploadFileCtr', () => {
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
mockFileService.uploadFile.mockResolvedValue(expectedResult);
const result = await controller.uploadFile(params);
const result = await invokeIpc('upload.uploadFile', params);
expect(result).toEqual(expectedResult);
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
@@ -58,110 +82,7 @@ describe('UploadFileCtr', () => {
const error = new Error('Upload failed');
mockFileService.uploadFile.mockRejectedValue(error);
await expect(controller.uploadFile(params)).rejects.toThrow('Upload failed');
});
});
describe('getFileUrlById', () => {
it('should get file path by id successfully', async () => {
const fileId = 'file-id-123';
const expectedPath = '/files/abc123.txt';
mockFileService.getFilePath.mockResolvedValue(expectedPath);
const result = await controller.getFileUrlById(fileId);
expect(result).toBe(expectedPath);
expect(mockFileService.getFilePath).toHaveBeenCalledWith(fileId);
});
it('should handle get file path error', async () => {
const fileId = 'non-existent-id';
const error = new Error('File not found');
mockFileService.getFilePath.mockRejectedValue(error);
await expect(controller.getFileUrlById(fileId)).rejects.toThrow('File not found');
});
});
describe('getFileHTTPURL', () => {
it('should get file HTTP URL successfully', async () => {
const filePath = '/files/abc123.txt';
const expectedUrl = 'http://localhost:3000/files/abc123.txt';
mockFileService.getFileHTTPURL.mockResolvedValue(expectedUrl);
const result = await controller.getFileHTTPURL(filePath);
expect(result).toBe(expectedUrl);
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith(filePath);
});
it('should handle get HTTP URL error', async () => {
const filePath = '/files/abc123.txt';
const error = new Error('Failed to generate URL');
mockFileService.getFileHTTPURL.mockRejectedValue(error);
await expect(controller.getFileHTTPURL(filePath)).rejects.toThrow('Failed to generate URL');
});
});
describe('deleteFiles', () => {
it('should delete files successfully', async () => {
const paths = ['/files/file1.txt', '/files/file2.txt'];
mockFileService.deleteFiles.mockResolvedValue(undefined);
await controller.deleteFiles(paths);
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(paths);
});
it('should handle delete files error', async () => {
const paths = ['/files/file1.txt'];
const error = new Error('Delete failed');
mockFileService.deleteFiles.mockRejectedValue(error);
await expect(controller.deleteFiles(paths)).rejects.toThrow('Delete failed');
});
it('should handle empty paths array', async () => {
const paths: string[] = [];
mockFileService.deleteFiles.mockResolvedValue(undefined);
await controller.deleteFiles(paths);
expect(mockFileService.deleteFiles).toHaveBeenCalledWith([]);
});
});
describe('createFile', () => {
it('should create file successfully', async () => {
const params = {
hash: 'xyz789',
path: '/test/newfile.txt',
content: 'bmV3IGZpbGUgY29udGVudA==',
filename: 'newfile.txt',
type: 'text/plain',
};
const expectedResult = { id: 'new-file-id', url: '/files/new-file-id' };
mockFileService.uploadFile.mockResolvedValue(expectedResult);
const result = await controller.createFile(params);
expect(result).toEqual(expectedResult);
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
});
it('should handle create file error', async () => {
const params = {
hash: 'xyz789',
path: '/test/newfile.txt',
content: 'bmV3IGZpbGUgY29udGVudA==',
filename: 'newfile.txt',
type: 'text/plain',
};
const error = new Error('Create failed');
mockFileService.uploadFile.mockRejectedValue(error);
await expect(controller.createFile(params)).rejects.toThrow('Create failed');
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
});
});
});
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import UploadFileServerCtr from '../UploadFileServerCtr';
vi.mock('@/services/fileSrv', () => ({
default: class MockFileService {},
}));
const mockFileService = {
getFileHTTPURL: vi.fn(),
getFilePath: vi.fn(),
deleteFiles: vi.fn(),
uploadFile: vi.fn(),
};
const mockApp = {
getService: vi.fn(() => mockFileService),
} as unknown as App;
describe('UploadFileServerCtr', () => {
let controller: UploadFileServerCtr;
beforeEach(() => {
vi.clearAllMocks();
controller = new UploadFileServerCtr(mockApp);
});
it('gets file path by id', async () => {
mockFileService.getFilePath.mockResolvedValue('path');
await expect(controller.getFileUrlById('id')).resolves.toBe('path');
expect(mockFileService.getFilePath).toHaveBeenCalledWith('id');
});
it('gets HTTP URL', async () => {
mockFileService.getFileHTTPURL.mockResolvedValue('url');
await expect(controller.getFileHTTPURL('/path')).resolves.toBe('url');
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith('/path');
});
it('deletes files', async () => {
mockFileService.deleteFiles.mockResolvedValue(undefined);
await controller.deleteFiles(['a']);
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(['a']);
});
it('creates files via upload service', async () => {
const params = { filename: 'file' } as any;
mockFileService.uploadFile.mockResolvedValue({ success: true });
await expect(controller.createFile(params)).resolves.toEqual({ success: true });
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
});
});
@@ -1,7 +1,7 @@
import { ControllerModule, ipcClientEvent } from './index';
import { ControllerModule, IpcMethod } from './index';
export default class DevtoolsCtr extends ControllerModule {
@ipcClientEvent('openDevtools')
@IpcMethod()
async openDevtools() {
const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
devtoolsBrowser.show();
+5 -29
View File
@@ -1,34 +1,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/infrastructure/IoCContainer';
import { ShortcutActionType } from '@/shortcuts';
const ipcDecorator =
(name: string, mode: 'client' | 'server') =>
(target: any, methodName: string, descriptor?: any) => {
const actions = IoCContainer.controllers.get(target.constructor) || [];
actions.push({
methodName,
mode,
name,
});
IoCContainer.controllers.set(target.constructor, actions);
return descriptor;
};
/**
* IPC client event decorator for controllers
*/
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
ipcDecorator(method, 'client');
/**
* IPC server event decorator for controllers
*/
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
ipcDecorator(method, 'server');
import { IpcService } from '@/utils/ipc';
const shortcutDecorator = (name: string) => (target: any, methodName: string, descriptor?: any) => {
const actions = IoCContainer.shortcuts.get(target.constructor) || [];
@@ -68,10 +41,13 @@ interface IControllerModule {
beforeAppReady?(): void;
}
export class ControllerModule implements IControllerModule {
export class ControllerModule extends IpcService implements IControllerModule {
constructor(public app: App) {
super();
this.app = app;
}
}
export type IControlModule = typeof ControllerModule;
export { IpcMethod, IpcServerMethod } from '@/utils/ipc';
@@ -0,0 +1,52 @@
import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } from '@/utils/ipc';
import AuthCtr from './AuthCtr';
import BrowserWindowsCtr from './BrowserWindowsCtr';
import DevtoolsCtr from './DevtoolsCtr';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import McpInstallCtr from './McpInstallCtr';
import MenuController from './MenuCtr';
import NetworkProxyCtr from './NetworkProxyCtr';
import NotificationCtr from './NotificationCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
import ShellCommandCtr from './ShellCommandCtr';
import ShortcutController from './ShortcutCtr';
import SystemController from './SystemCtr';
import TrayMenuCtr from './TrayMenuCtr';
import UpdaterCtr from './UpdaterCtr';
import UploadFileCtr from './UploadFileCtr';
import UploadFileServerCtr from './UploadFileServerCtr';
export const controllerIpcConstructors = [
AuthCtr,
BrowserWindowsCtr,
DevtoolsCtr,
LocalFileCtr,
McpCtr,
McpInstallCtr,
MenuController,
NetworkProxyCtr,
NotificationCtr,
RemoteServerConfigCtr,
RemoteServerSyncCtr,
ShellCommandCtr,
ShortcutController,
SystemController,
TrayMenuCtr,
UpdaterCtr,
UploadFileCtr,
] as const satisfies readonly IpcServiceConstructor[];
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
type DesktopControllerServices = CreateServicesResult<DesktopControllerIpcConstructors>;
export type DesktopIpcServices = MergeIpcService<DesktopControllerServices>;
export const controllerServerIpcConstructors = [
UploadFileServerCtr,
] as const satisfies readonly IpcServiceConstructor[];
type DesktopControllerServerConstructors = typeof controllerServerIpcConstructors;
type DesktopServerControllerServices = CreateServicesResult<DesktopControllerServerConstructors>;
export type DesktopServerIpcServices = MergeIpcService<DesktopServerControllerServices>;
+237 -147
View File
@@ -1,23 +1,31 @@
import {
DEFAULT_VARIANTS,
LOBE_LOCALE_COOKIE,
LOBE_THEME_APPEARANCE,
Locales,
RouteVariants,
} from '@lobechat/desktop-bridge';
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
import { Session, app, ipcMain, protocol } from 'electron';
import { app, protocol, session } from 'electron';
import { macOS, windows } from 'electron-is';
import { pathExistsSync, remove } from 'fs-extra';
import { pathExistsSync } from 'fs-extra';
import os from 'node:os';
import { join } from 'node:path';
import { extname, join } from 'node:path';
import { name } from '@/../../package.json';
import { buildDir, LOCAL_DATABASE_DIR, nextStandaloneDir } from '@/const/dir';
import { buildDir, nextExportDir } from '@/const/dir';
import { isDev } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import { IControlModule } from '@/controllers';
import { IServiceModule } from '@/services';
import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { getServerMethodMetadata } from '@/utils/ipc';
import { createLogger } from '@/utils/logger';
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
import { BrowserManager } from './browser/BrowserManager';
import { I18nManager } from './infrastructure/I18nManager';
import { IoCContainer } from './infrastructure/IoCContainer';
import { ProtocolManager } from './infrastructure/ProtocolManager';
import { RendererProtocolManager } from './infrastructure/RendererProtocolManager';
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
import { StoreManager } from './infrastructure/StoreManager';
import { UpdaterManager } from './infrastructure/UpdaterManager';
@@ -35,8 +43,10 @@ type Class<T> = new (...args: any[]) => T;
const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
const devDefaultRendererUrl = 'http://localhost:3015';
export class App {
nextServerUrl = 'http://localhost:3015';
rendererLoadedUrl: string;
browserManager: BrowserManager;
menuManager: MenuManager;
@@ -47,7 +57,13 @@ export class App {
trayManager: TrayManager;
staticFileServerManager: StaticFileServerManager;
protocolManager: ProtocolManager;
rendererProtocolManager: RendererProtocolManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
/**
* Escape hatch: allow testing static renderer in dev via env
*/
private readonly rendererStaticOverride =
process.env.DESKTOP_RENDERER_STATIC === '1' || process.env.DESKTOP_RENDERER_STATIC === 'true';
/**
* whether app is in quiting
@@ -79,9 +95,32 @@ export class App {
// Initialize store manager
this.storeManager = new StoreManager(this);
this.rendererProtocolManager = new RendererProtocolManager({
getExportMimeType: this.getExportMimeType.bind(this),
nextExportDir,
resolveRendererFilePath: this.resolveRendererFilePath.bind(this),
});
protocol.registerSchemesAsPrivileged([
{
privileges: {
allowServiceWorkers: true,
corsEnabled: true,
secure: true,
standard: true,
supportFetchAPI: true,
},
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
},
this.rendererProtocolManager.protocolScheme,
]);
// Initialize rendererLoadedUrl from RendererProtocolManager
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
// load controllers
const controllers: IControlModule[] = importAll(
(import.meta as any).glob('@/controllers/*Ctr.ts', { eager: true }),
import.meta.glob('@/controllers/*Ctr.ts', { eager: true }),
);
logger.debug(`Loading ${controllers.length} controllers`);
@@ -89,13 +128,13 @@ export class App {
// load services
const services: IServiceModule[] = importAll(
(import.meta as any).glob('@/services/*Srv.ts', { eager: true }),
import.meta.glob('@/services/*Srv.ts', { eager: true }),
);
logger.debug(`Loading ${services.length} services`);
services.forEach((service) => this.addService(service));
this.initializeIPCEvents();
this.initializeServerIpcEvents();
this.i18n = new I18nManager(this);
this.browserManager = new BrowserManager(this);
@@ -106,9 +145,9 @@ export class App {
this.staticFileServerManager = new StaticFileServerManager(this);
this.protocolManager = new ProtocolManager(this);
// register the schema to interceptor url
// it should register before app ready
this.registerNextHandler();
// Configure renderer loading strategy (dev server vs static export)
// should register before app ready
this.configureRendererLoader();
// initialize protocol handlers
this.protocolManager.initialize();
@@ -130,9 +169,6 @@ export class App {
this.initDevBranding();
// Clean up stale database lock file before starting IPC server
await this.cleanupDatabaseLock();
// ==============
await this.ipcServer.start();
logger.debug('IPC server started');
@@ -268,81 +304,21 @@ export class App {
private services = new Map<Class<any>, any>();
private ipcServer: ElectronIPCServer;
/**
* events dispatched from webview layer
*/
private ipcClientEventMap: IPCEventMap = new Map();
private ipcServerEventMap: IPCEventMap = new Map();
shortcutMethodMap: ShortcutMethodMap = new Map();
protocolHandlerMap: ProtocolHandlerMap = new Map();
/**
* use in next router interceptor in prod browser render
*/
nextInterceptor: (params: { session: Session }) => () => void;
/**
* Collection of unregister functions for custom request handlers
*/
private customHandlerUnregisterFns: Array<() => void> = [];
/**
* Function to register custom request handler
*/
private registerCustomHandlerFn?: (handler: CustomRequestHandler) => () => void;
/**
* Register custom request handler
* @param handler Custom request handler function
* @returns Function to unregister the handler
*/
registerRequestHandler = (handler: CustomRequestHandler): (() => void) => {
if (!this.registerCustomHandlerFn) {
logger.warn('Custom request handler registration is not available');
return () => {};
}
logger.debug('Registering custom request handler');
const unregisterFn = this.registerCustomHandlerFn(handler);
this.customHandlerUnregisterFns.push(unregisterFn);
return () => {
unregisterFn();
const index = this.customHandlerUnregisterFns.indexOf(unregisterFn);
if (index !== -1) {
this.customHandlerUnregisterFns.splice(index, 1);
}
};
};
/**
* Unregister all custom request handlers
*/
unregisterAllRequestHandlers = () => {
this.customHandlerUnregisterFns.forEach((unregister) => unregister());
this.customHandlerUnregisterFns = [];
};
private addController = (ControllerClass: IControlModule) => {
const controller = new ControllerClass(this);
this.controllers.set(ControllerClass, controller);
IoCContainer.controllers.get(ControllerClass)?.forEach((event) => {
if (event.mode === 'client') {
// Store all objects from event decorator in ipcClientEventMap
this.ipcClientEventMap.set(event.name, {
controller,
methodName: event.methodName,
});
}
if (event.mode === 'server') {
// Store all objects from event decorator in ipcServerEventMap
this.ipcServerEventMap.set(event.name, {
controller,
methodName: event.methodName,
});
}
const serverMethods = getServerMethodMetadata(ControllerClass);
serverMethods?.forEach((methodName, propertyKey) => {
const channel = `${ControllerClass.groupName}.${methodName}`;
this.ipcServerEventMap.set(channel, {
controller,
methodName: propertyKey,
});
});
IoCContainer.shortcuts.get(ControllerClass)?.forEach((shortcut) => {
@@ -375,79 +351,194 @@ export class App {
}
};
private resolveExportFilePath(pathname: string) {
// Normalize by removing leading/trailing slashes so extname works as expected
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
if (!normalizedPath) return join(nextExportDir, 'index.html');
const basePath = join(nextExportDir, normalizedPath);
const ext = extname(normalizedPath);
// If the request explicitly includes an extension (e.g. html, ico, txt),
// treat it as a direct asset without variant injection.
if (ext) {
return pathExistsSync(basePath) ? basePath : null;
}
const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
for (const candidate of candidates) {
if (pathExistsSync(candidate)) return candidate;
}
const fallback404 = join(nextExportDir, '404.html');
if (pathExistsSync(fallback404)) return fallback404;
return null;
}
private getExportMimeType(filePath: string) {
const ext = extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
return map[ext];
}
/**
* Clean up stale database lock file from previous crashes or abnormal exits
* Configure renderer loading strategy for dev/prod
*/
private cleanupDatabaseLock = async () => {
try {
const dbPath = join(this.appStoragePath, LOCAL_DATABASE_DIR);
const lockPath = `${dbPath}.lock`;
private configureRendererLoader() {
if (isDev && !this.rendererStaticOverride) {
this.rendererLoadedUrl = devDefaultRendererUrl;
this.setupDevRenderer();
return;
}
if (pathExistsSync(lockPath)) {
logger.info(`Cleaning up stale database lock file: ${lockPath}`);
await remove(lockPath);
logger.info('Database lock file removed successfully');
} else {
logger.debug('No database lock file found, skipping cleanup');
if (isDev && this.rendererStaticOverride) {
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
}
this.setupProdRenderer();
}
/**
* Development: use Next dev server directly
*/
private setupDevRenderer() {
logger.info('Development mode: renderer served from Next dev server, no protocol hook');
}
/**
* Production: serve static Next export assets
*/
private setupProdRenderer() {
// Use the URL from RendererProtocolManager
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
this.rendererProtocolManager.registerHandler();
}
/**
* Resolve renderer file path in production by combining variant prefix and pathname.
* Falls back to default variant when cookies are missing or invalid.
*/
private async resolveRendererFilePath(url: URL) {
const pathname = url.pathname;
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
// Static assets should be resolved from root (no variant prefix)
if (
pathname.startsWith('/_next/') ||
pathname.startsWith('/static/') ||
pathname === '/favicon.ico' ||
pathname === '/manifest.json'
) {
return this.resolveExportFilePath(pathname);
}
// If the incoming path already contains an extension (like .html or .ico),
// treat it as a direct asset lookup to avoid double variant prefixes.
const extension = extname(normalizedPathname);
if (extension) {
const directPath = this.resolveExportFilePath(pathname);
if (directPath) return directPath;
// Next.js RSC payloads are emitted under variant folders (e.g. /en-US__0__light/__next._tree.txt),
// but the runtime may request them without the variant prefix. For missing .txt requests,
// retry resolution with variant injection.
if (extension === '.txt' && normalizedPathname.includes('__next.')) {
const variant = await this.getRouteVariantFromCookies();
return (
this.resolveExportFilePath(`/${variant}${pathname}`) ||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
null
);
}
return null;
}
const variant = await this.getRouteVariantFromCookies();
const variantPrefixedPath = `/${variant}${pathname}`;
// Try variant-specific path first, then default variant as fallback
return (
this.resolveExportFilePath(variantPrefixedPath) ||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
null
);
}
private readonly defaultRouteVariant = RouteVariants.serializeVariants(DEFAULT_VARIANTS);
private readonly localeCookieName = LOBE_LOCALE_COOKIE;
private readonly themeCookieName = LOBE_THEME_APPEARANCE;
/**
* Build variant string from Electron session cookies to match Next export structure.
* Desktop is always treated as non-mobile (0).
*/
private async getRouteVariantFromCookies(): Promise<string> {
try {
const cookies = await session.defaultSession.cookies.get({
url: `${this.rendererLoadedUrl}/`,
});
const locale = cookies.find((c) => c.name === this.localeCookieName)?.value;
const themeCookie = cookies.find((c) => c.name === this.themeCookieName)?.value;
const serialized = RouteVariants.serializeVariants(
RouteVariants.createVariants({
isMobile: false,
locale: locale as Locales | undefined,
theme: themeCookie === 'dark' || themeCookie === 'light' ? themeCookie : undefined,
}),
);
return RouteVariants.serializeVariants(RouteVariants.deserializeVariants(serialized));
} catch (error) {
logger.error('Failed to cleanup database lock file:', error);
// Non-fatal error, allow application to continue
}
};
private registerNextHandler() {
logger.debug('Registering Next.js handler');
const handler = createHandler({
debug: true,
localhostUrl: this.nextServerUrl,
protocol,
standaloneDir: nextStandaloneDir,
});
// Log output based on development or production mode
if (isDev) {
logger.info(
`Development mode: Custom request handler enabled, but Next.js interception disabled`,
);
} else {
logger.info(
`Production mode: ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`,
);
}
this.nextInterceptor = handler.createInterceptor;
// Save custom handler registration function
if (handler.registerCustomHandler) {
this.registerCustomHandlerFn = handler.registerCustomHandler;
logger.debug('Custom request handler registration is available');
} else {
logger.warn('Custom request handler registration is not available');
logger.warn('Failed to read route variant cookies, using default', error);
return this.defaultRouteVariant;
}
}
private initializeIPCEvents() {
logger.debug('Initializing IPC events');
// Register batch controller client events for render side consumption
this.ipcClientEventMap.forEach((eventInfo, key) => {
const { controller, methodName } = eventInfo;
/**
* Build renderer URL with variant prefix injected into the path.
* In dev mode (without static override), Next.js dev server handles routing automatically.
* In prod or dev with static override, we need to inject variant to match export structure: /[variants]/path
*/
async buildRendererUrl(path: string): Promise<string> {
// Ensure path starts with /
const cleanPath = path.startsWith('/') ? path : `/${path}`;
ipcMain.handle(key, async (e, data) => {
// 从 WebContents 获取对应的 BrowserWindow id
const senderIdentifier = this.browserManager.getIdentifierByWebContents(e.sender);
try {
return await controller[methodName](data, {
identifier: senderIdentifier,
} as IpcClientEventSender);
} catch (error) {
logger.error(`Error handling IPC event ${key}:`, error);
return { error: error.message };
}
});
});
// In dev mode without static override, use dev server directly (no variant needed)
if (isDev && !this.rendererStaticOverride) {
return `${this.rendererLoadedUrl}${cleanPath}`;
}
// Batch register server events from controllers for next server consumption
// In prod or dev with static override, inject variant for static export structure
const variant = await this.getRouteVariantFromCookies();
return `${this.rendererLoadedUrl}/${variant}.html${cleanPath}`;
}
private initializeServerIpcEvents() {
logger.debug('Initializing IPC server events');
const ipcServerEvents = {} as ElectronIPCEventHandler;
this.ipcServerEventMap.forEach((eventInfo, key) => {
@@ -477,6 +568,5 @@ export class App {
// 执行清理操作
this.staticFileServerManager.destroy();
this.unregisterAllRequestHandlers();
};
}
+62 -116
View File
@@ -1,9 +1,9 @@
import { app } from 'electron';
import { pathExistsSync, remove } from 'fs-extra';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LOCAL_DATABASE_DIR } from '@/const/dir';
// Import after mocks are set up
import { App } from '../App';
const mockPathExistsSync = vi.fn();
// Mock electron modules
vi.mock('electron', () => ({
@@ -24,6 +24,7 @@ vi.mock('electron', () => ({
},
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
},
nativeTheme: {
on: vi.fn(),
@@ -32,6 +33,17 @@ vi.mock('electron', () => ({
protocol: {
registerSchemesAsPrivileged: vi.fn(),
},
session: {
defaultSession: {
cookies: {
get: vi.fn(async () => []),
},
},
},
}));
vi.mock('fs-extra', () => ({
pathExistsSync: (...args: any[]) => mockPathExistsSync(...args),
}));
// Mock logger
@@ -44,16 +56,6 @@ vi.mock('@/utils/logger', () => ({
}),
}));
// Mock fs-extra module
vi.mock('fs-extra', async () => {
const actual = await vi.importActual('fs-extra');
return {
...actual,
pathExistsSync: vi.fn(),
remove: vi.fn(),
};
});
// Mock common/routes
vi.mock('~common/routes', () => ({
findMatchingRoute: vi.fn(),
@@ -76,11 +78,9 @@ vi.mock('@/const/env', () => ({
vi.mock('@/const/dir', () => ({
buildDir: '/mock/build',
nextStandaloneDir: '/mock/standalone',
LOCAL_DATABASE_DIR: 'lobehub-local-db',
nextExportDir: '/mock/export/out',
appStorageDir: '/mock/storage/path',
userDataDir: '/mock/user/data',
DB_SCHEMA_HASH_FILENAME: 'lobehub-local-db-schema-hash',
FILE_STORAGE_DIR: 'file-storage',
INSTALL_PLUGINS_DIR: 'plugins',
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
@@ -155,121 +155,25 @@ vi.mock('../ui/TrayManager', () => ({
})),
}));
vi.mock('@/utils/next-electron-rsc', () => ({
createHandler: vi.fn(() => ({
createInterceptor: vi.fn(),
registerCustomHandler: vi.fn(),
})),
}));
// Mock controllers and services
vi.mock('../../controllers/*Ctr.ts', () => ({}));
vi.mock('../../services/*Srv.ts', () => ({}));
// Import after mocks are set up
import { App } from '../App';
describe('App - Database Lock Cleanup', () => {
describe('App', () => {
let appInstance: App;
let mockLockPath: string;
beforeEach(() => {
vi.clearAllMocks();
mockPathExistsSync.mockReset();
// Mock glob imports to return empty arrays
(import.meta as any).glob = vi.fn(() => ({}));
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
import.meta.glob = vi.fn(() => ({}));
});
afterEach(() => {
vi.clearAllMocks();
});
describe('bootstrap - database lock cleanup', () => {
it('should remove stale lock file if it exists during bootstrap', async () => {
// Setup: simulate existing lock file
vi.mocked(pathExistsSync).mockReturnValue(true);
vi.mocked(remove).mockResolvedValue(undefined);
// Create app instance
appInstance = new App();
// Call bootstrap which should trigger cleanup
await appInstance.bootstrap();
// Verify: lock file check was called
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
// Verify: lock file was removed
expect(remove).toHaveBeenCalledWith(mockLockPath);
});
it('should not attempt to remove lock file if it does not exist', async () => {
// Setup: no lock file exists
vi.mocked(pathExistsSync).mockReturnValue(false);
// Create app instance
appInstance = new App();
// Call bootstrap
await appInstance.bootstrap();
// Verify: lock file check was called
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
// Verify: remove was NOT called since file doesn't exist
expect(remove).not.toHaveBeenCalled();
});
it('should continue bootstrap even if lock cleanup fails', async () => {
// Setup: simulate lock file exists but cleanup fails
vi.mocked(pathExistsSync).mockReturnValue(true);
vi.mocked(remove).mockRejectedValue(new Error('Permission denied'));
// Create app instance
appInstance = new App();
// Bootstrap should not throw even if cleanup fails
await expect(appInstance.bootstrap()).resolves.not.toThrow();
// Verify: cleanup was attempted
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
expect(remove).toHaveBeenCalledWith(mockLockPath);
});
it('should clean up lock file before starting IPC server', async () => {
// Setup
vi.mocked(pathExistsSync).mockReturnValue(true);
const callOrder: string[] = [];
vi.mocked(remove).mockImplementation(async () => {
callOrder.push('remove');
});
// Mock IPC server start to track call order
const { ElectronIPCServer } = await import('@lobechat/electron-server-ipc');
const mockStart = vi.fn().mockImplementation(() => {
callOrder.push('ipcServer.start');
return Promise.resolve();
});
vi.mocked(ElectronIPCServer).mockImplementation(
() =>
({
start: mockStart,
}) as any,
);
// Create app instance and bootstrap
appInstance = new App();
await appInstance.bootstrap();
// Verify: cleanup happens before IPC server starts
expect(callOrder).toEqual(['remove', 'ipcServer.start']);
});
});
describe('appStoragePath', () => {
it('should return storage path from store manager', () => {
appInstance = new App();
@@ -279,4 +183,46 @@ describe('App - Database Lock Cleanup', () => {
expect(storagePath).toBe('/mock/storage/path');
});
});
describe('resolveRendererFilePath', () => {
it('should retry missing .txt requests with variant-prefixed lookup', async () => {
appInstance = new App();
// Avoid touching the electron session cookie code path in this unit test
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => 'en-US__0__light');
mockPathExistsSync.mockImplementation((p: string) => {
// root miss
if (p === '/mock/export/out/__next._tree.txt') return false;
// variant hit
if (p === '/mock/export/out/en-US__0__light/__next._tree.txt') return true;
return false;
});
const resolved = await (appInstance as any).resolveRendererFilePath(
new URL('app://next/__next._tree.txt'),
);
expect(resolved).toBe('/mock/export/out/en-US__0__light/__next._tree.txt');
});
it('should keep direct lookup for existing root .txt assets (no variant retry)', async () => {
appInstance = new App();
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => {
throw new Error('should not be called');
});
mockPathExistsSync.mockImplementation((p: string) => {
if (p === '/mock/export/out/en-US__0__light.txt') return true;
return false;
});
const resolved = await (appInstance as any).resolveRendererFilePath(
new URL('app://next/en-US__0__light.txt'),
);
expect(resolved).toBe('/mock/export/out/en-US__0__light.txt');
});
});
});
+127 -12
View File
@@ -2,14 +2,18 @@ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-c
import {
BrowserWindow,
BrowserWindowConstructorOptions,
type Session,
session as electronSession,
ipcMain,
nativeTheme,
screen,
} from 'electron';
import console from 'node:console';
import { join } from 'node:path';
import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
import { isDev, isMac, isWindows } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import {
BACKGROUND_DARK,
BACKGROUND_LIGHT,
@@ -18,12 +22,15 @@ import {
THEME_CHANGE_DELAY,
TITLE_BAR_HEIGHT,
} from '@/const/theme';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { createLogger } from '@/utils/logger';
import type { App } from '../App';
// Create logger
const logger = createLogger('core:Browser');
// Track sessions that already have protocol handlers installed to avoid duplicates
const protocolHandledSessions = new WeakSet<Session>();
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
devTools?: boolean;
@@ -41,7 +48,6 @@ export default class Browser {
private app: App;
private _browserWindow?: BrowserWindow;
private themeListenerSetup = false;
private stopInterceptHandler;
identifier: string;
options: BrowserWindowOpts;
private readonly windowStateKey: string;
@@ -167,11 +173,14 @@ export default class Browser {
}
loadUrl = async (path: string) => {
const initUrl = this.app.nextServerUrl + path;
const initUrl = await this.app.buildRendererUrl(path);
console.log('[Browser] initUrl', initUrl);
try {
logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`);
await this._browserWindow.loadURL(initUrl);
logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`);
} catch (error) {
logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error);
@@ -295,7 +304,6 @@ export default class Browser {
*/
destroy() {
logger.debug(`Destroying window instance: ${this.identifier}`);
this.stopInterceptHandler?.();
this.cleanupThemeListener();
this._browserWindow = undefined;
}
@@ -339,6 +347,7 @@ export default class Browser {
backgroundThrottling: false,
contextIsolation: true,
preload: join(preloadDir, 'index.js'),
sandbox: false,
},
width: savedState?.width || width,
...this.getPlatformThemeConfig(isDarkMode),
@@ -354,13 +363,10 @@ export default class Browser {
// Apply initial visual effects
this.applyVisualEffects();
logger.debug(`[${this.identifier}] Setting up nextInterceptor.`);
this.stopInterceptHandler = this.app.nextInterceptor({
session: browserWindow.webContents.session,
});
// Setup CORS bypass for local file server
this.setupCORSBypass(browserWindow);
// Setup request hook for remote server sync (base URL rewrite + OIDC header)
this.setupRemoteServerRequestHook(browserWindow);
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
this.loadPlaceholder().then(() => {
@@ -409,8 +415,7 @@ export default class Browser {
} catch (error) {
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
}
// Need to clean up intercept handler and theme manager
this.stopInterceptHandler?.();
// Need to clean up theme manager
this.cleanupThemeListener();
return;
}
@@ -445,8 +450,7 @@ export default class Browser {
} catch (error) {
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
}
// Need to clean up intercept handler and theme manager
this.stopInterceptHandler?.();
// Need to clean up theme manager
this.cleanupThemeListener();
}
});
@@ -528,4 +532,115 @@ export default class Browser {
logger.debug(`[${this.identifier}] CORS bypass setup completed`);
}
/**
* Rewrite tRPC requests to remote server and inject OIDC token via webRequest hooks.
* Replaces the previous proxyTRPCRequest IPC forwarding.
*/
private setupRemoteServerRequestHook(browserWindow: BrowserWindow) {
const session = browserWindow.webContents.session;
const remoteServerConfigCtr = this.app.getController(RemoteServerConfigCtr);
const logPrefix = `[${this.identifier}] RemoteServerRequestHook`;
// Guard to ensure hooks are registered only once per session
const targetSession = session || electronSession.defaultSession;
if (!targetSession || protocolHandledSessions.has(targetSession)) return;
const rewriteUrl = async (rawUrl: string) => {
let remoteServerUrl: string | undefined;
try {
const requestUrl = new URL(rawUrl);
const config = await remoteServerConfigCtr.getRemoteServerConfig();
remoteServerUrl = await remoteServerConfigCtr.getRemoteServerUrl(config);
const remoteBase = new URL(remoteServerUrl);
if (requestUrl.origin === remoteBase.origin) return;
const rewrittenUrl = new URL(
requestUrl.pathname + requestUrl.search,
remoteBase,
).toString();
logger.debug(`${logPrefix} rewrite ${rawUrl} -> ${rewrittenUrl}`);
return rewrittenUrl;
} catch (error) {
logger.error(
`${logPrefix} rewriteUrl error (rawUrl=${rawUrl}, remoteServerUrl=${remoteServerUrl})`,
error,
);
return null;
}
};
// Transparent rewrite via protocol handlers (no HTTP 302)
const registerProtocolHandlers = async () => {
const proxyHandler = async (request: Request): Promise<Response | null> => {
// lobe-backend://lobe/trpc/xxx -> http://<target_host>/trpc/xxx
try {
const rewrittenUrl = await rewriteUrl(request.url);
if (!rewrittenUrl) return null;
const headers = new Headers(request.headers);
const token = await remoteServerConfigCtr.getAccessToken();
if (token) headers.set('Oidc-Auth', token);
// eslint-disable-next-line no-undef
const requestInit: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
// Only forward body for non-GET/HEAD requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
const body = request.body ?? undefined;
if (body) {
requestInit.body = body;
// Node.js (undici) requires `duplex` when sending a streaming body
requestInit.duplex = 'half';
}
}
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(rewrittenUrl, requestInit);
} catch (error) {
logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
return new Response('Upstream fetch failed, target url: ' + rewrittenUrl, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
status: 502,
statusText: 'Bad Gateway',
});
}
const responseHeaders = new Headers(upstreamResponse.headers);
const allowOrigin = request.headers.get('Origin') || undefined;
if (allowOrigin) {
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
}
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', '*');
responseHeaders.set('X-Src-Url', rewrittenUrl);
return new Response(upstreamResponse.body, {
headers: responseHeaders,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
});
} catch (error) {
logger.error(`${logPrefix} protocol.handle error:`, error);
return null;
}
};
targetSession.protocol.handle(ELECTRON_BE_PROTOCOL_SCHEME, proxyHandler);
logger.debug(`${logPrefix} protocol handler registered for ${ELECTRON_BE_PROTOCOL_SCHEME}`);
};
registerProtocolHandlers();
protocolHandledSessions.add(targetSession);
}
}
@@ -107,7 +107,11 @@ describe('Browser', () => {
let mockApp: AppCore;
let mockStoreManagerGet: ReturnType<typeof vi.fn>;
let mockStoreManagerSet: ReturnType<typeof vi.fn>;
let mockNextInterceptor: ReturnType<typeof vi.fn>;
let mockRemoteServerConfigCtr: {
getAccessToken: ReturnType<typeof vi.fn>;
getRemoteServerConfig: ReturnType<typeof vi.fn>;
};
let autoLoadUrlSpy: ReturnType<typeof vi.spyOn> | undefined;
const defaultOptions: BrowserWindowOpts = {
height: 600,
@@ -133,14 +137,34 @@ describe('Browser', () => {
// Create mock App
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
mockStoreManagerSet = vi.fn();
mockNextInterceptor = vi.fn().mockReturnValue(vi.fn());
// Browser setup now installs protocol handlers that depend on RemoteServerConfigCtr
mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue(null),
getRemoteServerConfig: vi.fn().mockResolvedValue({
remoteServerUrl: 'http://localhost:3000',
}),
};
// Ensure Browser can register protocol handlers on the session
(mockBrowserWindow.webContents.session as any).protocol = {
handle: vi.fn(),
};
mockApp = {
browserManager: {
retrieveByIdentifier: vi.fn(),
},
buildRendererUrl: vi.fn(async (path: string) => {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `http://localhost:3000${cleanPath}`;
}),
getController: vi.fn((ctr: any) => {
// Only the remote server config controller is required in these unit tests
if (ctr?.name === 'RemoteServerConfigCtr') return mockRemoteServerConfigCtr;
throw new Error(`Unexpected controller requested in Browser tests: ${ctr?.name ?? ctr}`);
}),
isQuiting: false,
nextInterceptor: mockNextInterceptor,
nextServerUrl: 'http://localhost:3000',
storeManager: {
get: mockStoreManagerGet,
@@ -149,6 +173,8 @@ describe('Browser', () => {
} as unknown as AppCore;
browser = new Browser(defaultOptions, mockApp);
// The constructor triggers an async placeholder->loadUrl chain; stub it to avoid cross-test flakiness.
autoLoadUrlSpy = vi.spyOn(browser, 'loadUrl').mockResolvedValue(undefined as any);
});
afterEach(() => {
@@ -164,10 +190,6 @@ describe('Browser', () => {
it('should create BrowserWindow on construction', () => {
expect(MockBrowserWindow).toHaveBeenCalled();
});
it('should setup next interceptor', () => {
expect(mockNextInterceptor).toHaveBeenCalled();
});
});
describe('browserWindow getter', () => {
@@ -344,12 +366,14 @@ describe('Browser', () => {
describe('loadUrl', () => {
it('should load full URL successfully', async () => {
autoLoadUrlSpy?.mockRestore();
await browser.loadUrl('/test-path');
expect(mockBrowserWindow.loadURL).toHaveBeenCalledWith('http://localhost:3000/test-path');
});
it('should load error page on failure', async () => {
autoLoadUrlSpy?.mockRestore();
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
await browser.loadUrl('/test-path');
@@ -358,6 +382,7 @@ describe('Browser', () => {
});
it('should setup retry handler on error', async () => {
autoLoadUrlSpy?.mockRestore();
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
await browser.loadUrl('/test-path');
@@ -367,9 +392,13 @@ describe('Browser', () => {
});
it('should load fallback HTML when error page fails', async () => {
autoLoadUrlSpy?.mockRestore();
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
mockBrowserWindow.loadFile.mockRejectedValueOnce(new Error('Error page failed'));
mockBrowserWindow.loadURL.mockResolvedValueOnce(undefined);
mockBrowserWindow.loadFile.mockImplementation(async (filePath: string) => {
if (filePath === '/mock/resources/error.html') throw new Error('Error page failed');
return undefined;
});
await browser.loadUrl('/test-path');
@@ -2,11 +2,6 @@
*
*/
export class IoCContainer {
static controllers: WeakMap<
any,
{ methodName: string; mode: 'client' | 'server'; name: string }[]
> = new WeakMap();
static shortcuts: WeakMap<any, { methodName: string; name: string }[]> = new WeakMap();
static protocolHandlers: WeakMap<any, { action: string; methodName: string; urlType: string }[]> =
@@ -0,0 +1,179 @@
import { app, protocol } from 'electron';
import { pathExistsSync } from 'fs-extra';
import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
import { createLogger } from '@/utils/logger';
type ResolveRendererFilePath = (url: URL) => Promise<string | null>;
type GetExportMimeType = (filePath: string) => string | undefined;
const RENDERER_PROTOCOL_PRIVILEGES = {
allowServiceWorkers: true,
corsEnabled: true,
secure: true,
standard: true,
supportFetchAPI: true,
} as const;
interface RendererProtocolManagerOptions {
getExportMimeType: GetExportMimeType;
host?: string;
nextExportDir: string;
resolveRendererFilePath: ResolveRendererFilePath;
scheme?: string;
}
const RENDERER_DIR = 'next';
export class RendererProtocolManager {
private readonly scheme: string;
private readonly host: string;
private readonly nextExportDir: string;
private readonly resolveRendererFilePath: ResolveRendererFilePath;
private readonly getExportMimeType: GetExportMimeType;
private handlerRegistered = false;
constructor(options: RendererProtocolManagerOptions) {
const { nextExportDir, resolveRendererFilePath, getExportMimeType } = options;
this.scheme = 'app';
this.host = RENDERER_DIR;
this.nextExportDir = nextExportDir;
this.resolveRendererFilePath = resolveRendererFilePath;
this.getExportMimeType = getExportMimeType;
}
/**
* Get the full renderer URL with scheme and host
*/
getRendererUrl(): string {
return `${this.scheme}://${this.host}`;
}
get protocolScheme() {
return {
privileges: RENDERER_PROTOCOL_PRIVILEGES,
scheme: this.scheme,
};
}
registerHandler() {
if (this.handlerRegistered) return;
if (!pathExistsSync(this.nextExportDir)) {
createLogger('core:RendererProtocolManager').warn(
`Next export directory not found, skip static handler: ${this.nextExportDir}`,
);
return;
}
const logger = createLogger('core:RendererProtocolManager');
logger.debug(
`Registering renderer ${this.scheme}:// handler for production export at host ${this.host}`,
);
const register = () => {
if (this.handlerRegistered) return;
protocol.handle(this.scheme, async (request) => {
const url = new URL(request.url);
const hostname = url.hostname;
const pathname = url.pathname;
const isAssetRequest = this.isAssetRequest(pathname);
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
if (hostname !== this.host) {
return new Response('Not Found', { status: 404 });
}
const buildFileResponse = async (targetPath: string) => {
const buffer = await readFile(targetPath);
const headers = new Headers();
const mimeType = this.getExportMimeType(targetPath);
if (mimeType) headers.set('Content-Type', mimeType);
return new Response(buffer, { headers });
};
const resolveEntryFilePath = () =>
this.resolveRendererFilePath(new URL(`${this.scheme}://${this.host}/`));
let filePath = await this.resolveRendererFilePath(url);
// If the resolved file is the export 404 page, treat it as missing so we can
// fall back to the entry HTML for SPA routing (unless explicitly requested).
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
filePath = null;
}
if (!filePath) {
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
// Fallback to entry HTML for unknown routes (SPA-like behavior)
filePath = await resolveEntryFilePath();
if (!filePath || this.is404Html(filePath)) {
return new Response('Render file Not Found', { status: 404 });
}
}
try {
return await buildFileResponse(filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
const fallbackPath = await resolveEntryFilePath();
if (!fallbackPath || this.is404Html(fallbackPath)) {
return new Response('Render file Not Found', { status: 404 });
}
try {
return await buildFileResponse(fallbackPath);
} catch (fallbackError) {
logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
return new Response('Internal Server Error', { status: 500 });
}
}
logger.error(`Failed to serve export asset ${filePath}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
});
this.handlerRegistered = true;
};
if (app.isReady()) {
register();
} else {
// protocol.handle needs the default session, which is only available after ready
app.whenReady().then(register);
}
}
private isAssetRequest(pathname: string) {
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const ext = extname(normalizedPathname);
return (
pathname.startsWith('/_next/') ||
pathname.startsWith('/static/') ||
pathname === '/favicon.ico' ||
pathname === '/manifest.json' ||
!!ext
);
}
private is404Html(filePath: string) {
return basename(filePath) === '404.html';
}
}
@@ -13,52 +13,6 @@ describe('IoCContainer', () => {
// For each test, use fresh class instances
});
describe('controllers WeakMap', () => {
it('should store controller metadata', () => {
const metadata = [{ methodName: 'handleMessage', mode: 'client' as const, name: 'message' }];
IoCContainer.controllers.set(TestController, metadata);
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata);
});
it('should allow multiple controllers', () => {
const metadata1 = [{ methodName: 'method1', mode: 'client' as const, name: 'action1' }];
const metadata2 = [{ methodName: 'method2', mode: 'server' as const, name: 'action2' }];
IoCContainer.controllers.set(TestController, metadata1);
IoCContainer.controllers.set(AnotherController, metadata2);
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata1);
expect(IoCContainer.controllers.get(AnotherController)).toEqual(metadata2);
});
it('should allow overwriting controller metadata', () => {
const oldMetadata = [{ methodName: 'oldMethod', mode: 'client' as const, name: 'old' }];
const newMetadata = [{ methodName: 'newMethod', mode: 'server' as const, name: 'new' }];
IoCContainer.controllers.set(TestController, oldMetadata);
IoCContainer.controllers.set(TestController, newMetadata);
expect(IoCContainer.controllers.get(TestController)).toEqual(newMetadata);
});
it('should support multiple methods per controller', () => {
const metadata = [
{ methodName: 'method1', mode: 'client' as const, name: 'action1' },
{ methodName: 'method2', mode: 'server' as const, name: 'action2' },
{ methodName: 'method3', mode: 'client' as const, name: 'action3' },
];
IoCContainer.controllers.set(TestController, metadata);
const stored = IoCContainer.controllers.get(TestController);
expect(stored).toHaveLength(3);
expect(stored?.[0].mode).toBe('client');
expect(stored?.[1].mode).toBe('server');
});
});
describe('shortcuts WeakMap', () => {
it('should store shortcut metadata', () => {
const metadata = [{ methodName: 'toggleDarkMode', name: 'CmdOrCtrl+Shift+D' }];
@@ -141,10 +95,6 @@ describe('IoCContainer', () => {
});
describe('static properties', () => {
it('should have controllers as a WeakMap', () => {
expect(IoCContainer.controllers).toBeInstanceOf(WeakMap);
});
it('should have shortcuts as a WeakMap', () => {
expect(IoCContainer.shortcuts).toBeInstanceOf(WeakMap);
});
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RendererProtocolManager } from '../RendererProtocolManager';
const {
mockApp,
mockPathExistsSync,
mockProtocol,
mockReadFile,
protocolHandlerRef,
} = vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockPathExistsSync: vi.fn().mockReturnValue(true),
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
protocolHandlerRef,
};
});
vi.mock('electron', () => ({
app: mockApp,
protocol: mockProtocol,
}));
vi.mock('fs-extra', () => ({
pathExistsSync: mockPathExistsSync,
}));
vi.mock('node:fs/promises', () => ({
readFile: mockReadFile,
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
describe('RendererProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
mockApp.isReady.mockReturnValue(true);
mockPathExistsSync.mockReturnValue(true);
});
afterEach(() => {
protocolHandlerRef.current = null;
});
it('should fall back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
const resolveRendererFilePath = vi.fn(async (url: URL) => {
if (url.pathname === '/missing') return '/export/404.html';
if (url.pathname === '/') return '/export/index.html';
return null;
});
const getExportMimeType = vi.fn(() => 'text/html; charset=utf-8');
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
const manager = new RendererProtocolManager({
getExportMimeType,
nextExportDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
expect(mockProtocol.handle).toHaveBeenCalled();
const handler = protocolHandlerRef.current;
const response = await handler({ url: 'app://next/missing' } as any);
const body = await response.text();
expect(resolveRendererFilePath).toHaveBeenCalledTimes(2);
expect(resolveRendererFilePath.mock.calls[0][0].pathname).toBe('/missing');
expect(resolveRendererFilePath.mock.calls[1][0].pathname).toBe('/');
expect(mockReadFile).toHaveBeenCalledWith('/export/index.html');
expect(body).toContain('/export/index.html');
expect(response.status).toBe(200);
});
it('should serve 404.html when explicitly requested', async () => {
const resolveRendererFilePath = vi.fn(async (url: URL) => {
if (url.pathname === '/404.html') return '/export/404.html';
if (url.pathname === '/') return '/export/index.html';
return null;
});
const getExportMimeType = vi.fn(() => 'text/html; charset=utf-8');
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
const manager = new RendererProtocolManager({
getExportMimeType,
nextExportDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({ url: 'app://next/404.html' } as any);
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
expect(mockReadFile).toHaveBeenCalledWith('/export/404.html');
expect(response.status).toBe(200);
});
it('should return 404 for missing asset requests without fallback', async () => {
const resolveRendererFilePath = vi.fn(async (_url: URL) => null);
const getExportMimeType = vi.fn();
const manager = new RendererProtocolManager({
getExportMimeType,
nextExportDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({ url: 'app://next/logo.png' } as any);
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
expect(response.status).toBe(404);
});
});
+8
View File
@@ -0,0 +1,8 @@
import type { DesktopIpcServices } from './controllers/registry';
declare module '@lobechat/electron-client-ipc' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface DesktopIpcServicesMap extends DesktopIpcServices {}
}
export { type DesktopIpcServices, type DesktopServerIpcServices } from './controllers/registry';
+2
View File
@@ -0,0 +1,2 @@
// Export types for renderer/server to use
export type { DesktopIpcServices, DesktopServerIpcServices } from './controllers/registry';
+3
View File
@@ -0,0 +1,3 @@
import 'vite/client';
export {};
+132
View File
@@ -0,0 +1,132 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
StdioClientTransport,
getDefaultEnvironment,
} from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Progress } from '@modelcontextprotocol/sdk/types.js';
import type { MCPClientParams, McpPrompt, McpResource, McpTool, ToolCallResult } from './types';
const MCP_TOOL_TIMEOUT = (() => {
const val = Number(process.env.MCP_TOOL_TIMEOUT);
return Number.isFinite(val) && val > 0 ? val : 60_000;
})();
export class MCPClient {
private readonly mcp: Client;
private transport: Transport;
constructor(params: MCPClientParams) {
this.mcp = new Client({ name: 'lobehub-desktop-mcp-client', version: '1.0.0' });
switch (params.type) {
case 'http': {
const headers: Record<string, string> = { ...params.headers };
if (params.auth) {
if (params.auth.type === 'bearer' && params.auth.token) {
headers['Authorization'] = `Bearer ${params.auth.token}`;
}
if (params.auth.type === 'oauth2' && params.auth.accessToken) {
headers['Authorization'] = `Bearer ${params.auth.accessToken}`;
}
}
this.transport = new StreamableHTTPClientTransport(new URL(params.url), {
requestInit: { headers },
});
break;
}
case 'stdio': {
this.transport = new StdioClientTransport({
args: params.args,
command: params.command,
env: {
...getDefaultEnvironment(),
...params.env,
},
});
break;
}
default: {
// Exhaustive check
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _never: never = params;
throw new Error(`Unsupported MCP connection type: ${(params as any).type}`);
}
}
}
private isMethodNotFoundError(error: unknown) {
const err = error as any;
if (!err) return false;
if (err.code === -32601) return true;
if (typeof err.message === 'string' && err.message.includes('Method not found')) return true;
return false;
}
async initialize(options: { onProgress?: (progress: Progress) => void } = {}) {
await this.mcp.connect(this.transport, { onprogress: options.onProgress });
}
async disconnect() {
if (typeof (this.mcp as any).disconnect === 'function') {
await (this.mcp as any).disconnect();
return;
}
if (this.transport && typeof (this.transport as any).close === 'function') {
(this.transport as any).close();
}
}
async listTools() {
const { tools } = await this.mcp.listTools();
return (tools || []) as McpTool[];
}
async listResources() {
const { resources } = await this.mcp.listResources();
return (resources || []) as McpResource[];
}
async listPrompts() {
const { prompts } = await this.mcp.listPrompts();
return (prompts || []) as McpPrompt[];
}
async listManifests() {
const [tools, prompts, resources] = await Promise.all([
this.listTools(),
this.listPrompts().catch((error) => {
if (this.isMethodNotFoundError(error)) return [] as McpPrompt[];
throw error;
}),
this.listResources().catch((error) => {
if (this.isMethodNotFoundError(error)) return [] as McpResource[];
throw error;
}),
]);
return {
prompts: prompts.length === 0 ? undefined : prompts,
resources: resources.length === 0 ? undefined : resources,
title: this.mcp.getServerVersion()?.title,
tools: tools.length === 0 ? undefined : tools,
version: this.mcp.getServerVersion()?.version?.replace('v', ''),
};
}
async callTool(toolName: string, args: any): Promise<ToolCallResult> {
const result = await this.mcp.callTool({ arguments: args, name: toolName }, undefined, {
timeout: MCP_TOOL_TIMEOUT,
});
return result as ToolCallResult;
}
}
+120
View File
@@ -0,0 +1,120 @@
export interface McpTool {
description: string;
inputSchema: {
[k: string]: unknown;
properties?: unknown | null;
type: 'object';
};
name: string;
}
export interface McpResource {
description?: string;
mimeType?: string;
name: string;
uri: string;
}
export interface McpPromptArgument {
description?: string;
name: string;
required?: boolean;
}
export interface McpPrompt {
arguments?: McpPromptArgument[];
description?: string;
name: string;
}
export interface TextContent {
_meta?: any;
text: string;
type: 'text';
}
export interface ImageContent {
_meta?: any;
/**
* Usually base64 data from MCP server (without data: prefix)
*/
data: string;
mimeType: string;
type: 'image';
}
export interface AudioContent {
_meta?: any;
/**
* Usually base64 data from MCP server (without data: prefix)
*/
data: string;
mimeType: string;
type: 'audio';
}
export interface ResourceContent {
_meta?: any;
resource: {
_meta?: any;
blob?: string;
mimeType?: string;
text?: string;
uri: string;
};
type: 'resource';
}
export interface ResourceLinkContent {
_meta?: any;
description?: string;
icons?: Array<{
mimeType?: string;
sizes?: string[];
src: string;
}>;
name: string;
title?: string;
type: 'resource_link';
uri: string;
}
export type ToolCallContent =
| TextContent
| ImageContent
| AudioContent
| ResourceContent
| ResourceLinkContent;
export interface ToolCallResult {
content: ToolCallContent[];
isError?: boolean;
structuredContent?: any;
}
export interface AuthConfig {
accessToken?: string;
token?: string;
type: 'none' | 'bearer' | 'oauth2';
}
export interface HttpMCPClientParams {
auth?: AuthConfig;
headers?: Record<string, string>;
name: string;
type: 'http';
url: string;
}
export interface StdioMCPClientParams {
args: string[];
command: string;
env?: Record<string, string>;
name: string;
type: 'stdio';
}
export type MCPClientParams = HttpMCPClientParams | StdioMCPClientParams;
@@ -89,6 +89,7 @@ const createMockApp = () => {
},
browserManager: {
getMainWindow: vi.fn(() => ({
broadcast: vi.fn(),
loadUrl: vi.fn(),
show: vi.fn(),
})),
+2 -2
View File
@@ -83,8 +83,8 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
accelerator: 'Command+,',
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
await mainWindow.loadUrl('/settings');
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('macOS.preferences'),
},
@@ -341,8 +341,8 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
await mainWindow.loadUrl('/settings');
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('file.preferences'),
},
@@ -17,6 +17,23 @@ const repoRoot = path.resolve(__dirname, '../../../../..');
describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integration', () => {
const searchService = new MacOSSearchServiceImpl();
const ensureResults = (results: unknown[], context: string) => {
if (results.length > 0) return true;
// eslint-disable-next-line no-console
console.warn(`⚠️ Spotlight returned 0 results for ${context} - indexing may be incomplete`);
return false;
};
const ensureResultsOrSkipAssertions = (results: unknown[], hint: string) => {
if (results.length > 0) return true;
// eslint-disable-next-line no-console
console.warn(
`⚠️ Spotlight returned 0 results for "${hint}". This usually means indexing is incomplete/disabled. Skipping strict assertions.`,
);
// Keep a minimal assertion so we still validate the call didn't throw.
expect(Array.isArray(results)).toBe(true);
return false;
};
describe('checkSearchServiceStatus', () => {
it('should verify Spotlight is available on macOS', async () => {
@@ -34,7 +51,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
expect(results.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(results, 'package.json')) return;
// Should find at least one package.json
const packageJson = results.find((r) => r.name === 'package.json');
@@ -49,7 +66,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
limit: 10,
onlyIn: repoRoot,
});
expect(results.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(results, 'README')) return;
// Should contain markdown files
const mdFile = results.find((r) => r.type === 'md');
@@ -64,7 +81,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
expect(results.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(results, 'macOS')) return;
// Should find the macOS.ts implementation file
const macOSFile = results.find((r) => r.name.includes('macOS') && r.type === 'ts');
@@ -106,7 +123,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
expect(results.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(results, 'test.ts')) return;
// Should find test files (can be in __tests__ directory or co-located with source files)
const testFile = results.find((r) => r.name.endsWith('.test.ts'));
@@ -161,6 +178,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'TypeScript identification')) return;
const tsFile = results.find((r) => r.name === 'LocalFileCtr.ts');
if (tsFile) {
expect(tsFile.type).toBe('ts');
@@ -176,6 +194,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'JSON identification')) return;
const jsonFile = results.find((r) => r.name.includes('tsconfig') && r.type === 'json');
if (jsonFile) {
expect(jsonFile.type).toBe('json');
@@ -191,6 +210,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'directory identification')) return;
const testDir = results.find((r) => r.name === '__tests__' && r.isDirectory);
if (testDir) {
expect(testDir.isDirectory).toBe(true);
@@ -221,7 +241,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
expect(results.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(results, 'package.json (metadata)')) return;
const file = results[0];
@@ -279,7 +299,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
expect(results.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(results, 'LocalFile')) return;
// Should find LocalFileCtr.ts or similar files
const found = results.some(
@@ -319,8 +339,8 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
});
// Both searches should find similar files
expect(lowerResults.length).toBeGreaterThan(0);
expect(upperResults.length).toBeGreaterThan(0);
if (!ensureResultsOrSkipAssertions(lowerResults, 'readme')) return;
if (!ensureResultsOrSkipAssertions(upperResults, 'README (case-insensitive)')) return;
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@lobehub/desktop-ipc-typings",
"version": "1.0.0",
"private": true,
"main": "./exports.d.ts",
"types": "./exports.d.ts",
"exports": {
".": "./exports.d.ts"
}
}

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