Compare commits

...

519 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
2963 changed files with 186158 additions and 43885 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
+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);
```
+161
View File
@@ -0,0 +1,161 @@
---
alwaysApply: false
---
# 如何添加新的快捷键:开发者指南
本指南将带您一步步地向 LobeChat 添加一个新的快捷键功能。我们将通过一个完整示例,演示从定义到实现的整个过程。
## 示例场景
假设我们要添加一个新的快捷键功能:**快速清空聊天记录**,快捷键为 `Mod+Shift+Backspace`。
## 步骤 1:更新快捷键常量定义
首先,在 `src/types/hotkey.ts` 中更新 `HotkeyEnum`
```typescript
export const HotkeyEnum = {
// 已有的快捷键...
AddUserMessage: 'addUserMessage',
EditMessage: 'editMessage',
// 新增快捷键
ClearChat: 'clearChat', // 添加这一行
// 其他已有快捷键...
} as const;
```
## 步骤 2:注册默认快捷键
在 `src/const/hotkeys.ts` 中添加快捷键的默认配置:
```typescript
import { KeyMapEnum as Key, combineKeys } from '@lobehub/ui';
// ...现有代码
export const HOTKEYS_REGISTRATION: HotkeyRegistration = [
// 现有的快捷键配置...
// 添加新的快捷键配置
{
group: HotkeyGroupEnum.Conversation, // 归类到会话操作组
id: HotkeyEnum.ClearChat,
keys: combineKeys([Key.Mod, Key.Shift, Key.Backspace]),
scopes: [HotkeyScopeEnum.Chat], // 在聊天作用域下生效
},
// 其他现有快捷键...
];
```
## 步骤 3:添加国际化翻译
在 `src/locales/default/hotkey.ts` 中添加对应的文本描述:
```typescript
import { HotkeyI18nTranslations } from '@/types/hotkey';
const hotkey: HotkeyI18nTranslations = {
// 现有翻译...
// 添加新快捷键的翻译
clearChat: {
desc: '清空当前会话的所有消息记录',
title: '清空聊天记录',
},
// 其他现有翻译...
};
export default hotkey;
```
如需支持其他语言,还需要在相应的语言文件中添加对应翻译。
## 步骤 4:创建并注册快捷键 Hook
在 `src/hooks/useHotkeys/chatScope.ts` 中添加新的 Hook
```typescript
export const useClearChatHotkey = () => {
const clearMessages = useChatStore((s) => s.clearMessages);
const { t } = useTranslation();
return useHotkeyById(HotkeyEnum.ClearChat, showConfirm);
};
// 注册聚合
export const useRegisterChatHotkeys = () => {
const { enableScope, disableScope } = useHotkeysContext();
useOpenChatSettingsHotkey();
// ...其他快捷键
useClearChatHotkey();
useEffect(() => {
enableScope(HotkeyScopeEnum.Chat);
return () => disableScope(HotkeyScopeEnum.Chat);
}, []);
return null;
};
```
## 步骤 5:给相应 UI 元素添加 Tooltip 提示(可选)
如果有对应的 UI 按钮,可以添加快捷键提示:
```tsx
import { DeleteOutlined } from '@ant-design/icons';
import { Tooltip } from '@lobehub/ui';
import { Button } from 'antd';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/selectors';
import { HotkeyEnum } from '@/types/hotkey';
const ClearChatButton = () => {
const { t } = useTranslation(['hotkey', 'chat']);
const clearChatHotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.ClearChat));
// 获取清空聊天的方法
const clearMessages = useChatStore((s) => s.clearMessages);
return (
<Tooltip hotkey={clearChatHotkey} title={t('clearChat.title', { ns: 'hotkey' })}>
<Button icon={<DeleteOutlined />} onClick={clearMessages} />
</Tooltip>
);
};
```
## 步骤 6:测试新快捷键
1. 启动开发服务器
2. 打开聊天页面
3. 按下设置的快捷键组合(`Cmd+Shift+Backspace` 或 `Ctrl+Shift+Backspace`
4. 确认功能正常工作
5. 检查快捷键设置面板中是否正确显示了新快捷键
## 最佳实践
1. **作用域考虑**:根据功能决定快捷键应属于全局作用域还是聊天作用域
2. **分组合理**:将快捷键放在合适的功能组中(System/Layout/Conversation
3. **冲突检查**:确保新快捷键不会与现有系统、浏览器或应用快捷键冲突
4. **平台适配**:使用 `Key.Mod` 而非硬编码 `Ctrl` 或 `Cmd`,以适配不同平台
5. **提供清晰描述**:为快捷键添加明确的标题和描述,帮助用户理解功能
按照以上步骤,您可以轻松地向系统添加新的快捷键功能,提升用户体验。如有特殊需求,如桌面专属快捷键,可以通过 `isDesktop` 标记进行区分处理。
## 常见问题排查
- **快捷键未生效**:检查作用域是否正确,以及是否在 RegisterHotkeys 中调用了对应的 hook
- **快捷键设置面板未显示**:确认在 HOTKEYS_REGISTRATION 中正确配置了快捷键
- **快捷键冲突**:在 HotkeyInput 组件中可以检测到冲突,用户会看到警告
- **功能在某些页面失效**:确认是否注册在了正确的作用域,以及相关页面是否激活了该作用域
通过这些步骤,您可以确保新添加的快捷键功能稳定、可靠且用户友好。
+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');
```
+138
View File
@@ -0,0 +1,138 @@
# Recent Data 使用指南
## 概述
Recent 数据(recentTopics, recentResources, recentPages)存储在 session store 中,可以在应用的任何地方访问。
## 数据初始化
在应用顶层(如 `RecentHydration.tsx`)中初始化所有 recent 数据:
```tsx
import { useInitRecentPage } from '@/hooks/useInitRecentPage';
import { useInitRecentResource } from '@/hooks/useInitRecentResource';
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
const App = () => {
// 初始化所有 recent 数据
useInitRecentTopic();
useInitRecentResource();
useInitRecentPage();
return <YourComponents />;
};
```
## 使用方式
### 方式一:直接从 Store 读取(推荐用于多处使用)
在任何组件中直接访问 store 中的数据:
```tsx
import { useSessionStore } from '@/store/session';
import { recentSelectors } from '@/store/session/selectors';
const Component = () => {
// 读取数据
const recentTopics = useSessionStore(recentSelectors.recentTopics);
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
if (!isInit) return <div>Loading...</div>;
return (
<div>
{recentTopics.map(topic => (
<div key={topic.id}>{topic.title}</div>
))}
</div>
);
};
```
### 方式二:使用 Hook 返回的数据(用于单一组件)
```tsx
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
const Component = () => {
const { data: recentTopics, isLoading } = useInitRecentTopic();
if (isLoading) return <div>Loading...</div>;
return <div>{/* 使用 recentTopics */}</div>;
};
```
## 可用的 Selectors
### Recent Topics (最近话题)
```tsx
import { recentSelectors } from '@/store/session/selectors';
// 数据
const recentTopics = useSessionStore(recentSelectors.recentTopics);
// 类型: RecentTopic[]
// 初始化状态
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
// 类型: boolean
```
**RecentTopic 类型:**
```typescript
interface RecentTopic {
agent: {
avatar: string | null;
backgroundColor: string | null;
id: string;
title: string | null;
} | null;
id: string;
title: string | null;
updatedAt: Date;
}
```
### Recent Resources (最近文件)
```tsx
import { recentSelectors } from '@/store/session/selectors';
// 数据
const recentResources = useSessionStore(recentSelectors.recentResources);
// 类型: FileListItem[]
// 初始化状态
const isInit = useSessionStore(recentSelectors.isRecentResourcesInit);
// 类型: boolean
```
### Recent Pages (最近页面)
```tsx
import { recentSelectors } from '@/store/session/selectors';
// 数据
const recentPages = useSessionStore(recentSelectors.recentPages);
// 类型: any[]
// 初始化状态
const isInit = useSessionStore(recentSelectors.isRecentPagesInit);
// 类型: boolean
```
## 特性
1. **自动登录检测**:只有在用户登录时才会加载数据
2. **数据缓存**:数据存储在 store 中,多处使用无需重复加载
3. **自动刷新**:使用 SWR,在用户重新聚焦时自动刷新(5分钟间隔)
4. **类型安全**:完整的 TypeScript 类型定义
## 最佳实践
1. **初始化位置**:在应用顶层统一初始化所有 recent 数据
2. **数据访问**:使用 selectors 从 store 读取数据
3. **多处使用**:同一数据在多个组件中使用时,推荐使用方式一(直接从 store 读取)
4. **性能优化**:使用 selector 确保只有相关数据变化时才重新渲染
+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
+3 -2
View File
@@ -24,7 +24,7 @@ Desktop.ini
.windsurfrules
*.code-workspace
.vscode/sessions.json
prd
# Temporary files
.temp/
temp/
@@ -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
+1 -1
View File
@@ -1,2 +1,2 @@
npm run typecheck
npm run type-check
npx --no-install lint-staged
+3 -1
View File
@@ -30,7 +30,9 @@ module.exports = defineConfig({
jsonMode: true,
},
markdown: {
reference: '你需要保持 mdx 的组件格式,输出文本不需要在最外层包裹任何代码块语法',
reference:
'你需要保持 mdx 的组件格式,输出文本不需要在最外层包裹任何代码块语法。以下是一些词汇的固定翻译:\n' +
JSON.stringify(require('./glossary.json'), null, 2),
entry: ['./README.zh-CN.md', './contributing/**/*.zh-CN.md', './docs/**/*.zh-CN.mdx'],
entryLocale: 'zh-CN',
outputLocales: ['en-US'],
+3 -2
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
@@ -65,7 +66,7 @@ The project follows a well-organized monorepo structure:
### Type Checking
- Use `bun run typecheck` to check for type errors
- Use `bun run type-check` to check for type errors
### i18n
@@ -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
+10 -3
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,10 +45,12 @@ 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
- use `bun run typecheck` to check type errors.
- use `bun run type-check` to check type errors.
### i18n
@@ -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:
@@ -64,6 +67,10 @@ When working with Linear issues:
3. **Update issue status** when completing tasks using `mcp__linear-server__update_issue`
4. **MUST add completion comment** using `mcp__linear-server__create_comment`
### Creating Issues
When creating new Linear issues using `mcp__linear-server__create_issue`, **MUST add the `claude code` label** to indicate the issue was created by Claude Code.
### Completion Comment (REQUIRED)
**Every time you complete an issue, you MUST add a comment summarizing the work done.** This is critical for:
@@ -79,7 +86,7 @@ When working with Linear issues:
**Workflow for EACH individual issue:**
1. Complete the implementation for this specific issue
2. Run type check: `bun run typecheck`
2. Run type check: `bun run type-check`
3. Run related tests if applicable
4. Create PR if needed
5. **IMMEDIATELY** update issue status to **"In Review"** (NOT "Done"): `mcp__linear-server__update_issue`
+15
View File
@@ -107,6 +107,19 @@ 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
@@ -115,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
+2 -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
@@ -46,7 +47,7 @@ see @.cursor/rules/typescript.mdc
### Typecheck
- use `bun run typecheck` to check type errors.
- use `bun run type-check` to check type errors.
### i18n
+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,
},
};
+5 -5
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
@@ -313,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
+5 -5
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 测试
@@ -302,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',
+19 -3
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,10 +51,12 @@
"@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",
@@ -61,6 +73,7 @@
"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",
@@ -71,9 +84,12 @@
"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",
"stylelint": "^15.11.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"undici": "^7.16.0",
+1
View File
@@ -2,4 +2,5 @@ packages:
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '.'
+2 -2
View File
@@ -12,7 +12,7 @@ export const appBrowsers = {
identifier: 'chat',
keepAlive: true,
minWidth: 400,
path: '/chat',
path: '/agent',
showOnInit: true,
titleBarStyle: 'hidden',
vibrancy: 'under-window',
@@ -72,7 +72,7 @@ export const windowTemplates = {
allowMultipleInstances: true,
autoHideMenuBar: true,
baseIdentifier: 'chatSingle',
basePath: '/chat',
basePath: '/agent',
height: 600,
keepAlive: false, // Multi-instance windows don't need to stay alive
minWidth: 400,
+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,
+1 -1
View File
@@ -563,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)
@@ -42,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) {
+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,
});
}
}
}
@@ -2,9 +2,10 @@ 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, IpcMethod } from './index';
@@ -13,6 +14,54 @@ 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
*/
@@ -45,6 +45,24 @@ export default class RemoteServerConfigCtr extends ControllerModule {
*/
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
*/
@@ -54,12 +72,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
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;
}
/**
@@ -73,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;
}
@@ -88,7 +108,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
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();
@@ -468,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, IpcMethod } from './index';
import { ControllerModule } from './index';
// Create logger
const logger = createLogger('controllers:RemoteServerSyncCtr');
@@ -174,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,
@@ -341,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.
*/
@IpcMethod()
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
}
}
+47 -5
View File
@@ -51,15 +51,44 @@ export default class SystemController extends ControllerModule {
};
}
/**
* 检查可用性
*/
@IpcMethod()
checkAccessibilityForMacOS() {
if (!macOS()) return;
requestAccessibilityAccess() {
if (!macOS()) return true;
return systemPreferences.isTrustedAccessibilityClient(true);
}
@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);
@@ -87,6 +116,19 @@ export default class SystemController extends ControllerModule {
// 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);
}
@IpcMethod()
async getSystemThemeMode() {
return nativeTheme.themeSource;
}
@IpcMethod()
async setSystemThemeMode(themeMode: ThemeMode) {
nativeTheme.themeSource = themeMode === 'auto' ? 'system' : themeMode;
}
/**
@@ -1,38 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
import { ControllerModule, IpcServerMethod } from './index';
export default class SystemServerCtr extends ControllerModule {
static override readonly groupName = 'system';
@IpcServerMethod()
async getDatabasePath() {
return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
}
@IpcServerMethod()
async getDatabaseSchemaHash() {
try {
return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
} catch {
return undefined;
}
}
@IpcServerMethod()
async getUserDataPath() {
return userDataDir;
}
@IpcServerMethod()
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);
}
}
@@ -22,6 +22,7 @@ vi.mock('electron', () => ({
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();
@@ -34,6 +35,7 @@ const mockGetMainWindow = vi.fn(() => ({
toggleVisible: mockToggleVisible,
loadUrl: mockLoadUrl,
show: mockShow,
broadcast: mockBroadcast,
}));
const mockShowOther = vi.fn();
@@ -85,14 +87,18 @@ 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 });
});
@@ -105,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,373 +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: {
handle: vi.fn(),
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();
});
});
});
@@ -139,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');
await invokeIpc('system.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 = await invokeIpc('system.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);
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import SystemServerCtr from '../SystemServerCtr';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock('@/const/dir', () => ({
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
LOCAL_DATABASE_DIR: 'database',
userDataDir: '/mock/user/data',
}));
const mockApp = {
appStoragePath: '/mock/storage',
} as unknown as App;
describe('SystemServerCtr', () => {
let controller: SystemServerCtr;
beforeEach(() => {
vi.clearAllMocks();
controller = new SystemServerCtr(mockApp);
});
it('returns database path', async () => {
await expect(controller.getDatabasePath()).resolves.toBe('/mock/storage/database');
});
it('reads schema hash when file exists', async () => {
const { readFileSync } = await import('node:fs');
vi.mocked(readFileSync).mockReturnValue('hash123');
await expect(controller.getDatabaseSchemaHash()).resolves.toBe('hash123');
expect(readFileSync).toHaveBeenCalledWith('/mock/storage/db-schema-hash.txt', 'utf8');
});
it('returns undefined when schema hash file missing', async () => {
const { readFileSync } = await import('node:fs');
vi.mocked(readFileSync).mockImplementation(() => {
throw new Error('missing');
});
await expect(controller.getDatabaseSchemaHash()).resolves.toBeUndefined();
});
it('returns user data path', async () => {
await expect(controller.getUserDataPath()).resolves.toBe('/mock/user/data');
});
it('writes schema hash to disk', async () => {
const { writeFileSync } = await import('node:fs');
await controller.setDatabaseSchemaHash('newhash');
expect(writeFileSync).toHaveBeenCalledWith(
'/mock/storage/db-schema-hash.txt',
'newhash',
'utf8',
);
});
});
@@ -4,6 +4,7 @@ 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';
@@ -13,7 +14,6 @@ import RemoteServerSyncCtr from './RemoteServerSyncCtr';
import ShellCommandCtr from './ShellCommandCtr';
import ShortcutController from './ShortcutCtr';
import SystemController from './SystemCtr';
import SystemServerCtr from './SystemServerCtr';
import TrayMenuCtr from './TrayMenuCtr';
import UpdaterCtr from './UpdaterCtr';
import UploadFileCtr from './UploadFileCtr';
@@ -24,6 +24,7 @@ export const controllerIpcConstructors = [
BrowserWindowsCtr,
DevtoolsCtr,
LocalFileCtr,
McpCtr,
McpInstallCtr,
MenuController,
NetworkProxyCtr,
@@ -43,7 +44,6 @@ type DesktopControllerServices = CreateServicesResult<DesktopControllerIpcConstr
export type DesktopIpcServices = MergeIpcService<DesktopControllerServices>;
export const controllerServerIpcConstructors = [
SystemServerCtr,
UploadFileServerCtr,
] as const satisfies readonly IpcServiceConstructor[];
+222 -100
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, 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 { LOCAL_DATABASE_DIR, buildDir, 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 { 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,6 +95,29 @@ 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.glob('@/controllers/*Ctr.ts', { eager: true }),
@@ -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');
@@ -272,53 +308,6 @@ export class App {
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);
@@ -362,56 +351,190 @@ 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
logger.warn('Failed to read route variant cookies, using default', error);
return this.defaultRouteVariant;
}
};
}
private registerNextHandler() {
logger.debug('Registering Next.js handler');
const handler = createHandler({
debug: true,
localhostUrl: this.nextServerUrl,
protocol,
standaloneDir: nextStandaloneDir,
});
/**
* 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}`;
// 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}`,
);
// In dev mode without static override, use dev server directly (no variant needed)
if (isDev && !this.rendererStaticOverride) {
return `${this.rendererLoadedUrl}${cleanPath}`;
}
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');
}
// 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() {
@@ -445,6 +568,5 @@ export class App {
// 执行清理操作
this.staticFileServerManager.destroy();
this.unregisterAllRequestHandlers();
};
}
+58 -113
View File
@@ -1,13 +1,10 @@
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', () => ({
app: {
@@ -36,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
@@ -48,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(),
@@ -80,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',
@@ -159,118 +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', () => ({}));
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.glob = vi.fn(() => ({}));
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
});
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();
@@ -280,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');
@@ -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';
}
}
@@ -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);
});
});
+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'),
},
@@ -24,6 +24,17 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
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 () => {
const isAvailable = await searchService.checkSearchServiceStatus();
@@ -40,7 +51,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'package.json search')) return;
if (!ensureResultsOrSkipAssertions(results, 'package.json')) return;
// Should find at least one package.json
const packageJson = results.find((r) => r.name === 'package.json');
@@ -55,7 +66,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
limit: 10,
onlyIn: repoRoot,
});
if (!ensureResults(results, 'README search')) return;
if (!ensureResultsOrSkipAssertions(results, 'README')) return;
// Should contain markdown files
const mdFile = results.find((r) => r.type === 'md');
@@ -70,7 +81,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'TypeScript file search')) return;
if (!ensureResultsOrSkipAssertions(results, 'macOS')) return;
// Should find the macOS.ts implementation file
const macOSFile = results.find((r) => r.name.includes('macOS') && r.type === 'ts');
@@ -112,7 +123,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'test file search')) return;
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'));
@@ -230,7 +241,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'file metadata read')) return;
if (!ensureResultsOrSkipAssertions(results, 'package.json (metadata)')) return;
const file = results[0];
@@ -288,7 +299,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
onlyIn: repoRoot,
});
if (!ensureResults(results, 'fuzzy search accuracy')) return;
if (!ensureResultsOrSkipAssertions(results, 'LocalFile')) return;
// Should find LocalFileCtr.ts or similar files
const found = results.some(
@@ -328,8 +339,8 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
});
// Both searches should find similar files
if (!ensureResults(lowerResults, 'case-insensitive search (lower)')) return;
if (!ensureResults(upperResults, 'case-insensitive search (upper)')) return;
if (!ensureResultsOrSkipAssertions(lowerResults, 'readme')) return;
if (!ensureResultsOrSkipAssertions(upperResults, 'README (case-insensitive)')) return;
});
});
@@ -1,425 +0,0 @@
// copy from https://github.com/kirill-konshin/next-electron-rsc
import { serialize as serializeCookie } from 'cookie';
import { type Protocol, type Session } from 'electron';
// @ts-ignore
import type { NextConfig } from 'next';
// @ts-ignore
import type NextNodeServer from 'next/dist/server/next-server';
import assert from 'node:assert';
import { IncomingMessage, ServerResponse } from 'node:http';
import { Socket } from 'node:net';
import path from 'node:path';
import { parse } from 'node:url';
import resolve from 'resolve';
import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import { isDev } from '@/const/env';
import { createLogger } from '@/utils/logger';
// 创建日志记录器
const logger = createLogger('utils:next-electron-rsc');
// 定义自定义处理器类型
export type CustomRequestHandler = (request: Request) => Promise<Response | null | undefined>;
export const createRequest = async ({
socket,
request,
session,
}: {
request: Request;
session: Session;
socket: Socket;
}): Promise<IncomingMessage> => {
const req = new IncomingMessage(socket);
const url = new URL(request.url);
// Normal Next.js URL does not contain schema and host/port, otherwise endless loops due to butchering of schema by normalizeRepeatedSlashes in resolve-routes
req.url = url.pathname + (url.search || '');
req.method = request.method;
request.headers.forEach((value, key) => {
req.headers[key] = value;
});
try {
// @see https://github.com/electron/electron/issues/39525#issue-1852825052
const cookies = await session.cookies.get({
url: request.url,
// domain: url.hostname,
// path: url.pathname,
// `secure: true` Cookies should not be sent via http
// secure: url.protocol === 'http:' ? false : undefined,
// theoretically not possible to implement sameSite because we don't know the url
// of the website that is requesting the resource
});
if (cookies.length) {
const cookiesHeader = [];
for (const cookie of cookies) {
const { name, value } = cookie;
cookiesHeader.push(serializeCookie(name, value));
}
req.headers.cookie = cookiesHeader.join('; ');
}
} catch (e) {
throw new Error('Failed to parse cookies', { cause: e });
}
if (request.body) {
req.push(Buffer.from(await request.arrayBuffer()));
}
req.push(null);
req.complete = true;
return req;
};
export class ReadableServerResponse extends ServerResponse {
private responsePromise: Promise<Response>;
constructor(req: IncomingMessage) {
super(req);
this.responsePromise = new Promise<Response>((resolve) => {
const readableStream = new ReadableStream({
cancel: () => {},
pull: () => {
this.emit('drain');
},
start: (controller) => {
let onData;
this.on(
'data',
(onData = (chunk) => {
controller.enqueue(chunk);
}),
);
this.once('end', (chunk) => {
controller.enqueue(chunk);
controller.close();
this.off('data', onData);
});
},
});
this.once('writeHead', (statusCode) => {
resolve(
new Response(readableStream, {
headers: this.getHeaders() as any,
status: statusCode,
statusText: this.statusMessage,
}),
);
});
});
}
write(chunk: any, ...args): boolean {
this.emit('data', chunk);
return super.write(chunk, ...args);
}
end(chunk: any, ...args): this {
this.emit('end', chunk);
return super.end(chunk, ...args);
}
writeHead(statusCode: number, ...args: any): this {
this.emit('writeHead', statusCode);
return super.writeHead(statusCode, ...args);
}
getResponse() {
return this.responsePromise;
}
}
/**
* https://nextjs.org/docs/pages/building-your-application/configuring/custom-server
* https://github.com/vercel/next.js/pull/68167/files#diff-d0d8b7158bcb066cdbbeb548a29909fe8dc4e98f682a6d88654b1684e523edac
* https://github.com/vercel/next.js/blob/canary/examples/custom-server/server.ts
*
* @param {string} standaloneDir
* @param {string} localhostUrl
* @param {import('electron').Protocol} protocol
* @param {boolean} debug
*/
export function createHandler({
standaloneDir,
localhostUrl,
protocol,
debug = false,
}: {
debug?: boolean;
localhostUrl: string;
protocol: Protocol;
standaloneDir: string;
}) {
assert(standaloneDir, 'standaloneDir is required');
assert(protocol, 'protocol is required');
// 存储自定义请求处理器的数组
const customHandlers: CustomRequestHandler[] = [];
// 注册自定义请求处理器的方法 - 在开发和生产环境中都提供此功能
function registerCustomHandler(handler: CustomRequestHandler) {
logger.debug('Registering custom request handler');
customHandlers.push(handler);
return () => {
const index = customHandlers.indexOf(handler);
if (index !== -1) {
logger.debug('Unregistering custom request handler');
customHandlers.splice(index, 1);
}
};
}
let registerProtocolHandle = false;
let interceptorCount = 0; // 追踪活跃的拦截器数量
protocol.registerSchemesAsPrivileged([
{
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
},
scheme: 'http',
},
]);
logger.debug('Registered HTTP scheme as privileged');
// 初始化 Next.js 应用(仅在生产环境中使用)
let app: NextNodeServer | null = null;
let handler: any = null;
let preparePromise: Promise<void> | null = null;
if (!isDev) {
logger.info('Initializing Next.js app for production');
// https://github.com/lobehub/lobe-chat/pull/9851
// @ts-ignore
// noinspection JSConstantReassignment
process.env.NODE_ENV = 'production';
const next = require(resolve.sync('next', { basedir: standaloneDir }));
// @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340
const config = require(path.join(standaloneDir, '.next', 'required-server-files.json'))
.config as NextConfig;
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
app = next({ dir: standaloneDir }) as NextNodeServer;
handler = app.getRequestHandler();
preparePromise = app.prepare();
} else {
logger.debug('Starting in development mode');
}
// 通用的请求处理函数 - 开发和生产环境共用
const handleRequest = async (
request: Request,
session: Session,
socket: Socket,
): Promise<Response> => {
try {
// 检查是否是本地文件服务请求,如果是则跳过处理
const url = new URL(request.url);
if (url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/')) {
if (debug) logger.debug(`Skipping local file service request: ${request.url}`);
// 直接使用 fetch 转发请求到本地文件服务
return fetch(request);
}
// 先尝试使用自定义处理器处理请求
for (const customHandler of customHandlers) {
try {
const response = await customHandler(request);
if (response) {
if (debug) logger.debug(`Custom handler processed: ${request.url}`);
return response;
}
} catch (error) {
if (debug) logger.error(`Custom handler error: ${error}`);
// 继续尝试下一个处理器
}
}
// 创建 Node.js 请求对象
const req = await createRequest({ request, session, socket });
// 创建可读取响应的 Response 对象
const res = new ReadableServerResponse(req);
if (isDev) {
// 开发环境:转发请求到开发服务器
if (debug) logger.debug(`Forwarding request to dev server: ${request.url}`);
// 修改 URL 以指向开发服务器
const devUrl = new URL(req.url, localhostUrl);
// 使用 node:http 模块发送请求到开发服务器
const http = require('node:http');
const devReq = http.request(
{
headers: req.headers,
hostname: devUrl.hostname,
method: req.method,
path: devUrl.pathname + (devUrl.search || ''),
port: devUrl.port,
},
(devRes) => {
// 设置响应状态码和头部
res.statusCode = devRes.statusCode;
res.statusMessage = devRes.statusMessage;
// 复制响应头
Object.keys(devRes.headers).forEach((key) => {
res.setHeader(key, devRes.headers[key]);
});
// 流式传输响应内容
devRes.pipe(res);
},
);
// 处理错误
devReq.on('error', (err) => {
if (debug) logger.error(`Error forwarding request: ${err}`);
});
// 传输请求体
req.pipe(devReq);
} else {
// 生产环境:使用 Next.js 处理请求
if (debug) logger.debug(`Processing with Next.js handler: ${request.url}`);
// 确保 Next.js 已准备就绪
if (preparePromise) await preparePromise;
const url = parse(req.url, true);
handler(req, res, url);
}
// 获取 Response 对象
const response = await res.getResponse();
// 处理 cookies(两种环境通用处理)
try {
const cookies = parseCookie(
response.headers.getSetCookie().reduce((r, c) => {
return [...r, ...splitCookiesString(c)];
}, []),
);
for (const cookie of cookies) {
let expirationDate: number | undefined;
if (cookie.expires) {
// expires 是 Date 对象,转换为秒级时间戳
expirationDate = Math.floor(cookie.expires.getTime() / 1000);
} else if (cookie.maxAge) {
// maxAge 是秒数,计算过期时间戳
expirationDate = Math.floor(Date.now() / 1000) + cookie.maxAge;
}
// 如果都没有,则为 session cookie,不设置 expirationDate
// 检查是否已过期
if (expirationDate && expirationDate < Math.floor(Date.now() / 1000)) {
await session.cookies.remove(request.url, cookie.name);
continue;
}
await session.cookies.set({
domain: cookie.domain,
expirationDate,
httpOnly: cookie.httpOnly,
name: cookie.name,
path: cookie.path,
secure: cookie.secure,
url: request.url,
value: cookie.value,
} as any);
}
} catch (e) {
logger.error('Failed to set cookies', e);
}
if (debug) logger.debug(`Request processed: ${request.url}, status: ${response.status}`);
return response;
} catch (e) {
if (debug) logger.error(`Error handling request: ${e}`);
return new Response(e.message, { status: 500 });
}
};
// 创建拦截器函数
const createInterceptor = ({ session }: { session: Session }) => {
assert(session, 'Session is required');
logger.debug(
`Creating interceptor with session in ${isDev ? 'development' : 'production'} mode`,
);
const socket = new Socket();
interceptorCount++; // 增加拦截器计数
const closeSocket = () => socket.end();
process.on('SIGTERM', () => closeSocket);
process.on('SIGINT', () => closeSocket);
if (!registerProtocolHandle) {
logger.debug(
`Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`,
);
protocol.handle('http', async (request) => {
if (!isDev) {
// 检查是否是本地文件服务请求,如果是则允许通过
const isLocalhost = request.url.startsWith(localhostUrl);
const url = new URL(request.url);
const isLocalIP =
request.url.startsWith('http://127.0.0.1:') ||
request.url.startsWith('http://localhost:');
const isLocalFileService = url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/');
const valid = isLocalhost || (isLocalIP && isLocalFileService);
if (!valid) {
throw new Error('External HTTP not supported, use HTTPS');
}
}
return handleRequest(request, session, socket);
});
registerProtocolHandle = true;
}
logger.debug(`Active interceptors count: ${interceptorCount}`);
return function stopIntercept() {
interceptorCount--; // 减少拦截器计数
logger.debug(`Stopping interceptor, remaining count: ${interceptorCount}`);
// 只有当没有活跃的拦截器时才取消注册协议处理器
if (registerProtocolHandle && interceptorCount === 0) {
logger.debug('Unregistering HTTP protocol handler (no active interceptors)');
protocol.unhandle('http');
registerProtocolHandle = false;
}
process.off('SIGTERM', () => closeSocket);
process.off('SIGINT', () => closeSocket);
closeSocket();
};
};
return { createInterceptor, registerCustomHandler };
}
+5
View File
@@ -0,0 +1,5 @@
import { pathToFileURL } from 'node:url';
export const filePathToAppUrl = (filePath: string) => {
return `app://lobehub.com${pathToFileURL(filePath).pathname}`;
};
+22 -5
View File
@@ -51,10 +51,24 @@ describe('setupElectronApi', () => {
});
});
it('should expose lobeEnv with darwinMajorVersion', () => {
setupElectronApi();
const call = mockContextBridgeExposeInMainWorld.mock.calls.find((i) => i[0] === 'lobeEnv');
expect(call).toBeTruthy();
const exposedEnv = call?.[1] as any;
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'darwinMajorVersion')).toBe(true);
expect(
exposedEnv.darwinMajorVersion === undefined ||
typeof exposedEnv.darwinMajorVersion === 'number',
).toBe(true);
});
it('should expose both APIs in correct order', () => {
setupElectronApi();
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(2);
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(3);
// First call should be for 'electron'
expect(mockContextBridgeExposeInMainWorld.mock.calls[0][0]).toBe('electron');
@@ -66,6 +80,9 @@ describe('setupElectronApi', () => {
invoke: mockInvoke,
onStreamInvoke: mockOnStreamInvoke,
});
// Third call should be for 'lobeEnv'
expect(mockContextBridgeExposeInMainWorld.mock.calls[2][0]).toBe('lobeEnv');
});
it('should handle errors when exposing electron API fails', () => {
@@ -77,8 +94,8 @@ describe('setupElectronApi', () => {
setupElectronApi();
expect(consoleErrorSpy).toHaveBeenCalledWith(error);
// Should still try to expose electronAPI even if first one fails
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(2);
// Should still try to expose electronAPI and lobeEnv even if first one fails
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(3);
});
it('should continue execution if exposing electronAPI fails', () => {
@@ -136,7 +153,7 @@ describe('setupElectronApi', () => {
setupElectronApi();
setupElectronApi();
// Should be called 4 times total (2 per setup call)
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(4);
// Should be called 6 times total (3 per setup call)
expect(mockContextBridgeExposeInMainWorld).toHaveBeenCalledTimes(6);
});
});
+8
View File
@@ -19,4 +19,12 @@ export const setupElectronApi = () => {
invoke,
onStreamInvoke,
});
const os = require('node:os');
const osInfo = os.release();
const darwinMajorVersion = osInfo.split('.')[0];
contextBridge.exposeInMainWorld('lobeEnv', {
darwinMajorVersion: Number(darwinMajorVersion),
});
};
+3 -2
View File
@@ -1,7 +1,8 @@
import { DispatchInvoke } from '@lobechat/electron-client-ipc';
import { ipcRenderer } from 'electron';
type IpcInvoke = <T = unknown>(event: string, ...data: unknown[]) => Promise<T>;
/**
* Client-side method to invoke electron main process
*/
export const invoke: DispatchInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
export const invoke: IpcInvoke = async (event, ...data) => ipcRenderer.invoke(event, ...data);
+11 -11
View File
@@ -1,4 +1,4 @@
import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
import type { StreamInvokeRequestParams } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock electron module
@@ -29,7 +29,7 @@ describe('onStreamInvoke', () => {
});
it('should set up stream listeners and send start event', () => {
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: { 'content-type': 'application/json' },
method: 'POST',
urlPath: '/trpc/lambda/test.endpoint',
@@ -77,7 +77,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -105,7 +105,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -137,7 +137,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -178,7 +178,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -220,7 +220,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -254,7 +254,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -289,7 +289,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
body: JSON.stringify({
filters: { active: true },
query: 'complex query',
@@ -316,7 +316,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
@@ -346,7 +346,7 @@ describe('onStreamInvoke', () => {
onResponse: vi.fn(),
};
const params: ProxyTRPCRequestParams = {
const params: StreamInvokeRequestParams = {
headers: {},
method: 'GET',
urlPath: '/trpc/test',
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
import type { StreamInvokeRequestParams } from '@lobechat/electron-client-ipc';
import { ipcRenderer } from 'electron';
import { v4 as uuid } from 'uuid';
@@ -21,7 +21,7 @@ export interface StreamerCallbacks {
* @param callbacks The callbacks to handle stream events.
*/
export const onStreamInvoke = (
params: ProxyTRPCRequestParams,
params: StreamInvokeRequestParams,
callbacks: StreamerCallbacks,
): (() => void) => {
const requestId = uuid();
+18 -4
View File
@@ -3,7 +3,9 @@
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": false,
"target": "ESNext",
"declaration": true,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
@@ -13,9 +15,21 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/main/*"],
"~common/*": ["./src/common/*"]
"@/*": [
"./src/main/*"
],
"~common/*": [
"./src/common/*"
],
"*": [
"./*"
]
}
},
"include": ["src/main/**/*", "src/preload/**/*", "src/common/**/*", "electron-builder.js"]
}
"include": [
"src/main/**/*",
"src/preload/**/*",
"src/common/**/*",
"electron-builder.js"
]
}
+33
View File
@@ -0,0 +1,33 @@
---
title: >-
LobeHub 2.0 is Here
description: >-
LobeHub 2.0 is here, bringing a new level of AI collaboration and productivity.
tags:
- LobeHub
- AI Collaboration
- Productivity
---
# LobeHub 2.0 is Here 🎉
After nearly 10 days of meticulous refinement, LobeChat has fully integrated the DeepSeek R1 model in version v1.49.12, offering users a revolutionary interactive experience in the chain of thought!
## 🚀 Major Updates
- 🤯 **Comprehensive Support for DeepSeek R1**: Now fully integrated in both the Community and Cloud versions ([lobechat.com](https://lobechat.com)).
- 🧠 **Real-Time Chain of Thought Display**: Transparently presents the AI's reasoning process, making the resolution of complex issues clear and visible.
- ⚡️ **Deep Thinking Experience**: Utilizing Chain of Thought technology, it provides more insightful AI conversations.
- 💫 **Intuitive Problem Analysis**: Makes the analysis of complex issues clear and easy to understand.
## 🌟 How to Use
1. Upgrade to LobeChat v1.49.12 or visit [lobechat.com](https://lobechat.com).
2. Select the DeepSeek R1 model in the settings.
3. Experience a whole new level of intelligent conversation!
## 📢 Feedback and Support
If you encounter any issues while using the application or have suggestions for new features, feel free to engage with us through GitHub Discussions. Let's work together to create a better LobeChat!
+33
View File
@@ -0,0 +1,33 @@
---
title: >-
LobeHub 2.0 is Here
description: >-
LobeHub 2.0 is here, bringing a new level of AI collaboration and productivity.
tags:
- LobeHub
- AI Collaboration
- Productivity
---
# LobeHub 2.0 is Here 🎉
After nearly 10 days of meticulous refinement, LobeChat has fully integrated the DeepSeek R1 model in version v1.49.12, offering users a revolutionary interactive experience in the chain of thought!
## 🚀 Major Updates
- 🤯 **Comprehensive Support for DeepSeek R1**: Now fully integrated in both the Community and Cloud versions ([lobechat.com](https://lobechat.com)).
- 🧠 **Real-Time Chain of Thought Display**: Transparently presents the AI's reasoning process, making the resolution of complex issues clear and visible.
- ⚡️ **Deep Thinking Experience**: Utilizing Chain of Thought technology, it provides more insightful AI conversations.
- 💫 **Intuitive Problem Analysis**: Makes the analysis of complex issues clear and easy to understand.
## 🌟 How to Use
1. Upgrade to LobeChat v1.49.12 or visit [lobechat.com](https://lobechat.com).
2. Select the DeepSeek R1 model in the settings.
3. Experience a whole new level of intelligent conversation!
## 📢 Feedback and Support
If you encounter any issues while using the application or have suggestions for new features, feel free to engage with us through GitHub Discussions. Let's work together to create a better LobeChat!
+6
View File
@@ -2,6 +2,12 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "https://github.com/user-attachments/assets/5fe4c373-ebd0-42a9-bdca-0ab7e0a2e747",
"id": "2025-12-15-V2",
"date": "2025-12-15",
"versionRange": ["1.47.8", "1.49.12"]
},
{
"image": "https://github.com/user-attachments/assets/5fe4c373-ebd0-42a9-bdca-0ab7e0a2e747",
"id": "2025-02-02-deepseek-r1",
+2 -2
View File
@@ -1089,7 +1089,9 @@ table users {
first_name text
last_name text
full_name text
occupation text
is_onboarded boolean [default: false]
onboarding jsonb
clerk_created_at "timestamp with time zone"
email_verified boolean [not null, default: false]
email_verified_at "timestamp with time zone"
@@ -1150,7 +1152,6 @@ table user_memories_contexts {
associated_objects jsonb
associated_subjects jsonb
title text
title_vector vector(1024)
description text
description_vector vector(1024)
type varchar(255)
@@ -1163,7 +1164,6 @@ table user_memories_contexts {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
title_vector [name: 'user_memories_contexts_title_vector_index']
description_vector [name: 'user_memories_contexts_description_vector_index']
type [name: 'user_memories_contexts_type_index']
user_id [name: 'user_memories_contexts_user_id_index']
+8
View File
@@ -0,0 +1,8 @@
{
"助手": {
"en-US": "Agent"
},
"文稿": {
"en-US": "Page"
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { KnipConfig } from 'knip';
const config: KnipConfig = {
entry: ['src/app/**/*.ts{x,}'],
ignore: [
// Test files
'src/**/__tests__/**',
'src/**/*.test.ts{x,}',
'src/**/*.spec.ts{x,}',
// Other directories
'packages/**',
'e2e/**',
'scripts/**',
// Config files
'*.config.{js,ts,mjs,cjs}',
'next-env.d.ts',
],
ignoreDependencies: [],
ignoreExportsUsedInFile: true,
project: ['src/**/*.ts{x,}'],
};
export default config;
+26 -4
View File
@@ -136,7 +136,7 @@
"passwordPlaceholder": "يرجى إدخال كلمة المرور",
"signinLink": "تسجيل الدخول الآن",
"submit": "تسجيل",
"subtitle": "انضم إلى مجتمع LobeChat",
"subtitle": "ابدأ مساحة التعاون الخاصة بـ Agents",
"success": "تم التسجيل بنجاح! يرجى التحقق من بريدك الإلكتروني لتأكيد الحساب",
"title": "إنشاء حساب",
"usernamePlaceholder": "يرجى إدخال اسم المستخدم"
@@ -144,8 +144,7 @@
"verifyEmail": {
"backToSignIn": "العودة إلى تسجيل الدخول",
"checkSpam": "إذا لم تتلقَ البريد الإلكتروني، يرجى التحقق من مجلد الرسائل غير المرغوب فيها",
"descriptionPrefix": "لقد أرسلنا رسالة تحقق إلى",
"descriptionSuffix": "",
"description": "تم إرسال رسالة تحقق إلى {{email}}",
"resend": {
"button": "إعادة إرسال رسالة التحقق",
"error": "فشل الإرسال، يرجى المحاولة لاحقًا",
@@ -159,6 +158,11 @@
"prevMonth": "الشهر الماضي",
"recent30Days": "آخر 30 يومًا"
},
"footer": {
"agreement": "بالمتابعة، فإنك تؤكد أنك قد قرأت ووافقت على <terms>الشروط والأحكام</terms> و<privacy>سياسة الخصوصية</privacy>",
"privacy": "سياسة الخصوصية",
"terms": "شروط الخدمة"
},
"header": {
"desc": "إدارة معلومات حسابك.",
"title": "الحساب"
@@ -188,12 +192,25 @@
"login": "تسجيل الدخول",
"loginOrSignup": "تسجيل الدخول / الاشتراك",
"profile": {
"authorizations": {
"actions": {
"revoke": "إلغاء التفويض"
},
"revoke": {
"description": "بعد إلغاء التفويض، لن يتمكن هذا التطبيق من الوصول إلى بياناتك. لإعادة استخدامه، ستحتاج إلى منحه التفويض مرة أخرى.",
"title": "هل أنت متأكد من إلغاء التفويض لـ {{name}}؟"
},
"title": "إدارة التفويضات"
},
"avatar": "الصورة الشخصية",
"cancel": "إلغاء",
"changePassword": "إعادة تعيين كلمة المرور",
"email": "عنوان البريد الإلكتروني",
"fullName": "الاسم الكامل",
"fullNameInputHint": "يرجى إدخال الاسم الكامل الجديد",
"occupation": "المهنة",
"occupationInputHint": "يرجى إدخال مهنتك",
"occupationPlaceholder": "مثال: مهندس برمجيات، مدير منتج، مصمم",
"password": "كلمة المرور",
"resetPasswordError": "فشل إرسال رابط إعادة تعيين كلمة المرور",
"resetPasswordSent": "تم إرسال رابط إعادة تعيين كلمة المرور، يرجى التحقق من بريدك الإلكتروني",
@@ -215,6 +232,7 @@
"title": "تفاصيل الملف الشخصي",
"updateAvatar": "تحديث الصورة الشخصية",
"updateFullName": "تحديث الاسم الكامل",
"updateOccupation": "تحديث المهنة",
"updateUsername": "تحديث اسم المستخدم",
"username": "اسم المستخدم",
"usernameDuplicate": "اسم المستخدم مستخدم بالفعل",
@@ -224,6 +242,10 @@
"usernameRule": "اسم المستخدم يجب أن يحتوي فقط على أحرف أو أرقام أو شرطة سفلية",
"usernameUpdateFailed": "فشل في تحديث اسم المستخدم، يرجى المحاولة لاحقًا"
},
"signin": {
"subtitle": "سجّل أو قم بتسجيل الدخول إلى حسابك في {{appName}}",
"title": "مساحة التعاون الخاصة بك في Agents"
},
"signout": "تسجيل الخروج",
"signup": "الاشتراك",
"stats": {
@@ -269,7 +291,7 @@
},
"tab": {
"apikey": "إدارة مفاتيح API",
"profile": "الملف الشخصي",
"profile": "حسابي",
"security": "الأمان",
"stats": "الإحصائيات",
"usage": "إحصاءات الاستخدام"
+66 -16
View File
@@ -3,6 +3,23 @@
"title": "النموذج"
},
"active": "نشط",
"agentBuilder": {
"installPlugin": {
"authRequired": "يتطلب مكون MCP السحابي تسجيل الدخول والمصادقة",
"cancel": "إلغاء",
"clickApproveToConnect": "انقر على \"الموافقة\" للاتصال وتفويض هذا التكامل",
"clickApproveToInstall": "انقر على \"الموافقة\" لتثبيت هذا المكون الإضافي",
"connectedAndEnabled": "تم الاتصال والتفعيل",
"connectionFailed": "فشل الاتصال",
"installFailed": "فشل التثبيت",
"installPlugin": "تثبيت المكون الإضافي",
"installToEnable": "قم بتثبيت هذا المكون الإضافي لتمكين المساعد",
"installedAndEnabled": "تم التثبيت والتفعيل",
"requiresAuth": "يتطلب تفويضًا، انقر على \"الموافقة\" للاتصال",
"retry": "إعادة المحاولة"
},
"welcome": "مرحبًا، أنا **Lobe AI**، خبير إعداد مساعدك الشخصي. أخبرني بنوع المساعد الذي تريده، وسأقوم بإعداده لك."
},
"agentDefaultMessage": "مرحبًا، أنا **{{name}}**، يمكنك بدء المحادثة معي على الفور، أو يمكنك الذهاب إلى [إعدادات المساعد]({{url}}) لإكمال معلوماتي.",
"agentDefaultMessageWithSystemRole": "مرحبًا، أنا **{{name}}**، كيف يمكنني مساعدتك؟",
"agentDefaultMessageWithoutEdit": "مرحبًا، أنا **{{name}}**، كيف يمكنني مساعدتك؟",
@@ -16,19 +33,20 @@
},
"availableAgents": "المساعدون المتاحون",
"backToBottom": "العودة إلى الأسفل",
"builtinCopilot": "المساعد المدمج",
"chatList": {
"expandMessage": "عرض الرسائل",
"longMessageDetail": "عرض التفاصيل"
},
"clearCurrentMessages": "مسح رسائل الجلسة الحالية",
"confirmClearCurrentMessages": "سيتم مسح رسائل الجلسة الحالية قريبًا، وبمجرد المسح لن يمكن استعادتها، يرجى تأكيد الإجراء الخاص بك",
"confirmRemoveChatGroupItemAlert": "سيتم حذف فريق الوكيل هذا، ولن يتأثر الأعضاء الآخرون. يرجى تأكيد الإجراء.",
"confirmRemoveChatGroupItemAlert": "سيتم حذف هذه المجموعة، ولن يتأثر أعضاء الفريق. يرجى تأكيد الإجراء.",
"confirmRemoveGroupItemAlert": "سيتم حذف هذه المجموعة قريبًا. بعد الحذف، سيُنتقل المساعدون في هذه المجموعة إلى القائمة الافتراضية. يرجى تأكيد إجراء الحذف.",
"confirmRemoveGroupSuccess": "تم حذف فريق الوكلاء بنجاح",
"confirmRemoveGroupSuccess": "تم حذف المجموعة بنجاح",
"confirmRemoveSessionItemAlert": "سيتم حذف هذا المساعد قريبًا، وبمجرد الحذف لن يمكن استعادته، يرجى تأكيد الإجراء الخاص بك",
"confirmRemoveSessionSuccess": "تم حذف المساعد بنجاح",
"defaultAgent": "المساعد الافتراضي",
"defaultGroupChat": "فريق الوكلاء",
"defaultGroupChat": "مجموعة",
"defaultList": "القائمة الافتراضية",
"defaultSession": "المساعد الافتراضي",
"dm": {
@@ -44,6 +62,7 @@
},
"duplicateTitle": "{{title}} نسخة",
"emptyAgent": "لا يوجد مساعد",
"emptyAgentAction": "إنشاء مساعد",
"extendParams": {
"disableContextCaching": {
"desc": "يمكن تقليل تكلفة توليد محادثة واحدة بنسبة تصل إلى 90%، وزيادة سرعة الاستجابة بمقدار 4 مرات (<1>اعرف المزيد</1>). عند التفعيل، سيتم تعطيل حد عدد الرسائل التاريخية تلقائيًا",
@@ -89,8 +108,13 @@
},
"groupDescription": "وصف الفريق",
"groupSidebar": {
"agentProfile": {
"chat": "الدردشة",
"model": "النموذج"
},
"members": {
"addMember": "إضافة عضو",
"enableOrchestrator": "تفعيل المنسق",
"memberSettings": "إعدادات العضو",
"orchestrator": "المُنسق",
"orchestratorThinking": "المُنسق يفكر...",
@@ -120,7 +144,7 @@
"noTemplateMembers": "لا يوجد أعضاء في القالب",
"noTemplates": "لا توجد قوالب متاحة",
"searchTemplates": "ابحث في القوالب...",
"title": "إنشاء فريق وكلاء",
"title": "إنشاء مجموعة",
"useTemplate": "استخدام القالب"
},
"hideForYou": "تم إخفاء محتوى الرسائل الخاصة، يرجى تفعيل خيار 【عرض محتوى الرسائل الخاصة】 في الإعدادات للعرض",
@@ -154,28 +178,29 @@
"knowledgeBase": {
"all": "جميع المحتويات",
"allFiles": "جميع الملفات",
"allKnowledgeBases": "جميع قواعد المعرفة",
"disabled": "الوضع الحالي للنشر لا يدعم محادثات قاعدة المعرفة. إذا كنت بحاجة إلى استخدامها، يرجى التبديل إلى نشر قاعدة البيانات على الخادم أو استخدام خدمة {{cloud}}.",
"allLibraries": "جميع قواعد البيانات",
"disabled": "وضع النشر الحالي لا يدعم المحادثة مع قاعدة البيانات. لاستخدام هذه الميزة، يرجى التبديل إلى نشر قاعدة بيانات على الخادم أو استخدام خدمة {{cloud}}",
"library": {
"action": {
"add": "إضافة",
"detail": "تفاصيل",
"remove": "إزالة"
},
"title": "الملفات/قاعدة المعرفة"
"title": "الملفات / قاعدة البيانات"
},
"relativeFilesOrKnowledgeBases": "ملفات/قواعد معرفة مرتبطة",
"title": "قاعدة المعرفة",
"uploadGuide": "يمكنك عرض الملفات التي تم تحميلها في «قاعدة المعرفة»",
"relativeFilesOrLibraries": "الملفات / قواعد البيانات المرتبطة",
"title": "قاعدة البيانات",
"uploadGuide": "يمكنك عرض الملفات التي تم تحميلها في قسم \"الموارد\"",
"viewMore": "عرض المزيد"
},
"memberSelection": {
"addMember": "إضافة عضو",
"allMembers": "جميع الأعضاء",
"createGroup": "إنشاء فريق وكيل",
"createGroup": "إنشاء مجموعة",
"noAvailableAgents": "لا يوجد وكلاء متاحون للدعوة",
"noSelectedAgents": "لم يتم اختيار أي وكيل بعد",
"searchAgents": "ابحث عن وكيل...",
"selectedAgents": "تم الاختيار ({{count}})",
"setInitialMembers": "اختيار أعضاء الفريق"
},
"members": "الأعضاء",
@@ -245,14 +270,15 @@
"senderAssistant": "الوكيل",
"senderUser": "أنت"
},
"newAgent": "مساعد جديد",
"newGroupChat": "إنشاء فريق وكلاء جديد",
"noAgentsYet": "لا يوجد أعضاء في هذا الفريق بعد. انقر على زر + لدعوة مساعد.",
"newAgent": "إنشاء مساعد",
"newGroupChat": "إنشاء مجموعة",
"newPage": "إنشاء مستند",
"noAgentsYet": "لا يوجد أعضاء في هذه المجموعة بعد. انقر على زر + لدعوة مساعد.",
"noAvailableAgents": "لا يوجد أعضاء متاحون للدعوة",
"noMatchingAgents": "لا يوجد أعضاء مطابقون",
"noMembersYet": "لا يوجد أعضاء في هذه المجموعة بعد. انقر على زر + لدعوة المساعدين.",
"noSelectedAgents": "لم يتم اختيار أي أعضاء بعد",
"openInNewWindow": "افتح الصفحة في نافذة جديدة",
"openInNewWindow": "فتح في نافذة مستقلة",
"owner": "مالك المجموعة",
"pin": "تثبيت",
"pinOff": "إلغاء التثبيت",
@@ -296,7 +322,7 @@
"searchAgentPlaceholder": "مساعد البحث...",
"searchAgents": "مساعد البحث...",
"selectedAgents": "المساعدون المختارون",
"sendPlaceholder": "أدخل محتوى الدردشة...",
"sendPlaceholder": "اطرح سؤالًا، أنشئ محتوى، أو ابدأ مهمة، <hotkey><hotkey/>",
"sessionGroup": {
"config": "إدارة المجموعات",
"confirmRemoveGroupAlert": "سيتم حذف هذه المجموعة قريبًا، وبعد الحذف، سيتم نقل مساعدي هذه المجموعة إلى القائمة الافتراضية، يرجى تأكيد إجراءك",
@@ -306,11 +332,17 @@
"createGroupSuccess": "تم إنشاء المحادثة الجماعية بنجاح",
"createSuccess": "تم الإنشاء بنجاح",
"creatingAgent": "جاري إنشاء المساعد...",
"groupName": "اسم المجموعة",
"inputPlaceholder": "الرجاء إدخال اسم المجموعة...",
"moveGroup": "نقل إلى مجموعة",
"newGroup": "مجموعة جديدة",
"noAvailableAgents": "لا يوجد مساعدين متاحين حالياً",
"noMatchingAgents": "لم يتم العثور على مساعدين مطابقين",
"noSelectedAgents": "يرجى اختيار مساعدين",
"rename": "إعادة تسمية المجموعة",
"renameSuccess": "تمت إعادة التسمية بنجاح",
"searchAgents": "البحث عن مساعدين",
"selectedAgents": "المساعدون المختارون ({{count}})",
"sortSuccess": "تمت إعادة ترتيب بنجاح",
"sorting": "جاري تحديث ترتيب المجموعة...",
"tooLong": "يجب أن يكون طول اسم المجموعة بين 1 و 20"
@@ -361,6 +393,11 @@
"title": "المهام المنجزة"
}
},
"tab": {
"groupProfile": "ملف المجموعة",
"profile": "ملف المساعد",
"search": "بحث"
},
"thread": {
"divider": "موضوع فرعي",
"threadMessageCount": "{{messageCount}} رسالة",
@@ -409,10 +446,18 @@
"toolRejected": "تم رفض استدعاء الأداة هذه المرة بشكل يدوي"
}
},
"toolAuth": {
"authorize": "تفويض",
"authorizing": "جارٍ التفويض...",
"hint": "في حال عدم التفويض أو الإعداد، قد لا تعمل هذه الأدوات بشكل صحيح، مما قد يؤدي إلى فقدان وظائف المساعد أو حدوث أخطاء.",
"signIn": "تسجيل الدخول",
"title": "يستخدم هذا المساعد الأدوات التالية التي تتطلب التفويض"
},
"topic": {
"checkOpenNewTopic": "هل ترغب في فتح موضوع جديد؟",
"checkSaveCurrentMessages": "هل ترغب في حفظ الدردشة الحالية كموضوع؟",
"openNewTopic": "فتح موضوع جديد",
"recent": "المواضيع الأخيرة",
"saveCurrentMessages": "حفظ الجلسة الحالية كموضوع"
},
"translate": {
@@ -424,6 +469,7 @@
"clear": "مسح الصوت"
},
"untitledAgent": "مساعد بدون اسم",
"untitledGroup": "مجموعة بدون عنوان",
"updateAgent": "تحديث معلومات المساعد",
"upload": {
"action": {
@@ -451,6 +497,10 @@
"videoSizeExceeded": "لا يمكن أن يتجاوز حجم ملف الفيديو 20 ميغابايت، حجم الملف الحالي هو {{actualSize}}"
}
},
"viewMode": {
"normal": "عادي",
"wideScreen": "شاشة عريضة"
},
"you": "أنت",
"zenMode": "وضع التركيز"
}
+41 -4
View File
@@ -137,14 +137,36 @@
"close": "إغلاق",
"cmdk": {
"about": "حول",
"aiModeEmptyState": "أدخل سؤالك في الحقل أعلاه لبدء المحادثة مع الذكاء الاصطناعي",
"aiModePlaceholder": "اطرح سؤالاً على الذكاء الاصطناعي...",
"communitySupport": "دعم المجتمع",
"discover": "استكشاف",
"knowledgeBase": "قاعدة المعرفة",
"memory": "الذاكرة",
"navigate": "التنقل",
"newAgent": "إنشاء مساعد جديد",
"noResults": "لم يتم العثور على نتائج",
"openSettings": "فتح الإعدادات",
"painting": "الرسم بالذكاء الاصطناعي",
"pages": "المستندات",
"painting": "الرسم",
"resource": "الموارد",
"search": {
"agent": "مساعد",
"agents": "مساعدون",
"assistant": "مساعد الذكاء الاصطناعي",
"assistants": "مساعدو الذكاء الاصطناعي",
"file": "ملف",
"files": "ملفات",
"loading": "جارٍ البحث...",
"mcp": "خادم MCP",
"mcps": "خوادم MCP",
"message": "المحادثة",
"messages": "المحادثات",
"plugin": "الملحق",
"plugins": "الملحقات",
"searching": "نتائج البحث",
"topic": "موضوع",
"topics": "مواضيع"
},
"searchPlaceholder": "أدخل أمرًا أو ابحث...",
"settings": "الإعدادات",
"starOnGitHub": "قيّمنا على GitHub",
@@ -304,6 +326,13 @@
"business": "شراكات تجارية",
"support": "الدعم عبر البريد الإلكتروني"
},
"navPanel": {
"agent": "المساعد",
"displayItems": "عرض العناصر",
"library": "المكتبة",
"searchAgent": "بحث عن مساعد...",
"searchResultEmpty": "لا توجد نتائج بحث"
},
"new": "جديد",
"oauth": "تسجيل الدخول SSO",
"officialSite": "الموقع الرسمي",
@@ -358,13 +387,21 @@
}
},
"tab": {
"aiImage": "الرسم بالذكاء الاصطناعي",
"aiImage": "رسم",
"audio": "الصوت",
"chat": "الدردشة",
"community": "المجتمع",
"discover": "اكتشاف",
"files": "ملفات",
"home": "الصفحة الرئيسية",
"knowledgeBase": "قاعدة المعرفة",
"me": "أنا",
"setting": "الإعدادات"
"memory": "الذاكرة",
"pages": "المستندات",
"resource": "الموارد",
"search": "البحث",
"setting": "الإعدادات",
"video": "الفيديو"
},
"telemetry": {
"allow": "السماح",
+20 -2
View File
@@ -19,6 +19,7 @@
"chunkingTooltip": "قم بتقسيم الملف إلى عدة كتل نصية وتحويلها إلى متجهات، يمكن استخدامها في البحث الدلالي والمحادثة حول الملفات",
"chunkingUnsupported": "هذا الملف لا يدعم تقسيم الأجزاء",
"confirmDelete": "سيتم حذف هذا الملف، ولن يمكن استعادته بعد الحذف، يرجى تأكيد العملية",
"confirmDeleteFolder": "سيتم حذف هذا المجلد وجميع محتوياته، ولن يكون بالإمكان استعادته بعد الحذف. يرجى تأكيد العملية.",
"confirmDeleteMultiFiles": "سيتم حذف {{count}} ملفًا محددًا، ولن يمكن استعادته بعد الحذف، يرجى تأكيد العملية",
"confirmRemoveFromKnowledgeBase": "سيتم إزالة {{count}} ملفًا محددًا من قاعدة المعرفة، لا يزال بإمكانك رؤية الملفات في جميع الملفات، يرجى تأكيد العملية",
"copyUrl": "نسخ الرابط",
@@ -26,8 +27,19 @@
"createChunkingTask": "جارٍ التحضير...",
"deleteSuccess": "تم حذف الملف بنجاح",
"downloading": "جارٍ تحميل الملف...",
"goBack": "العودة إلى الصفحة السابقة",
"goForward": "الانتقال إلى الصفحة التالية",
"goToParent": "الانتقال إلى المجلد الرئيسي",
"moveError": "فشل في نقل الملف",
"moveHere": "نقل إلى هنا",
"moveSuccess": "تم نقل الملف بنجاح",
"moveToFolder": "نقل إلى...",
"moveToRoot": "نقل إلى الدليل الجذري",
"removeFromKnowledgeBase": "إزالة من قاعدة المعرفة",
"removeFromKnowledgeBaseSuccess": "تمت إزالة الملف بنجاح"
"removeFromKnowledgeBaseSuccess": "تمت إزالة الملف بنجاح",
"rename": "إعادة التسمية",
"renameError": "فشل في إعادة التسمية",
"renameSuccess": "تمت إعادة التسمية بنجاح"
},
"bottom": "لقد وصلت إلى النهاية",
"config": {
@@ -42,6 +54,12 @@
"or": "أو",
"title": "قم بسحب الملف أو المجلد هنا"
},
"noFolders": "لا توجد مجلدات حالياً",
"sort": {
"dateAdded": "تاريخ الإضافة",
"name": "الاسم",
"size": "الحجم"
},
"title": {
"createdAt": "تاريخ الإنشاء",
"size": "الحجم",
@@ -164,7 +182,7 @@
"OllamaSetupGuide": {
"action": {
"close": "إغلاق الإشعار",
"start": "تم التثبيت والتشغيل، ابدأ المحادثة"
"start": "تم التثبيت"
},
"cors": {
"description": "بسبب قيود أمان المتصفح، تحتاج إلى تكوين CORS لـ Ollama لاستخدامه بشكل صحيح.",
+25 -10
View File
@@ -58,11 +58,12 @@
"title": "سجل الإصدارات"
}
},
"downloads": "عدد التنزيلات",
"list": "قائمة المساعدين",
"marketSource": {
"label": "تبديل مصدر السوق",
"legacy": "السوق القديم",
"new": "السوق الجديد"
"label": "تبديل مصدر المجتمع",
"legacy": "المجتمع القديم",
"new": "المجتمع الجديد"
},
"more": "المزيد",
"plugins": "دمج الإضافات",
@@ -85,7 +86,7 @@
"subtitle": "المساعد الذي تحاول الوصول إليه تم أرشفته للأسباب التالية المحتملة:",
"title": "تم أرشفة المساعد"
},
"backToMarket": "العودة إلى سوق المساعدين",
"backToMarket": "العودة إلى مجتمع المساعد",
"deprecated": {
"reasons": {
"official": "تمت إزالة المساعد من قبل الإدارة لأسباب أمنية أو سياسية",
@@ -94,9 +95,9 @@
"subtitle": "المساعد الذي تحاول الوصول إليه تم رفضه للأسباب التالية المحتملة:",
"title": "تم رفض المساعد"
},
"support": "إذا واجهت أي مشاكل، يرجى نسخ الرابط وإرساله إلى <1>support@lobehub.com</1> للاستفسار.",
"support": "لأي استفسارات، يرجى نسخ الرابط وإرساله إلى <email>support@lobehub.com</email>.",
"unpublished": {
"subtitle": "المساعد الذي تحاول الوصول إليه قيد المراجعة حاليًا. إذا كان لديك أي استفسار، يرجى نسخ الرابط وإرساله إلى <1>support@lobehub.com</1>.",
"subtitle": "المساعد الذي تحاول الوصول إليه يخضع حاليًا لمراجعة الإصدار. إذا كان لديك أي استفسار، يرجى نسخ الرابط وإرساله إلى <email>support@lobehub.com</email>.",
"title": "المساعد قيد المراجعة"
}
},
@@ -144,7 +145,7 @@
"createGuide": {
"func1": {
"desc1": "ادخل إلى صفحة إعداد المساعد الذي ترغب في تقديمه من خلال الإعدادات في الزاوية العليا اليمنى من نافذة المحادثة;",
"desc2": "انقر على زر التقديم إلى سوق المساعدين في الزاوية العليا اليمنى.",
"desc2": "انقر على زر الإرسال إلى مجتمع المساعد في الزاوية اليمنى العليا.",
"tag": "الطريقة الأولى",
"title": "تقديم عبر LobeChat"
},
@@ -186,8 +187,10 @@
}
},
"home": {
"communityAgents": "مساعدو المجتمع",
"featuredAssistants": "مساعدون مميزون",
"featuredModels": "نماذج مميزة",
"featuredPlugins": "الإضافات المميزة",
"featuredProviders": "مزودو نماذج مميزون",
"featuredTools": "إضافات مميزة",
"more": "اكتشف المزيد"
@@ -516,7 +519,7 @@
"hero": {
"desc": "منصة MCP Servers مفتوحة المصدر وقابلة للنشر، تساعد أنظمة الذكاء الاصطناعي على الوصول بسهولة إلى أنظمة الملفات، قواعد البيانات، واجهات برمجة التطبيقات وغيرها من الموارد الحيوية، لتوسيع قدرات الذكاء الاصطناعي الخاصة بك بشكل شامل.",
"subTitle": "مفتوح المصدر وجاهز للاستخدام",
"title": "سوق MCP مفتوح المصدر للذكاء الاصطناعي"
"title": "مجتمع MCP مفتوح المصدر للذكاء الاصطناعي"
},
"sorts": {
"createdAt": "أضيف مؤخراً",
@@ -529,7 +532,7 @@
"toolsCount": "عدد الأدوات",
"updatedAt": "تم التحديث مؤخراً"
},
"title": "سوق MCP",
"title": "مجتمع MCP",
"unvalidated": {
"desc": "هذا الخادم MCP لم يتم التحقق منه بعد",
"title": "غير مُحقق"
@@ -690,6 +693,18 @@
"home": "الصفحة الرئيسية",
"model": "النموذج",
"plugin": "الإضافة",
"provider": "مزود النموذج"
"provider": "مزود النموذج",
"user": "المستخدم"
},
"user": {
"agents": "المساعدون",
"downloads": "التنزيلات",
"editProfile": "تعديل الملف الشخصي",
"login": "كن منشئًا",
"logout": "تسجيل الخروج",
"myProfile": "صفحتي الشخصية",
"noAgents": "لم يقم هذا المستخدم بنشر أي مساعدين بعد",
"publishedAgents": "المساعدون الذين أنشأتهم",
"website": "الموقع الشخصي"
}
}
+23 -1
View File
@@ -9,6 +9,11 @@
"on": "إظهار شريط أدوات التنسيق"
}
},
"autoSave": {
"latest": "تم تحميل أحدث إصدار",
"saved": "تم الحفظ",
"saving": "يتم الحفظ تلقائيًا..."
},
"cancel": "إلغاء",
"confirm": "تأكيد",
"file": {
@@ -20,13 +25,27 @@
},
"link": {
"edit": "تعديل الرابط",
"editLinkTitle": "الرابط",
"editTextTitle": "العنوان",
"open": "فتح الرابط",
"placeholder": "أدخل عنوان URL للرابط",
"unlink": "إزالة الرابط"
},
"markdown": {
"cancel": "إلغاء",
"confirm": "تحويل",
"parseMessage": "سيتم تحويل المحتوى إلى تنسيق Markdown، وسيتم استبدال المحتوى الحالي. هل ترغب في المتابعة؟ (سيُغلق تلقائيًا بعد 5 ثوانٍ)",
"parseTitle": "تنسيق Markdown"
},
"math": {
"placeholder": "يرجى إدخال معادلة TeX"
},
"modifier": {
"accept": "قبول",
"acceptAll": "قبول الكل",
"reject": "رفض",
"rejectedAll": "رفض الكل"
},
"slash": {
"h1": "عنوان رئيسي من المستوى الأول",
"h2": "عنوان فرعي من المستوى الثاني",
@@ -50,13 +69,16 @@
"bulletList": "قائمة نقطية",
"code": "كود مضمن",
"codeblock": "كتلة كود",
"image": "صورة",
"italic": "مائل",
"link": "رابط",
"numberList": "قائمة مرقمة",
"redo": "إعادة",
"strikethrough": "شطب",
"table": "جدول",
"taskList": "قائمة المهام",
"tex": "معادلة TeX",
"underline": "تسطير"
"underline": "تسطير",
"undo": "تراجع"
}
}
+2
View File
@@ -132,6 +132,7 @@
"PluginSettingsInvalid": "تحتاج هذه الإضافة إلى تكوين صحيح قبل الاستخدام، يرجى التحقق من صحة تكوينك",
"ProviderBizError": "طلب خدمة {{provider}} خاطئ، يرجى التحقق من المعلومات التالية أو إعادة المحاولة",
"QuotaLimitReached": "عذرًا، لقد تم الوصول إلى الحد الأقصى لاستخدام الرموز (Token) أو عدد الطلبات لهذا المفتاح. يرجى زيادة حصة المفتاح أو المحاولة لاحقًا.",
"ServerAgentRuntimeError": "عذرًا، خدمة الوكيل غير متاحة حاليًا. يرجى المحاولة لاحقًا أو التواصل معنا عبر البريد الإلكتروني للحصول على الدعم.",
"StreamChunkError": "خطأ في تحليل كتلة الرسالة لطلب التدفق، يرجى التحقق مما إذا كانت واجهة برمجة التطبيقات الحالية تتوافق مع المعايير، أو الاتصال بمزود واجهة برمجة التطبيقات الخاصة بك للاستفسار.",
"SubscriptionKeyMismatch": "نعتذر، بسبب عطل عرضي في النظام، فإن استخدام الاشتراك الحالي غير فعال مؤقتًا. يرجى النقر على الزر أدناه لاستعادة الاشتراك، أو مراسلتنا عبر البريد الإلكتروني للحصول على الدعم.",
"SubscriptionPlanLimit": "لقد استنفدت نقاط اشتراكك، ولا يمكنك استخدام هذه الميزة. يرجى الترقية إلى خطة أعلى، أو تكوين واجهة برمجة التطبيقات للنموذج المخصص للاستمرار في الاستخدام",
@@ -162,6 +163,7 @@
"title": "تأكيد معلومات المصادقة الخاصة بـ {{name}}"
},
"confirm": "تأكيد وإعادة المحاولة",
"goToSettings": "الانتقال إلى الإعدادات",
"oauth": {
"description": "فتح المسؤول توثيق تسجيل الدخول الموحد، انقر فوق الزر أدناه لتسجيل الدخول وفتح التطبيق",
"success": "تم تسجيل الدخول بنجاح",
+51 -15
View File
@@ -1,8 +1,8 @@
{
"addFolder": "إنشاء مجلد",
"addKnowledge": "إضافة معرفة",
"addLibrary": "إضافة",
"addPage": "إنشاء مستند",
"desc": "نظّم معرفتك في العمل، الدراسة والحياة.",
"desc": "قم بإدارة مواردك للعمل، الدراسة والحياة.",
"detail": {
"basic": {
"createdAt": "تاريخ الإنشاء",
@@ -50,6 +50,12 @@
"pin": "تثبيت المستند"
},
"saving": "جارٍ الحفظ...",
"slashCommands": {
"bulletedList": "قائمة غير مرتبة",
"image": "صورة",
"orderedList": "قائمة مرتبة",
"todoList": "قائمة المهام"
},
"titlePlaceholder": "بدون عنوان",
"wordCount": "{{wordCount}} كلمة"
},
@@ -57,16 +63,46 @@
"copyContent": "نسخ المحتوى الكامل",
"duplicate": "إنشاء نسخة",
"empty": "لا توجد مستندات حالياً، انقر على الزر أعلاه لإنشاء أول مستند لك",
"filter": {
"all": "الكل",
"onlyInPages": "فقط في المستندات"
},
"noResults": "لم يتم العثور على مستندات مطابقة",
"pageCount": "إجمالي {{count}} مستند",
"selectNote": "اختر مستندًا لبدء التحرير",
"title": "المستندات",
"untitled": "بدون عنوان"
},
"empty": "لا توجد ملفات/مجلدات تم تحميلها بعد",
"header": {
"actions": {
"builtInBlockList": {
"filtered": "تم تصفية {{ignored}} ملفًا من أصل {{total}} ملف"
},
"connect": "اتصال...",
"gitignore": {
"apply": "تطبيق القواعد",
"cancel": "تجاهل القواعد",
"content": "تم اكتشاف ملف .gitignore (عدد {{count}} من الملفات)، هل ترغب في تطبيق قواعد التجاهل؟",
"filtered": "{{ignored}} ملفًا تم تجاهله من أصل {{total}} ملفًا",
"title": "تم اكتشاف .gitignore"
},
"newFolder": "إنشاء مجلد جديد",
"newPage": "مستند جديد",
"notion": {
"error": "فشل في استيراد ملف Notion",
"foundFiles": "تم العثور على {{count}} ملف",
"importing": "جارٍ استيراد ملفات Notion...",
"noMarkdownFiles": "لم يتم العثور على ملفات Markdown في ملف ZIP",
"partial": "تم استيراد {{success}} ملفًا بنجاح، وفشل {{failed}} ملفًا",
"success": "تم استيراد {{count}} ملفًا بنجاح"
},
"notionGuide": {
"cancel": "إلغاء الاستيراد الآن",
"desc": "يرجى أولاً تصدير ملفات Markdown (بصيغة ZIP) من Notion، ثم النقر على متابعة لاختيار ملف الضغط واستيراد جميع الصفحات.",
"ok": "اختر ملف ZIP من Notion",
"title": "استيراد محتوى Notion"
},
"uploadFile": "رفع ملف",
"uploadFolder": "رفع مجلد"
},
@@ -91,7 +127,7 @@
"quickActions": "إجراءات سريعة",
"recentFiles": "الملفات الأخيرة",
"recentPages": "الصفحات الأخيرة",
"subtitle": "مرحبًا بك في قاعدة المعرفة، ابدأ من هنا لإدارة مستنداتك وملاحظاتك",
"subtitle": "مرحبًا بك في مركز الموارد، ابدأ من هنا لإدارة مستنداتك وملفاتك.",
"uploadEntries": {
"files": {
"title": "رفع ملفات"
@@ -99,27 +135,27 @@
"folder": {
"title": "رفع مجلد"
},
"knowledgeBase": {
"title": "قاعدة معرفة جديدة"
"library": {
"title": "إنشاء مكتبة جديدة"
},
"newPage": {
"title": "إنشاء مستند جديد"
}
}
},
"knowledgeBase": {
"library": {
"list": {
"confirmRemoveKnowledgeBase": "سيتم حذف هذه المكتبة المعرفية، ولن يتم حذف الملفات الموجودة بها، بل ستنتقل إلى جميع الملفات. بعد حذف المكتبة المعرفية، لن يمكن استعادتها، يرجى توخي الحذر.",
"empty": "انقر على <1>+</1> لبدء إنشاء مكتبة معرفية"
"confirmRemoveLibrary": "سيتم حذف هذه المكتبة، لكن الملفات بداخلها لن تُحذف، بل سيتم نقلها إلى جميع الملفات. لا يمكن استعادة المكتبة بعد حذفها، يرجى الحذر.",
"empty": "انقر <1>+</1> لبدء إنشاء مكتبة"
},
"new": "إنشاء مكتبة معرفية جديدة",
"title": "المكتبة المعرفية"
"new": "مكتبة",
"title": "المكتبة"
},
"menu": {
"allFiles": "جميع الملفات",
"allPages": "جميع المستندات"
},
"networkError": "فشل في الحصول على قاعدة المعرفة، يرجى التحقق من اتصال الشبكة ثم إعادة المحاولة",
"networkError": "فشل في تحميل المكتبة، يرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى",
"notSupportGuide": {
"desc": "الوضع الحالي للنشر هو وضع قاعدة بيانات العميل، ولا يمكن استخدام وظيفة إدارة الملفات. يرجى التبديل إلى <1>وضع نشر قاعدة بيانات الخادم</1>، أو استخدام <3>LobeChat Cloud</3> مباشرة.",
"features": {
@@ -131,9 +167,9 @@
"desc": "استخدام نماذج متجهات عالية الأداء لتحويل النصوص إلى متجهات، مما يتيح البحث الدلالي في محتوى الملفات.",
"title": "تحويل دلالي إلى متجهات"
},
"repos": {
"desc": "يدعم إنشاء مكتبات معرفية، ويسمح بإضافة أنواع مختلفة من الملفات، لبناء معرفتك في مجالك.",
"title": "المكتبة المعرفية"
"libraries": {
"desc": "يدعم إنشاء مكتبات ويسمح بإضافة أنواع مختلفة من الملفات لبناء مواردك المتخصصة",
"title": "المكتبة"
}
},
"title": "الوضع الحالي للنشر لا يدعم إدارة الملفات"
@@ -155,7 +191,7 @@
"videos": "الفيديوهات",
"websites": "المواقع"
},
"title": "قاعدة المعرفة",
"title": "الموارد",
"toggleLeftPanel": "إظهار/إخفاء اللوحة الجانبية اليسرى",
"uploadDock": {
"body": {
+10
View File
@@ -0,0 +1,10 @@
{
"starter": {
"createAgent": "إنشاء مساعد",
"createGroup": "إنشاء مجموعة",
"deepResearch": "بحث معمق",
"developing": "قيد التطوير",
"image": "رسم",
"write": "كتابة"
}
}
+8 -4
View File
@@ -49,6 +49,10 @@
"desc": "إعادة توليد آخر رسالة",
"title": "إعادة توليد الرسالة"
},
"saveDocument": {
"desc": "احفظ جميع التغييرات التي أُجريت على المستند الحالي فورًا",
"title": "حفظ المستند"
},
"saveTopic": {
"desc": "حفظ الموضوع الحالي وفتح موضوع جديد",
"title": "فتح موضوع جديد"
@@ -66,12 +70,12 @@
"title": "تبديل المساعد بسرعة"
},
"toggleLeftPanel": {
"desc": "عرض أو إخفاء لوحة المساعد على اليسار",
"title": "عرض/إخفاء لوحة المساعد"
"desc": "إظهار أو إخفاء اللوحة الجانبية اليسرى",
"title": "إظهار/إخفاء اللوحة الجانبية اليسرى"
},
"toggleRightPanel": {
"desc": "عرض أو إخفاء لوحة المواضيع على اليمين",
"title": "عرض/إخفاء لوحة الموضوع"
"desc": "إظهار أو إخفاء اللوحة الجانبية اليمنى",
"title": "إظهار/إخفاء اللوحة الجانبية اليمنى"
},
"toggleZenMode": {
"desc": "في وضع التركيز، عرض المحادثة الحالية فقط، وإخفاء واجهة المستخدم الأخرى",
+1
View File
@@ -2,6 +2,7 @@
"addToKnowledgeBase": {
"addSuccess": "تم إضافة الملف بنجاح، <1>عرض الآن</1>",
"confirm": "إضافة",
"error": "فشل في إضافة الملف إلى قاعدة المعرفة",
"id": {
"placeholder": "يرجى اختيار قاعدة المعرفة لإضافتها",
"required": "يرجى اختيار قاعدة المعرفة",
+68 -5
View File
@@ -1,14 +1,15 @@
{
"authorize": {
"cancel": "إلغاء",
"confirm": "تأكيد التفويض",
"description": {
"and": "و",
"prefix": "بالنقر على تأكيد التفويض، فإنك توافق على",
"confirm": "أنشئ ملفك الشخصي",
"description": "ملفك الشخصي في المجتمع مستقل عن حساب المستخدم في {{appName}}.",
"footer": {
"agreement": "بمتابعتك، فإنك تؤكد أنك قد قرأت ووافقت على <terms>الشروط والأحكام</terms> و<privacy>سياسة الخصوصية</privacy>",
"privacy": "سياسة الخصوصية",
"terms": "شروط الخدمة"
},
"title": "تأكيد التفويض"
"subtitle": "أنشئ ملفًا شخصيًا في المجتمع لتتمكن من تقديم وإدارة معلومات النشر.",
"title": "إنشاء ملف المجتمع الشخصي"
},
"callback": {
"buttons": {
@@ -51,5 +52,67 @@
"submit": "تم التفويض بنجاح! يمكنك الآن نشر المساعد.",
"upload": "تم التفويض بنجاح! يمكنك الآن نشر إصدار جديد."
}
},
"profileSetup": {
"cancel": "إلغاء",
"descriptionEdit": "حدّث معلومات ملفك الشخصي في المجتمع.",
"descriptionFirstTime": "قم بإعداد ملفك الشخصي لإكمال إنشاء ملف المجتمع.",
"errors": {
"fileTooLarge": "حجم الملف لا يمكن أن يتجاوز 2 ميغابايت",
"notAuthenticated": "يرجى تسجيل الدخول أولاً قبل المتابعة",
"updateFailed": "فشل في تحديث الملف الشخصي، يرجى المحاولة مرة أخرى",
"uploadFailed": "فشل التحميل، يرجى المحاولة مرة أخرى",
"usernameTaken": "معرّف المستخدم هذا مستخدم بالفعل، يرجى اختيار معرّف آخر"
},
"fields": {
"avatar": {
"label": "الصورة الشخصية"
},
"bannerUrl": {
"clickToUpload": "انقر لتحميل صورة الغلاف",
"label": "صورة الغلاف",
"remove": "إزالة صورة الغلاف",
"tooltip": "ستظهر صورة الغلاف في أعلى صفحتك الشخصية (النسبة الموصى بها 16:9)",
"uploading": "جارٍ التحميل..."
},
"description": {
"label": "نبذة شخصية",
"maxLength": "النبذة الشخصية يجب ألا تتجاوز 200 حرف",
"placeholder": "قدّم نفسك بإيجاز..."
},
"displayName": {
"label": "الاسم الظاهر",
"maxLength": "الاسم الظاهر يجب ألا يتجاوز 50 حرفًا",
"placeholder": "أدخل اسمك الظاهر",
"required": "يرجى إدخال الاسم الظاهر"
},
"github": {
"placeholder": "اسم مستخدم GitHub"
},
"twitter": {
"placeholder": "اسم مستخدم X (تويتر)"
},
"userName": {
"label": "معرّف المستخدم",
"maxLength": "معرّف المستخدم يجب ألا يتجاوز 32 حرفًا",
"minLength": "معرّف المستخدم يجب أن لا يقل عن 3 أحرف",
"pattern": "يمكن أن يحتوي معرّف المستخدم على أحرف وأرقام وشرطات سفلية وشرطات فقط",
"placeholder": "أدخل معرّف المستخدم الخاص بك",
"required": "يرجى إدخال معرّف المستخدم",
"tooltip": "معرّف المستخدم هو معرفك الفريد وسيُستخدم في رابط صفحتك الشخصية"
},
"website": {
"invalidUrl": "يرجى إدخال رابط صالح",
"placeholder": "رابط الموقع الشخصي"
}
},
"getStarted": "ابدأ الآن",
"save": "حفظ",
"socialLinks": {
"title": "روابط التواصل الاجتماعي"
},
"success": "تم تحديث الملف الشخصي بنجاح",
"titleEdit": "تعديل الملف الشخصي",
"titleFirstTime": "أكمل ملفك الشخصي"
}
}
+102
View File
@@ -0,0 +1,102 @@
{
"context": {
"actions": {
"delete": "حذف",
"edit": "تعديل"
},
"defaultType": "سياق",
"deleteConfirm": "هل أنت متأكد من أنك تريد حذف هذه الذاكرة السياقية؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteTitle": "حذف الذاكرة السياقية",
"description": "الوصف",
"empty": "لا توجد ذكريات سياقية حالياً",
"source": "المصدر"
},
"empty": {
"description": "استخراج الذكريات هو عملية تدريجية، يُرجى التفاعل في مواضيع أكثر لتغذية محتوى الذكريات المستخرجة. حاول إجراء محادثات أعمق مع المساعد لالتقاط وتخزين معلومات ذات قيمة بشكل أفضل.",
"search": "لم يتم العثور على ذكريات مطابقة",
"title": "لا توجد ذكريات حالياً"
},
"experience": {
"actions": {
"delete": "حذف",
"edit": "تعديل"
},
"defaultType": "تجربة",
"deleteConfirm": "هل أنت متأكد من أنك تريد حذف هذه الذاكرة التجريبية؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteTitle": "حذف الذاكرة التجريبية",
"empty": "لا توجد ذكريات تجريبية حالياً",
"keyLearning": "التعلم الرئيسي",
"situation": "السياق",
"source": "المصدر",
"steps": {
"action": "الإجراء",
"result": "النتيجة",
"situation": "الوضع",
"task": "المهمة"
}
},
"filter": {
"search": "ابحث عن كلمات مفتاحية أو وصف للذاكرة...",
"sort": {
"createdAt": "تاريخ الإنشاء",
"scoreConfidence": "درجة الثقة",
"scoreImpact": "الأهمية",
"scorePriority": "أولوية التفضيل",
"scoreUrgency": "درجة الإلحاح"
}
},
"identity": {
"empty": "لا توجد ذاكرة هوية حالياً",
"filter": {
"search": "ابحث عن دور أو علاقة أو وصف...",
"type": {
"all": "الكل",
"demographic": "السمات",
"personal": "الدور",
"professional": "مهني"
}
},
"list": {
"confirmDelete": "تأكيد الحذف",
"deleteCancel": "إلغاء",
"deleteContent": "هل أنت متأكد من أنك تريد حذف ذاكرة الهوية هذه؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteOk": "حذف",
"noResults": "لم يتم العثور على أي ذاكرة هوية مطابقة",
"updated": "تم التحديث"
},
"roleCloud": {
"collapse": "إخفاء",
"expand": "عرض المزيد"
},
"view": {
"list": "قائمة",
"timeline": "الجدول الزمني"
}
},
"loading": "جارٍ التحميل...",
"preference": {
"actions": {
"delete": "حذف",
"edit": "تعديل"
},
"conclusionDirectives": "توجيهات الاستنتاج",
"defaultType": "تفضيل",
"deleteConfirm": "هل أنت متأكد من أنك تريد حذف هذه الذاكرة التفضيلية؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteTitle": "حذف الذاكرة التفضيلية",
"empty": "لا توجد ذكريات تفضيلية حالياً",
"source": "المصدر",
"suggestions": "الإجراءات التي قد يتخذها المساعد"
},
"tab": {
"contexts": "السياقات",
"experiences": "التجارب",
"home": "الرئيسية",
"identities": "الهويات",
"preferences": "التفضيلات",
"search": "بحث"
},
"viewMode": {
"masonry": "عرض متدرج",
"timeline": "الجدول الزمني"
}
}
+6 -6
View File
@@ -10,24 +10,24 @@
"discover": {
"assistants": {
"description": "إنشاء المحتوى، الكتابة، الأسئلة والأجوبة، توليد الصور، توليد الفيديو، توليد الصوت، الوكلاء الذكيون، سير العمل الآلي، تخصيص مساعد الذكاء الاصطناعي / GPTs / OLLaMA الخاص بك",
"title": ساعدات الذكاء الاصطناعي"
"title": جتمع الوكلاء الذكيين"
},
"description": "إنشاء المحتوى، الكتابة، الأسئلة والأجوبة، توليد الصور، توليد الفيديو، توليد الصوت، الوكلاء الذكيون، سير العمل الآلي، تطبيقات الذكاء الاصطناعي المخصصة، تخصيص منصة تطبيقات الذكاء الاصطناعي الخاصة بك",
"mcp": {
"description": "ابحث وقارن واتصل بآلاف خوادم MCP، مما يساعد أنظمة الذكاء الاصطناعي على الوصول بسهولة إلى أنظمة الملفات وقواعد البيانات وواجهات برمجة التطبيقات وغيرها من الموارد الحيوية، لتوسيع قدرات الذكاء الاصطناعي الخاصة بك بشكل شامل",
"title": "سوق خوادم MCP"
"title": "مجتمع خوادم MCP"
},
"models": {
"description": "استكشاف نماذج الذكاء الاصطناعي الرائجة OpenAI / GPT / Claude 3 / Gemini / Ollama / Azure / DeepSeek",
"title": "نماذج الذكاء الاصطناعي"
"title": "مجتمع النماذج"
},
"plugins": {
"description": "استكشف توليد الرسوم البيانية، والأبحاث الأكاديمية، وتوليد الصور، وتوليد الفيديو، وتوليد الصوت، وأتمتة سير العمل، ودمج قدرات إضافية غنية لمساعدتك.",
"title": "إضافات الذكاء الاصطناعي"
"title": "مجتمع الإضافات"
},
"providers": {
"description": "استكشاف مزودي النماذج الرائجة OpenAI / Qwen / Ollama / Anthropic / DeepSeek / Google Gemini / OpenRouter",
"title": زودو خدمات نماذج الذكاء الاصطناعي"
"title": جتمع مزودي النماذج"
},
"search": "بحث",
"title": "اكتشاف"
@@ -38,7 +38,7 @@
},
"plugins": {
"description": "البحث، توليد الرسوم البيانية، الأكاديميات، توليد الصور، توليد الفيديو، توليد الصوت، سير العمل الآلي، خصص قدرات ToolCall الخاصة بـ ChatGPT / Claude",
"title": "سوق الإضافات"
"title": "مجتمع الإضافات"
},
"welcome": {
"description": "{{appName}} يقدم لك أفضل تجربة لاستخدام ChatGPT وClaude وGemini وOLLaMA WebUI",
+12
View File
@@ -1229,6 +1229,9 @@
"deepseek_r1_distill_qwen_32b": {
"description": "DeepSeek-R1-Distill-Qwen-32B هو نموذج تم الحصول عليه من Qwen2.5-32B من خلال تقطير المعرفة. يستخدم هذا النموذج 800,000 عينة مختارة تم إنشاؤها بواسطة DeepSeek-R1 للتدريب، ويظهر أداءً ممتازًا في مجالات متعددة مثل الرياضيات، البرمجة، والاستدلال."
},
"devstral-2:123b": {
"description": "نموذج Devstral 2 123B، يتميز بقدرته على استخدام الأدوات لاستكشاف قواعد الشيفرة، وتحرير ملفات متعددة، ودعم وكلاء هندسة البرمجيات."
},
"doubao-1.5-lite-32k": {
"description": "دو باو 1.5 لايت هو نموذج الجيل الجديد الخفيف، مع سرعة استجابة قصوى، حيث يصل الأداء والوقت المستغرق إلى مستوى عالمي."
},
@@ -1937,6 +1940,15 @@
"gpt-5.1-codex-mini": {
"description": "GPT-5.1 Codex mini: إصدار مصغر ومنخفض التكلفة من Codex، مُحسَّن لمهام البرمجة القائمة على الوكلاء."
},
"gpt-5.2": {
"description": "GPT-5.2 — النموذج الرائد المخصص للبرمجة وتدفقات العمل الذكية، يتمتع بقدرات أقوى في الاستدلال وسياق طويل المدى."
},
"gpt-5.2-chat-latest": {
"description": "GPT-5.2 Chat: إصدار GPT-5.2 المستخدم في ChatGPT (chat-latest)، لتجربة أحدث التحسينات في المحادثة."
},
"gpt-5.2-pro": {
"description": "GPT-5.2 pro: إصدار أكثر ذكاءً ودقة من GPT-5.2 (لواجهة Responses API فقط)، مثالي للمسائل المعقدة والاستدلال متعدد الجولات الطويل."
},
"gpt-audio": {
"description": "GPT Audio هو نموذج دردشة عام موجه لإدخال وإخراج الصوت، ويدعم استخدام الصوت في واجهة برمجة تطبيقات Chat Completions."
},
+52
View File
@@ -0,0 +1,52 @@
{
"finish": "إنهاء",
"modeSelection": {
"desc": "اختر الوضع الذي يناسب استخدامك",
"lite": {
"desc": "واجهة مبسطة تركز على تجربة المحادثة، مناسبة للاستخدام اليومي",
"title": "الوضع البسيط"
},
"pro": {
"desc": "كامل الميزات، يتضمن إعدادات متقدمة وأدوات للمطورين، مناسب للمستخدمين المتقدمين",
"title": "الوضع الاحترافي"
},
"title": "اختر وضع الاستخدام"
},
"next": "التالي",
"proSettings": {
"connectors": {
"desc": "اربط خدمات الطرف الثالث التي تستخدمها بشكل متكرر",
"title": "الموصلات"
},
"desc": "قم بضبط الخيارات المتقدمة الخاصة بك",
"devMode": {
"desc": "عند التفعيل، ستظهر الميزات والخيارات الخاصة بالمطورين",
"title": "وضع المطور"
},
"model": {
"desc": "اختر النموذج الافتراضي لاستخدامه في المحادثات مع الذكاء الاصطناعي",
"title": "النموذج الافتراضي"
},
"title": "إعدادات الوضع الاحترافي"
},
"responseLanguage": {
"auto": "تلقائي (يتبع لغة النظام)",
"desc": "اختر اللغة التي يستخدمها المساعد الذكي في الردود",
"hint": "بعد اختيار اللغة، سيستخدم الذكاء الاصطناعي هذه اللغة في الردود، كما سيتم تغيير لغة الواجهة تلقائيًا",
"title": "اختر لغة ردود الذكاء الاصطناعي"
},
"telemetry": {
"desc": "لمساعدتنا في تحسين {{appName}}، هل تود مشاركة بيانات استخدام مجهولة الهوية؟",
"disable": "لا، شكرًا",
"enable": "نعم، أود المساعدة في التحسين",
"privacy": "نحن نولي أهمية كبيرة لخصوصيتك. نقوم بجمع بيانات استخدام مجهولة فقط، ولا نجمع أي محتوى من محادثاتك أو معلوماتك الشخصية.",
"title": "ساعدنا في تحسين التطبيق"
},
"title": "مرحبًا بك في {{appName}}",
"username": {
"desc": "أخبرنا كيف نُناديك",
"hint": "يمكنك تعديل هذا في الإعدادات في أي وقت",
"placeholder": "يرجى إدخال اسمك أو لقبك",
"title": "كيف تود أن نناديك؟"
}
}
+93 -4
View File
@@ -1,5 +1,54 @@
{
"builtins": {
"lobe-agent-builder": {
"apiName": {
"getAvailableModels": "الحصول على النماذج المتاحة",
"getAvailableTools": "الحصول على الأدوات المتاحة",
"getConfig": "الحصول على الإعدادات",
"getMeta": "الحصول على البيانات الوصفية",
"getPrompt": "الحصول على التعليمات النظامية",
"searchMarketTools": "البحث في سوق الإضافات",
"searchOfficialTools": "البحث عن الأدوات الرسمية",
"setModel": "تعيين النموذج",
"setOpeningMessage": "تعيين رسالة البداية",
"setOpeningQuestions": "تعيين أسئلة البداية",
"togglePlugin": "تبديل الإضافة",
"updateChatConfig": "تحديث إعدادات المحادثة",
"updateConfig": "تحديث الإعدادات",
"updateMeta": "تحديث البيانات الوصفية",
"updatePrompt": "تحديث التعليمات النظامية"
},
"title": "منشئ الوكيل"
},
"lobe-group-management": {
"apiName": {
"broadcast": "إرسال رسالة للجميع",
"createAgent": "إضافة عضو إلى الفريق",
"createWorkflow": "تخطيط سير العمل",
"executeTask": "تنفيذ المهمة",
"getAgentInfo": "الحصول على معلومات العضو",
"interrupt": "مقاطعة المهمة",
"inviteAgent": "دعوة عضو",
"removeAgent": "إزالة عضو",
"searchAgent": "البحث عن خبير ذي صلة",
"speak": "تعيين عضو للتحدث",
"summarize": "تلخيص المحادثة",
"vote": "بدء تصويت"
},
"title": "تنسيق الفريق"
},
"lobe-gtd": {
"apiName": {
"clearTodos": "مسح المهام",
"completeTodos": "إكمال المهام",
"createPlan": "إنشاء خطة",
"createTodos": "إنشاء مهام",
"removeTodos": "حذف المهام",
"updatePlan": "تحديث الخطة",
"updateTodos": "تحديث المهام"
},
"title": "أداة GTD"
},
"lobe-knowledge-base": {
"apiName": {
"readKnowledge": "قراءة محتوى قاعدة المعرفة",
@@ -24,6 +73,41 @@
},
"title": "النظام المحلي"
},
"lobe-page-agent": {
"apiName": {
"batchUpdate": "تحديث الدُفعة للعُقد",
"compareSnapshots": "مقارنة اللقطات",
"convertToList": "تحويل إلى قائمة",
"createNode": "إنشاء عقدة",
"cropImage": "اقتصاص الصورة",
"deleteNode": "حذف العقدة",
"deleteSnapshot": "حذف اللقطة",
"deleteTableColumn": "حذف عمود الجدول",
"deleteTableRow": "حذف صف الجدول",
"duplicateNode": "نسخ العقدة",
"editTitle": "تحرير عنوان المستند",
"indentListItem": "زيادة المسافة البادئة لعنصر القائمة",
"initPage": "تهيئة المستند",
"insertTableColumn": "إدراج عمود في الجدول",
"insertTableRow": "إدراج صف في الجدول",
"listSnapshots": "عرض اللقطات",
"mergeNodes": "دمج العقد",
"moveNode": "نقل العقدة",
"outdentListItem": "تقليل المسافة البادئة لعنصر القائمة",
"replaceText": "استبدال النص",
"resizeImage": "تغيير حجم الصورة",
"restoreSnapshot": "استعادة اللقطة",
"rotateImage": "تدوير الصورة",
"saveSnapshot": "حفظ اللقطة",
"setImageAlt": "تعيين النص البديل للصورة",
"splitNode": "تقسيم العقدة",
"toggleListType": "تبديل نوع القائمة",
"unwrapNode": "إلغاء تغليف العقدة",
"updateNode": "تحديث العقدة",
"wrapNodes": "تغليف العقد"
},
"title": "المستند"
},
"lobe-web-browsing": {
"apiName": {
"crawlMultiPages": "قراءة محتوى عدة صفحات",
@@ -338,6 +422,8 @@
"installed": "مثبت"
},
"config": {
"addEnv": "إضافة متغير بيئة",
"addHeaders": "إضافة رؤوس الطلب",
"args": "المعلمات",
"command": "الأمر",
"env": "متغيرات البيئة",
@@ -358,12 +444,15 @@
},
"title": "تثبيت إضافة مخصصة"
},
"install": {
"title": "معلومات التثبيت"
},
"marketplace": {
"title": "تثبيت إضافات الطرف الثالث",
"trustedBy": "مقدم من {{name}}",
"unverified": {
"title": "إضافات طرف ثالث غير موثوقة",
"warning": "هذه الإضافة من سوق طرف ثالث غير موثوق، يرجى التأكد من ثقتك بالمصدر قبل التثبيت."
"warning": "هذا المكون الإضافي来自 مجتمع طرف ثالث غير موثوق به. يرجى التأكد من أنك تثق في هذا المصدر قبل التثبيت."
},
"verified": "موثوقة"
},
@@ -441,7 +530,7 @@
"envConfigDescription": "سيتم تمرير هذه الإعدادات كمتغيرات بيئة عند بدء تشغيل خادم MCP",
"httpTypeNotice": "إضافات MCP من نوع HTTP لا تحتاج إلى متغيرات بيئة للتكوين حاليًا",
"indexUrl": {
"title": "فهرس السوق",
"title": "فهرس المجتمع",
"tooltip": "لا يدعم التحرير عبر الإنترنت حاليًا، يرجى التكوين عبر متغيرات البيئة عند النشر"
},
"messages": {
@@ -450,14 +539,14 @@
"envUpdateFailed": "فشل حفظ متغيرات البيئة",
"envUpdateSuccess": "تم حفظ متغيرات البيئة بنجاح"
},
"modalDesc": "بعد تكوين عنوان سوق الإضافات، يمكنك استخدام سوق إضافات مخصص",
"modalDesc": "بعد تكوين عنوان مجتمع المكونات الإضافية، يمكنك استخدام مجتمع مكونات إضافية مخصص",
"rules": {
"argsRequired": "يرجى إدخال معلمات التشغيل",
"commandRequired": "يرجى إدخال أمر التشغيل",
"urlRequired": "يرجى إدخال عنوان الخدمة"
},
"saveSettings": "حفظ الإعدادات",
"title": "إعدادات سوق الإضافات"
"title": "إعداد مجتمع المكونات الإضافية"
},
"showInPortal": "يرجى عرض التفاصيل في مساحة العمل",
"store": {

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