mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 14:20:27 +00:00
Compare commits
554 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3abf3b242e | |||
| a3c459226f | |||
| af66709990 | |||
| 2bc61becda | |||
| fcfea3f588 | |||
| 69659b228f | |||
| c8d66b5f31 | |||
| 0cb9524b85 | |||
| 8fda73d147 | |||
| e0147efde5 | |||
| eeb1e9d289 | |||
| efb3bba568 | |||
| 4285749df2 | |||
| 96850efdf4 | |||
| 18970d5983 | |||
| a9ece03d0a | |||
| 1f136227b8 | |||
| 08c338267e | |||
| 9d0da361fa | |||
| b36c04c404 | |||
| 3cc647a49d | |||
| a06f45b6cf | |||
| 25c2e90cf5 | |||
| 5a3cbf6a59 | |||
| 0b76fabc56 | |||
| ac2b1eca37 | |||
| 1a5caf9c48 | |||
| fffd8a04b0 | |||
| 446c10b598 | |||
| 56677e0415 | |||
| ae74142ae0 | |||
| 4f641269f8 | |||
| 2643a46a2c | |||
| 4517562629 | |||
| 26da014e94 | |||
| ddd759464d | |||
| e72adfe552 | |||
| 681590f2a1 | |||
| 2df15871b8 | |||
| 5a1f2e460c | |||
| ccd4ac56e0 | |||
| fc6097bb4f | |||
| 1bb74b3b5f | |||
| 38df1277a3 | |||
| ecc9ef8846 | |||
| 940c804caf | |||
| ae66a23350 | |||
| aaa61f7994 | |||
| cd2d00e7c1 | |||
| 565f355a9b | |||
| 9ea87353be | |||
| 03782b8b54 | |||
| 88406e070f | |||
| d09a8f23fa | |||
| 4a900bd775 | |||
| 1d889b705f | |||
| d67754bde1 | |||
| 4dc5308323 | |||
| c8b4c6d13e | |||
| 8040a3cc69 | |||
| 29309549c4 | |||
| 8467b3fbea | |||
| 0e7b2ba4c5 | |||
| 034b82d915 | |||
| 0b1c19a017 | |||
| 3dcf785e7e | |||
| 60d9151802 | |||
| 64c6527de2 | |||
| d3847fad34 | |||
| ca7570f817 | |||
| 2c452dddfc | |||
| 92c879b2a7 | |||
| 37a2e8edf6 | |||
| 9865c2d620 | |||
| 02f53afbf9 | |||
| ee707d2d01 | |||
| e5c5036f10 | |||
| 39c5d77163 | |||
| fb70ce70ef | |||
| 2b89dbe8f5 | |||
| 252ed1fe7c | |||
| 5ee75b73da | |||
| 29b28ece20 | |||
| f433332f87 | |||
| c62da64fcc | |||
| 8e8cb58b78 | |||
| 3d9a1488bb | |||
| 23b67b364a | |||
| a2be282be6 | |||
| 09b001067a | |||
| 36ba02efe9 | |||
| fd60bee830 | |||
| 0ec35f6c32 | |||
| 3ee17092ab | |||
| db0a650bd5 | |||
| 5678b9fadb | |||
| a99ccf4534 | |||
| 0bd603cc29 | |||
| 6a4c355b54 | |||
| 1a075f3ca5 | |||
| d4b618ed71 | |||
| 6d62e1ec80 | |||
| 3bdd1cbc0f | |||
| 600a20c822 | |||
| bbf20289db | |||
| 9a57fcd600 | |||
| d6bdf406aa | |||
| b98813f0b1 | |||
| dce2925ab9 | |||
| 86f9f1c06c | |||
| 5accb96fec | |||
| 8b83609103 | |||
| 74f4341e8d | |||
| c73416141b | |||
| 9f6084a2fb | |||
| cbd6715dd7 | |||
| d42f8f64c7 | |||
| 5330159fa8 | |||
| 34850e3d1c | |||
| e06a1bd4d9 | |||
| ab09c04bee | |||
| 39af292f6e | |||
| c951cc0afd | |||
| 80ea77ac6b | |||
| 544639ff92 | |||
| 9e58abb37d | |||
| 3b71cb1eee | |||
| 5d3fb27a19 | |||
| f73ce9be9e | |||
| d9e95fbbb7 | |||
| 132ced10a5 | |||
| d6c74efacb | |||
| 6229876ff5 | |||
| 9b0926efdc | |||
| 6faa261e3f | |||
| 03817cd427 | |||
| 3b2767aec9 | |||
| 8b3b8fd218 | |||
| 5d251563b3 | |||
| e6263ebb09 | |||
| 4dd315126b | |||
| 54da37d4bf | |||
| 5912717721 | |||
| 061b91a76e | |||
| 09bf76e10b | |||
| 59a6e3e0ac | |||
| aebe297429 | |||
| b5a6e2d60f | |||
| 11eb5834bb | |||
| e5cd73f7dd | |||
| 4237f7c2b9 | |||
| 54c9900997 | |||
| d786491be1 | |||
| af392959fa | |||
| 5f062a802c | |||
| 619627f100 | |||
| 989a108db9 | |||
| c85fb193f8 | |||
| 18b2564ac5 | |||
| 111aa922d7 | |||
| c06b63330e | |||
| d0771c2dbf | |||
| 3eeeed0c43 | |||
| 24920c93b4 | |||
| 2fc6e855d1 | |||
| 68be44a053 | |||
| 6364027ac2 | |||
| 0875ac4ca2 | |||
| 23a3cf609f | |||
| 40d4f496db | |||
| c47d7285b4 | |||
| e8d64606c8 | |||
| d95582fdcb | |||
| 8e4c481ffb | |||
| 1ebc06b1b4 | |||
| 5f13f37544 | |||
| ded42374d5 | |||
| 07963b3552 | |||
| 754b28382e | |||
| 0c1b19114c | |||
| 09fc1d2d13 | |||
| da34f490c8 | |||
| 85af7b16a2 | |||
| 9f93568fcf | |||
| b7ed1fc744 | |||
| 2713cce61e | |||
| 4e83977e7c | |||
| 07c6ad56ea | |||
| 6fb1a3ba90 | |||
| f04c789043 | |||
| 21b3b2a693 | |||
| ce97ffc0e0 | |||
| f0ebdacc00 | |||
| e864c9e44f | |||
| dc4d26d13f | |||
| aa5cb2953d | |||
| 0fc6f20840 | |||
| b75d81fa4e | |||
| cec6751bda | |||
| 319dfb21d5 | |||
| b3c67430e8 | |||
| 0186a4b01d | |||
| a1d98cb05b | |||
| beba396c8b | |||
| 16f3c0df75 | |||
| e99092e091 | |||
| f4329b81bb | |||
| d9d149de2a | |||
| b72fc88b3f | |||
| d06b0dbbcf | |||
| 31ea03382b | |||
| 2110a87422 | |||
| 2d0dde74c9 | |||
| 0973458529 | |||
| 208b07d417 | |||
| 52983de190 | |||
| 18f5834550 | |||
| a915507938 | |||
| c27bf79e0f | |||
| d59b44c042 | |||
| 7428a58303 | |||
| 0090bd715e | |||
| d7d38d338e | |||
| b6fd29787b | |||
| 0a184130dc | |||
| 706569e61c | |||
| 2cec8062f1 | |||
| 274f95a4a5 | |||
| 604e0cf1c8 | |||
| bf90eef3d4 | |||
| d8d56a6809 | |||
| ca9e1a124c | |||
| ae521e0d61 | |||
| 9179f63449 | |||
| 40271db7a1 | |||
| 9cfd816b50 | |||
| 64052762d1 | |||
| 6f10874617 | |||
| db681ac87d | |||
| f01327a796 | |||
| 9604523c51 | |||
| 0dc5e711bc | |||
| ce75257975 | |||
| a149391b0e | |||
| ace6d34d5f | |||
| 705e8f7090 | |||
| af52fb887a | |||
| 3688e5f3eb | |||
| f61f2ae589 | |||
| 9ac6f5fdc5 | |||
| 2eca77a535 | |||
| 3b5da54660 | |||
| b8ff536d14 | |||
| 4513f8ddd2 | |||
| 1ebd67e00f | |||
| e279bdfb93 | |||
| 515b7b1127 | |||
| 2edc83b79c | |||
| cc3a310717 | |||
| 8a9451a69e | |||
| 84cc9ef336 | |||
| 2e14d9ff18 | |||
| 2082eb2292 | |||
| 6c3ce6195d | |||
| acd29d7e4d | |||
| 3f7f964c1b | |||
| 7b45fc5831 | |||
| f97df5c05e | |||
| e2ce0e5f19 | |||
| 40b1630eec | |||
| cc7d13db30 | |||
| 83df1f4339 | |||
| 42242c0c24 | |||
| 3f8c6c50f3 | |||
| 024f65a47e | |||
| 18f757e925 | |||
| 33b5ac4bc8 | |||
| 55e177f0bd | |||
| b43f78eac5 | |||
| 8cb4096d27 | |||
| 034c68e251 | |||
| 2f4ed1a016 | |||
| 8bafec3d8b | |||
| 6b368c562d | |||
| 72c5d74041 | |||
| 93a6d89b02 | |||
| 59a555ab9e | |||
| 18f6f381c0 | |||
| 5df9feecfb | |||
| 0b8a3a2812 | |||
| ef0d6a3d3a | |||
| cedb98c3bf | |||
| f98e60b679 | |||
| 38cf8f28ac | |||
| 21333da8e7 | |||
| 8293741560 | |||
| bce556cd76 | |||
| 90f49a967f | |||
| a8ca5f78f7 | |||
| d89474a304 | |||
| 5c7775dc9f | |||
| 784128c96b | |||
| ae87e83e22 | |||
| 84c43d9091 | |||
| 6444f27751 | |||
| 4478e72d9c | |||
| 769c8871b7 | |||
| d6d4290b9e | |||
| 7e7ef23fb3 | |||
| eb65c57bfa | |||
| bf7744f93b | |||
| 34183041e4 | |||
| 93dda6bfee | |||
| ab34b9c400 | |||
| ff783b3fd4 | |||
| 977dff7201 | |||
| bedbb00ec4 | |||
| 61a9ae2b22 | |||
| b2f9b2288a | |||
| d9a93c996e | |||
| 69dbf4966d | |||
| 18bb4cb95d | |||
| 4717b8e945 | |||
| acc6efb778 | |||
| c9eeb26bcb | |||
| e9ea0ef92a | |||
| 173c091d54 | |||
| 7c1a52298b | |||
| fb742204e6 | |||
| bed9e7e797 | |||
| b25f669ca6 | |||
| d6316566f7 | |||
| a1520e643e | |||
| 1cc10d3f31 | |||
| fb37fd0ca1 | |||
| 99400b1ac2 | |||
| 7fa6e406d8 | |||
| 0bf2d38089 | |||
| 23a26a567f | |||
| bfced7f8ec | |||
| 5f097faa5f | |||
| 1b51e9deba | |||
| b298ec9c6d | |||
| af471ee9f4 | |||
| 978c7498e5 | |||
| 8181028c09 | |||
| 1b4ec2d1e4 | |||
| af9af15861 | |||
| 9708d90533 | |||
| b85ac86253 | |||
| 0d20305aac | |||
| 761420d6ef | |||
| 2356adb6cc | |||
| f632ed48d2 | |||
| e07605a6fe | |||
| bef203f8ff | |||
| 582aed80ac | |||
| da282c75d6 | |||
| 8fbc4e4a0a | |||
| bf037e333e | |||
| 0ab5195c11 | |||
| 7b779b2e5a | |||
| 06b3ee068d | |||
| fff1e4f10e | |||
| e486e15c64 | |||
| 1e332bec07 | |||
| 206cf8c645 | |||
| 9fc8a59458 | |||
| 067970d532 | |||
| 837bf8c461 | |||
| c0f77deeab | |||
| 392cdef720 | |||
| 1fbb302e25 | |||
| 5f81bcdea6 | |||
| bee3f8e456 | |||
| 1b239e229b | |||
| 749c90c105 | |||
| 9ed52c4135 | |||
| dcd1989faa | |||
| 3438c3de9f | |||
| d25ed06845 | |||
| e186e27d71 | |||
| a1b77bc7ac | |||
| d9a0cc5ca2 | |||
| 100f114e6b | |||
| 269c9a5faf | |||
| 8cfb934afd | |||
| 2fa471bb62 | |||
| 239f3e7154 | |||
| ed36d3b760 | |||
| aaf2045835 | |||
| 110f3c7b1e | |||
| 2ecf47f89b | |||
| 0d5a5f1e37 | |||
| d9f812a026 | |||
| e8ffde951f | |||
| e1e8416583 | |||
| ce1806b88a | |||
| 10fdf24bb4 | |||
| 8d2959f455 | |||
| 2036b75095 | |||
| a7facab0ca | |||
| c3526ac0f3 | |||
| 0c8c8cf83f | |||
| ba96c6d2f6 | |||
| 286c62660c | |||
| 3d286323c5 | |||
| cbf19e1c89 | |||
| d34130e8a6 | |||
| 46e376a0fd | |||
| efb6c9f350 | |||
| 598c840684 | |||
| 9d7975ee1f | |||
| e5a5c772e0 | |||
| 25d4a46550 | |||
| 8e8a2beee4 | |||
| 016c6360aa | |||
| 842cae1ae2 | |||
| 0e2a739e6a | |||
| 214d156e09 | |||
| fbed2713bb | |||
| 6d4d77a854 | |||
| b22d30e13b | |||
| 15b73b9627 | |||
| 89abb0e87c | |||
| 6030b1794d | |||
| 7a50256d04 | |||
| f1df8bd50b | |||
| a4bbd63abc | |||
| f11c7d2033 | |||
| 1751c4a644 | |||
| ceac3493e1 | |||
| cbdd083c8d | |||
| 73bae654e9 | |||
| 5e70f6de14 | |||
| e4734bf1a1 | |||
| f45ab6bc92 | |||
| b38bf6fb2e | |||
| 4eae151235 | |||
| 8a3158d781 | |||
| 9492b58531 | |||
| fc1b56fe0b | |||
| 9cb1b0fbb6 | |||
| f7c7b5ab53 | |||
| 8049af8b24 | |||
| 4d0814b536 | |||
| 4e29a9cb38 | |||
| 52a0333ea2 | |||
| a5dcd83431 | |||
| 5a442b1f84 | |||
| 8ef4e4114e | |||
| 6756812486 | |||
| ca77218968 | |||
| 76f47dfe27 | |||
| 0c770cb837 | |||
| 14002c32e9 | |||
| 5863c17793 | |||
| 7272fb0b19 | |||
| 4c22c8e4af | |||
| 5202895c8d | |||
| 6c2539b105 | |||
| 3db41e0deb | |||
| 5c1729eb4d | |||
| 6e0fdb87cf | |||
| 886e43b7fd | |||
| 8fd062b9af | |||
| 2856152a24 | |||
| cc24f08c34 | |||
| cc6cf4f4ab | |||
| c706e0cf85 | |||
| 44a2846983 | |||
| eddbea1180 | |||
| 52c526a216 | |||
| 5be282b591 | |||
| 4ffd13310d | |||
| 5b69b2c574 | |||
| 247028750d | |||
| d65c169663 | |||
| 102a47ac01 | |||
| ae794c9d17 | |||
| 3f3d2856b4 | |||
| 671ba275fc | |||
| bfd8f3d2bc | |||
| 5b9e3f906d | |||
| 40c748ec9e | |||
| 8c3c8d712f | |||
| 98e385a3d4 | |||
| 8f0e2f27aa | |||
| 55a4d9a7a9 | |||
| abe154d9c0 | |||
| 4a619725c2 | |||
| b4d5faff38 | |||
| db9a99fc72 | |||
| 07ff5acbb6 | |||
| 0b19fdd9bf | |||
| f0493b4cf1 | |||
| b25cf4c4c6 | |||
| 0a56d3cc90 | |||
| 43de4e47f7 | |||
| c0de93a2dd | |||
| b5c7cee5fe | |||
| 4c39157a29 | |||
| 9cf7c0a7de | |||
| 42d7f8cca8 | |||
| ae2f4c29e1 | |||
| 00e504d365 | |||
| 305483dacc | |||
| 04bb8273dc | |||
| 821c95d9d1 | |||
| ab603f95c3 | |||
| 870ac6aa45 | |||
| 216f367300 | |||
| 801c744522 | |||
| e92ea2c206 | |||
| e8e1c65503 | |||
| b6616a832f | |||
| 04020fc804 | |||
| 7519d6f3f1 | |||
| 27deea97cf | |||
| 3230f13817 | |||
| c3ca76293c | |||
| 81e56d462a | |||
| 0446127910 | |||
| 79b2afda70 | |||
| 0287239975 | |||
| 2da6cac673 | |||
| c46bd6e49a | |||
| ec3aa6ba3b | |||
| 0cb8bc1179 | |||
| 5cb69336b2 | |||
| 84a6e711b8 | |||
| 6591f3c4ef | |||
| 157d9accbc | |||
| 7cfc9a28e4 | |||
| 108d2a72a5 | |||
| 05ca7b51be | |||
| 2923bc676e | |||
| a44224132a | |||
| 458cbf4d5c | |||
| 542f4d97dd | |||
| 1c187063cd | |||
| 82424887dd | |||
| f3357b0b46 | |||
| a8822940b3 | |||
| f312661166 | |||
| f74befadc9 | |||
| a775f6544c | |||
| d9c4d672ca | |||
| 8645a6db16 | |||
| 3aceeb6d94 | |||
| 1afd4710e7 | |||
| b09361fbce | |||
| ecdda9d452 | |||
| 5ad4f0c3ad |
@@ -5,7 +5,7 @@ alwaysApply: false
|
||||
|
||||
# Database Migrations Guide
|
||||
|
||||
## Step1: Generate migrations:
|
||||
## Step1: Generate migrations
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
|
||||
@@ -36,13 +36,13 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
1. **创建控制器 (Controller)**
|
||||
- 位置:`apps/desktop/src/main/controllers/`
|
||||
- 示例:创建 `NewFeatureCtr.ts`
|
||||
- 规范:按 `_template.ts` 模板格式实现
|
||||
- 注册:在 `apps/desktop/src/main/controllers/index.ts` 导出
|
||||
- 需继承 `ControllerModule`,并设置 `static readonly groupName`(例如 `static override readonly groupName = 'newFeature';`)
|
||||
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors`(或 `controllerServerIpcConstructors`)中注册,保证类型推导与自动装配
|
||||
|
||||
2. **定义 IPC 事件处理器**
|
||||
- 使用 `@ipcClientEvent('eventName')` 装饰器注册事件处理函数
|
||||
- 处理函数应接收前端传递的参数并返回结果
|
||||
- 处理可能的错误情况
|
||||
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道,或使用 `@IpcServerMethod()` 声明仅供 Next.js 服务器调用的 IPC
|
||||
- 通道名称基于 `groupName.methodName` 自动生成,不再手动拼接字符串
|
||||
- 处理函数可通过 `getIpcContext()` 获取 `sender`、`event` 等上下文信息,并按照需要返回结构化结果
|
||||
|
||||
3. **实现业务逻辑**
|
||||
- 可能需要调用 Electron API 或 Node.js 原生模块
|
||||
@@ -60,15 +60,17 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
1. **创建服务层**
|
||||
- 位置:`src/services/electron/`
|
||||
- 添加服务方法调用 IPC
|
||||
- 使用 `dispatch` 或 `invoke` 函数
|
||||
- 使用 `ensureElectronIpc()` 生成的类型安全代理,避免手动拼通道名称
|
||||
|
||||
```typescript
|
||||
// src/services/electron/newFeatureService.ts
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { NewFeatureParams } from 'types';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
import type { NewFeatureParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
export const newFeatureService = async (params: NewFeatureParams) => {
|
||||
return dispatch('newFeatureEventName', params);
|
||||
return ipc.newFeature.doSomething(params);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -118,36 +120,31 @@ LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/NotificationCtr.ts
|
||||
import { BrowserWindow, Notification } from 'electron';
|
||||
import { ipcClientEvent } from 'electron-client-ipc';
|
||||
import { Notification } from 'electron';
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
import type {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
interface ShowNotificationParams {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
static override readonly groupName = 'notification';
|
||||
|
||||
@IpcMethod()
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
if (!Notification.isSupported()) {
|
||||
return { error: 'Notifications not supported', success: false };
|
||||
}
|
||||
|
||||
export class NotificationCtr {
|
||||
@ipcClientEvent('showNotification')
|
||||
async handleShowNotification({ title, body }: ShowNotificationParams) {
|
||||
try {
|
||||
if (!Notification.isSupported()) {
|
||||
return { success: false, error: 'Notifications not supported' };
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
});
|
||||
|
||||
const notification = new Notification({ body: params.body, title: params.title });
|
||||
notification.show();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to show notification:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
console.error('[NotificationCtr] Failed to show notification:', error);
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error', success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,15 @@ alwaysApply: false
|
||||
* 导入在步骤 2 中定义的 IPC 参数类型。
|
||||
* 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
|
||||
* 方法接收 `params` (符合 IPC 参数类型)。
|
||||
* 使用从 `@lobechat/electron-client-ipc` 导入的 `dispatch` (或 `invoke`) 函数,调用与 Manifest 中 `name` 字段匹配的 IPC 事件名称,并将 `params` 传递过去。
|
||||
* 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
|
||||
* 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
|
||||
|
||||
5. **实现后端逻辑 (Controller / IPC Handler):**
|
||||
* **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
|
||||
* **操作:**
|
||||
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ipcClientEvent`, 参数类型等)。
|
||||
* 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`/`IpcServerMethod`、参数类型等)。
|
||||
* 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
|
||||
* 使用 `@ipcClientEvent('yourApiName')` 装饰器将此方法注册为对应 IPC 事件的处理器,确保 `'yourApiName'` 与 Manifest 中的 `name` 和 Service 层调用的事件名称一致。
|
||||
* 使用 `@IpcMethod()` 或 `@IpcServerMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
|
||||
* 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
|
||||
* 实现核心业务逻辑:
|
||||
* 进行必要的输入验证。
|
||||
|
||||
@@ -149,50 +149,52 @@ export const createMainWindow = () => {
|
||||
|
||||
1. **在主进程中注册 IPC 处理器**
|
||||
```typescript
|
||||
// BrowserWindowsCtr.ts
|
||||
@ipcClientEvent('minimizeWindow')
|
||||
handleMinimizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
focusedWindow.minimize();
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
@ipcClientEvent('maximizeWindow')
|
||||
handleMaximizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
if (focusedWindow.isMaximized()) {
|
||||
focusedWindow.restore();
|
||||
} else {
|
||||
focusedWindow.maximize();
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@ipcClientEvent('closeWindow')
|
||||
handleCloseWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
focusedWindow.close();
|
||||
@IpcMethod()
|
||||
minimizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
focusedWindow?.minimize();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
maximizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow?.isMaximized()) focusedWindow.restore();
|
||||
else focusedWindow?.maximize();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
closeWindow() {
|
||||
BrowserWindow.getFocusedWindow()?.close();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
- `@IpcMethod()` 根据控制器的 `groupName` 自动将方法映射为 `windows.minimizeWindow` 形式的通道名称。
|
||||
- 控制器需继承 `ControllerModule`,并在 `controllers/registry.ts` 中通过 `controllerIpcConstructors` 注册,便于类型生成。
|
||||
|
||||
2. **在渲染进程中调用**
|
||||
```typescript
|
||||
// src/services/electron/windowService.ts
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
export const windowService = {
|
||||
minimize: () => dispatch('minimizeWindow'),
|
||||
maximize: () => dispatch('maximizeWindow'),
|
||||
close: () => dispatch('closeWindow'),
|
||||
minimize: () => ipc.windows.minimizeWindow(),
|
||||
maximize: () => ipc.windows.maximizeWindow(),
|
||||
close: () => ipc.windows.closeWindow(),
|
||||
};
|
||||
```
|
||||
- `ensureElectronIpc()` 会基于 `DesktopIpcServices` 运行时生成 Proxy,并通过 `window.electronAPI.invoke` 与主进程通信;不再直接使用 `dispatch`。
|
||||
|
||||
### 5. 自定义窗口控制 (无边框窗口)
|
||||
|
||||
@@ -252,45 +254,33 @@ export const createMainWindow = () => {
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
|
||||
import type { OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@IpcMethod()
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
? { tab: typeof options === 'string' ? options : undefined }
|
||||
: options;
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const query = new URLSearchParams();
|
||||
if (normalizedOptions.tab) query.set('active', normalizedOptions.tab);
|
||||
if (normalizedOptions.searchParams) {
|
||||
for (const [key, value] of Object.entries(normalizedOptions.searchParams)) {
|
||||
if (value) query.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = `/settings${query.size ? `?${query.toString()}` : ''}`;
|
||||
await mainWindow.loadUrl(fullPath);
|
||||
mainWindow.show();
|
||||
|
||||
@ipcClientEvent('openSettings')
|
||||
handleOpenSettings() {
|
||||
// 检查设置窗口是否已经存在
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
// 如果窗口已存在,将其置于前台
|
||||
this.settingsWindow.focus();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 创建新窗口
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
title: 'Settings',
|
||||
parent: this.mainWindow, // 设置父窗口,使其成为模态窗口
|
||||
modal: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 加载设置页面
|
||||
if (isDev) {
|
||||
this.settingsWindow.loadURL('http://localhost:3000/settings');
|
||||
} else {
|
||||
this.settingsWindow.loadFile(
|
||||
path.join(__dirname, '../../renderer/index.html'),
|
||||
{ hash: 'settings' }
|
||||
);
|
||||
}
|
||||
|
||||
// 监听窗口关闭事件
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null;
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
description: Explain how group chat works in LobeHub (Multi-agent orchestratoin)
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
This rule explains how group chat (multi-agent orchestration) works. Not confused with session group, which is a organization method to manage session.
|
||||
|
||||
## Key points
|
||||
|
||||
- A supervisor will devide who and how will speak next
|
||||
- Each agent will speak just like in single chat (if was asked to speak)
|
||||
- Not coufused with session group
|
||||
|
||||
## Related Files
|
||||
|
||||
- src/store/chat/slices/message/supervisor.ts
|
||||
- src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts
|
||||
- src/prompts/groupChat/index.ts (All prompts here)
|
||||
|
||||
## Snippets
|
||||
|
||||
```tsx
|
||||
// Detect whether in group chat
|
||||
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
|
||||
|
||||
// Member actions
|
||||
const addAgentsToGroup = useChatGroupStore((s) => s.addAgentsToGroup);
|
||||
const removeAgentFromGroup = useChatGroupStore((s) => s.removeAgentFromGroup);
|
||||
const persistReorder = useChatGroupStore((s) => s.reorderGroupMembers);
|
||||
|
||||
// Get group info
|
||||
const groupConfig = useChatGroupStore(chatGroupSelectors.currentGroupConfig);
|
||||
const currentGroupMemebers = useSessionStore(sessionSelectors.currentGroupAgents);
|
||||
```
|
||||
@@ -2,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 中导出
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
- name: Typecheck Desktop
|
||||
run: pnpm typecheck
|
||||
run: pnpm type-check
|
||||
working-directory: apps/desktop
|
||||
|
||||
- name: Test Desktop Client
|
||||
|
||||
+2
-1
@@ -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
|
||||
@@ -26,6 +26,7 @@ The project follows a well-organized monorepo structure:
|
||||
- `src/` - Main source code
|
||||
- `docs/` - Documentation
|
||||
- `.cursor/rules/` - Development rules and guidelines
|
||||
- PR titles starting with `✨ feat/` or `🐛 fix` will trigger the release workflow upon merge. Only use these prefixes for significant user-facing feature changes or bug fixes
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -83,7 +84,7 @@ All following rules are saved under `.cursor/rules/` directory:
|
||||
|
||||
### Frontend
|
||||
|
||||
- `react-component.mdc` – React component style guide and conventions
|
||||
- `react.mdc` – React component style guide and conventions
|
||||
- `i18n.mdc` – Internationalization guide using react-i18next
|
||||
- `typescript.mdc` – TypeScript code style guide
|
||||
- `packages/react-layout-kit.mdc` – Usage guide for react-layout-kit
|
||||
|
||||
+219
@@ -2,6 +2,225 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Version 2.0.0-next.171](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.170...v2.0.0-next.171)
|
||||
|
||||
<sup>Released on **2025-12-14**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Update GPT-5.2 models.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Update GPT-5.2 models, closes [#10749](https://github.com/lobehub/lobe-chat/issues/10749) ([0446127](https://github.com/lobehub/lobe-chat/commit/0446127))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.170](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.169...v2.0.0-next.170)
|
||||
|
||||
<sup>Released on **2025-12-12**</sup>
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.169](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.168...v2.0.0-next.169)
|
||||
|
||||
<sup>Released on **2025-12-12**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix CVE errors.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix CVE errors, closes [#10748](https://github.com/lobehub/lobe-chat/issues/10748) ([6591f3c](https://github.com/lobehub/lobe-chat/commit/6591f3c))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.168](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.167...v2.0.0-next.168)
|
||||
|
||||
<sup>Released on **2025-12-12**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Slove market oidc error.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Slove market oidc error, closes [#10715](https://github.com/lobehub/lobe-chat/issues/10715) ([108d2a7](https://github.com/lobehub/lobe-chat/commit/108d2a7))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.167](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.166...v2.0.0-next.167)
|
||||
|
||||
<sup>Released on **2025-12-11**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Add Replicate image provider.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Add Replicate image provider ([542f4d9](https://github.com/lobehub/lobe-chat/commit/542f4d9))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.166](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.165...v2.0.0-next.166)
|
||||
|
||||
<sup>Released on **2025-12-09**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **Dockerfile**: Electron main typing pkg.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **Dockerfile**: Electron main typing pkg, closes [#10693](https://github.com/lobehub/lobe-chat/issues/10693) ([f3357b0](https://github.com/lobehub/lobe-chat/commit/f3357b0))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.165](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.164...v2.0.0-next.165)
|
||||
|
||||
<sup>Released on **2025-12-09**</sup>
|
||||
|
||||
#### ♻ Code Refactoring
|
||||
|
||||
- **electron-main**: Client ipc decorate.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Code refactoring
|
||||
|
||||
- **electron-main**: Client ipc decorate, closes [#10679](https://github.com/lobehub/lobe-chat/issues/10679) ([f74befa](https://github.com/lobehub/lobe-chat/commit/f74befa))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.164](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.163...v2.0.0-next.164)
|
||||
|
||||
<sup>Released on **2025-12-08**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **profile**: Add mobile responsive layout and signup improvements.
|
||||
- **misc**: Update link handling in PlanTag component to use react-router-dom.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **profile**: Add mobile responsive layout and signup improvements, closes [#10669](https://github.com/lobehub/lobe-chat/issues/10669) ([1afd471](https://github.com/lobehub/lobe-chat/commit/1afd471))
|
||||
- **misc**: Update link handling in PlanTag component to use react-router-dom, closes [#10673](https://github.com/lobehub/lobe-chat/issues/10673) ([3aceeb6](https://github.com/lobehub/lobe-chat/commit/3aceeb6))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.163](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.162...v2.0.0-next.163)
|
||||
|
||||
<sup>Released on **2025-12-06**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Add smooth scroll to top on 'More' button click in Title component.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Add smooth scroll to top on 'More' button click in Title component, closes [#10178](https://github.com/lobehub/lobe-chat/issues/10178) ([5ad4f0c](https://github.com/lobehub/lobe-chat/commit/5ad4f0c))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.0.0-next.162](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.161...v2.0.0-next.162)
|
||||
|
||||
<sup>Released on **2025-12-05**</sup>
|
||||
|
||||
@@ -19,6 +19,7 @@ read @.cursor/rules/project-structure.mdc
|
||||
- git commit message should prefix with gitmoji
|
||||
- git branch name format example: tj/feat/feature-name
|
||||
- use .github/PULL_REQUEST_TEMPLATE.md to generate pull request description
|
||||
- PR titles starting with `✨ feat/` or `🐛 fix` will trigger the release workflow upon merge. Only use these prefixes for significant user-facing feature changes or bug fixes
|
||||
|
||||
### Package Management
|
||||
|
||||
@@ -44,6 +45,8 @@ see @.cursor/rules/typescript.mdc
|
||||
- wrap the file path in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` etc to run tests, this will run all tests and cost about 10mins
|
||||
- If trying to fix the same test twice, but still failed, stop and ask for help.
|
||||
- **Prefer `vi.spyOn` over `vi.mock`**: When mocking modules or functions, prefer using `vi.spyOn` to mock specific functions rather than `vi.mock` to mock entire modules. This approach is more targeted, easier to maintain, and allows for better control over mock behavior in individual tests.
|
||||
- **Tests must pass type check**: After writing or modifying tests, run `bun run type-check` to ensure there are no type errors. Tests should pass both runtime execution and TypeScript type checking.
|
||||
|
||||
### Typecheck
|
||||
|
||||
@@ -55,7 +58,7 @@ see @.cursor/rules/typescript.mdc
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` and `locales/en-US/namespace.json` locales file only for dev preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
|
||||
## Linear Issue Management
|
||||
## Linear Issue Management (ignore if not installed linear mcp)
|
||||
|
||||
When working with Linear issues:
|
||||
|
||||
|
||||
+58
-51
@@ -8,29 +8,24 @@ ARG USE_CN_MIRROR
|
||||
|
||||
ENV DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
RUN \
|
||||
# If you want to build docker in China, build with --build-arg USE_CN_MIRROR=true
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
|
||||
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"; \
|
||||
fi \
|
||||
# Add required package
|
||||
&& apt update \
|
||||
&& apt install ca-certificates proxychains-ng -qy \
|
||||
# Prepare required package to distroless
|
||||
&& mkdir -p /distroless/bin /distroless/etc /distroless/etc/ssl/certs /distroless/lib \
|
||||
# Copy proxychains to distroless
|
||||
&& cp /usr/lib/$(arch)-linux-gnu/libproxychains.so.4 /distroless/lib/libproxychains.so.4 \
|
||||
&& cp /usr/lib/$(arch)-linux-gnu/libdl.so.2 /distroless/lib/libdl.so.2 \
|
||||
&& cp /usr/bin/proxychains4 /distroless/bin/proxychains \
|
||||
&& cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf \
|
||||
# Copy node to distroless
|
||||
&& cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6 \
|
||||
&& cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1 \
|
||||
&& cp /usr/local/bin/node /distroless/bin/node \
|
||||
# Copy CA certificates to distroless
|
||||
&& cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt \
|
||||
# Cleanup temp files
|
||||
&& rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then
|
||||
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"
|
||||
fi
|
||||
apt update
|
||||
apt install ca-certificates proxychains-ng -qy
|
||||
mkdir -p /distroless/bin /distroless/etc /distroless/etc/ssl/certs /distroless/lib
|
||||
cp /usr/lib/$(arch)-linux-gnu/libproxychains.so.4 /distroless/lib/libproxychains.so.4
|
||||
cp /usr/lib/$(arch)-linux-gnu/libdl.so.2 /distroless/lib/libdl.so.2
|
||||
cp /usr/bin/proxychains4 /distroless/bin/proxychains
|
||||
cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf
|
||||
cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6
|
||||
cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1
|
||||
cp /usr/local/bin/node /distroless/bin/node
|
||||
cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt
|
||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
|
||||
EOF
|
||||
|
||||
## Builder image, install all the dependencies and build the app
|
||||
FROM base AS builder
|
||||
@@ -86,35 +81,45 @@ WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY .npmrc ./
|
||||
COPY packages ./packages
|
||||
# bring in desktop workspace manifest so pnpm can resolve it
|
||||
COPY apps/desktop/src/main/package.json ./apps/desktop/src/main/package.json
|
||||
|
||||
RUN \
|
||||
# If you want to build docker in China, build with --build-arg USE_CN_MIRROR=true
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
|
||||
export SENTRYCLI_CDNURL="https://npmmirror.com/mirrors/sentry-cli"; \
|
||||
npm config set registry "https://registry.npmmirror.com/"; \
|
||||
echo 'canvas_binary_host_mirror=https://npmmirror.com/mirrors/canvas' >> .npmrc; \
|
||||
fi \
|
||||
# Set the registry for corepack
|
||||
&& export COREPACK_NPM_REGISTRY=$(npm config get registry | sed 's/\/$//') \
|
||||
# Update corepack to latest (nodejs/corepack#612)
|
||||
&& npm i -g corepack@latest \
|
||||
# Enable corepack
|
||||
&& corepack enable \
|
||||
# Use pnpm for corepack
|
||||
&& corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json) \
|
||||
# Install the dependencies
|
||||
&& pnpm i \
|
||||
# Add db migration dependencies
|
||||
&& mkdir -p /deps \
|
||||
&& cd /deps \
|
||||
&& pnpm init \
|
||||
&& pnpm add pg drizzle-orm
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then
|
||||
export SENTRYCLI_CDNURL="https://npmmirror.com/mirrors/sentry-cli"
|
||||
npm config set registry "https://registry.npmmirror.com/"
|
||||
echo 'canvas_binary_host_mirror=https://npmmirror.com/mirrors/canvas' >> .npmrc
|
||||
fi
|
||||
export COREPACK_NPM_REGISTRY=$(npm config get registry | sed 's/\/$//')
|
||||
npm i -g corepack@latest
|
||||
corepack enable
|
||||
corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json)
|
||||
pnpm i
|
||||
mkdir -p /deps
|
||||
cd /deps
|
||||
pnpm init
|
||||
pnpm add pg drizzle-orm
|
||||
EOF
|
||||
|
||||
COPY . .
|
||||
|
||||
# run build standalone for docker version
|
||||
RUN npm run build:docker
|
||||
|
||||
# Prepare desktop export assets for Electron packaging (if generated)
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ -d "/app/out" ]; then
|
||||
mkdir -p /app/apps/desktop/dist/next
|
||||
cp -a /app/out/. /app/apps/desktop/dist/next/
|
||||
echo "✅ Copied Next export output into /app/apps/desktop/dist/next"
|
||||
else
|
||||
echo "ℹ️ No Next export output found at /app/out, creating empty directory"
|
||||
mkdir -p /app/apps/desktop/dist/next
|
||||
fi
|
||||
EOF
|
||||
|
||||
## Application image, copy all the files for production
|
||||
FROM busybox:latest AS app
|
||||
|
||||
@@ -123,6 +128,8 @@ COPY --from=base /distroless/ /
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder /app/.next/standalone /app/
|
||||
# Copy Next export output for desktop renderer
|
||||
COPY --from=builder /app/apps/desktop/dist/next /app/apps/desktop/dist/next
|
||||
|
||||
# Copy database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
@@ -137,12 +144,12 @@ COPY --from=builder /deps/node_modules/drizzle-orm /app/node_modules/drizzle-orm
|
||||
# Copy server launcher
|
||||
COPY --from=builder /app/scripts/serverLauncher/startServer.js /app/startServer.js
|
||||
|
||||
RUN \
|
||||
# Add nextjs:nodejs to run the app
|
||||
addgroup -S -g 1001 nodejs \
|
||||
&& adduser -D -G nodejs -H -S -h /app -u 1001 nextjs \
|
||||
# Set permission for nextjs:nodejs
|
||||
&& chown -R nextjs:nodejs /app /etc/proxychains4.conf
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
addgroup -S -g 1001 nodejs
|
||||
adduser -D -G nodejs -H -S -h /app -u 1001 nextjs
|
||||
chown -R nextjs:nodejs /app /etc/proxychains4.conf
|
||||
EOF
|
||||
|
||||
## Production image, copy all the files and run next
|
||||
FROM scratch
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -345,12 +345,12 @@ In addition, these plugins are not limited to news aggregation, but can also ext
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| Recent Submits | Description |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-11-28**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [SEO](https://lobechat.com/discover/plugin/SEO)<br/><sup>By **orrenprunckun** on **2025-11-14**</sup> | Enter any URL and keyword and get an On-Page SEO analysis & insights!<br/>`seo` |
|
||||
| [Shopping tools](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2025-10-27**</sup> | Search for products on eBay & AliExpress, find eBay events & coupons. Get prompt examples.<br/>`shopping` `e-bay` `ali-express` `coupons` |
|
||||
| [Web](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | Smart web search that reads and analyzes pages to deliver comprehensive answers from Google results.<br/>`web` `search` |
|
||||
| Recent Submits | Description |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Video Captions](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | Convert Youtube links into transcribed text, enable asking questions, create chapters, and summarize its content.<br/>`video-to-text` `youtube` |
|
||||
| [WeatherGPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | Get current weather information for a specific location.<br/>`weather` |
|
||||
| [Git OSS Stats](https://lobechat.com/discover/plugin/gitUserRepoStats)<br/><sup>By **yunwei37** on **2025-12-13**</sup> | Dynamically generate and analyze stats and history for OSS repos and developers.<br/>`github` `oss` |
|
||||
| [Questmate Forms](https://lobechat.com/discover/plugin/questmate)<br/><sup>By **questmate** on **2025-12-13**</sup> | Create forms, checklists and workflows (we call 'em Quests!) that you can assign, schedule or make public.<br/>`forms` `checklists` `productivity` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
@@ -387,8 +387,8 @@ Our marketplace is not just a showcase platform but also a collaborative space.
|
||||
| Recent Submits | Description |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Turtle Soup Host](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | A turtle soup host needs to provide the scenario, the complete story (truth of the event), and the key point (the condition for guessing correctly).<br/>`turtle-soup` `reasoning` `interaction` `puzzle` `role-playing` |
|
||||
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
|
||||
| [Academic Writing Assistant](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | Expert in academic research paper writing and formal documentation<br/>`academic-writing` `research` `formal-style` |
|
||||
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
|
||||
| [Minecraft Senior Developer](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | Expert in advanced Java development and Minecraft mod and server plugin development<br/>`development` `programming` `minecraft` `java` |
|
||||
|
||||
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
|
||||
|
||||
+7
-7
@@ -338,12 +338,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-11-28**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [SEO](https://lobechat.com/discover/plugin/SEO)<br/><sup>By **orrenprunckun** on **2025-11-14**</sup> | 输入任何 URL 和关键词,获取页面 SEO 分析和见解!<br/>`seo` |
|
||||
| [购物工具](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2025-10-27**</sup> | 在 eBay 和 AliExpress 上搜索产品,查找 eBay 活动和优惠券。获取快速示例。<br/>`购物` `e-bay` `ali-express` `优惠券` |
|
||||
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
|
||||
| 最近新增 | 描述 |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| [视频字幕](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | 将 Youtube 链接转换为转录文本,使其能够提问,创建章节,并总结其内容。<br/>`视频转文字` `you-tube` |
|
||||
| [天气 GPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | 获取特定位置的当前天气信息。<br/>`天气` |
|
||||
| [Git OSS Stats](https://lobechat.com/discover/plugin/gitUserRepoStats)<br/><sup>By **yunwei37** on **2025-12-13**</sup> | 动态生成和分析开源软件仓库和开发者的统计数据和历史记录。<br/>`github` `oss` |
|
||||
| [Questmate Forms](https://lobechat.com/discover/plugin/questmate)<br/><sup>By **questmate** on **2025-12-13**</sup> | 创建表单、清单和工作流程(我们称之为任务!),您可以分配、安排或公开。<br/>`表单` `清单` `生产力` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
@@ -376,8 +376,8 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
|
||||
| 最近新增 | 描述 |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| [海龟汤主持人](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | 一个海龟汤主持人,需要自己提供汤面,汤底与关键点(猜中的判定条件)。<br/>`海龟汤` `推理` `互动` `谜题` `角色扮演` |
|
||||
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
|
||||
| [学术写作助手](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | 专业的学术研究论文写作和正式文档编写专家<br/>`学术写作` `研究` `正式风格` |
|
||||
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
|
||||
| [Minecraft 资深开发者](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | 擅长高级 Java 开发及 Minecraft 开发<br/>`开发` `编程` `minecraft` `java` |
|
||||
|
||||
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').prettier;
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').remarklint;
|
||||
@@ -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/**
|
||||
@@ -0,0 +1,9 @@
|
||||
const config = require('@lobehub/lint').stylelint;
|
||||
|
||||
module.exports = {
|
||||
...config,
|
||||
rules: {
|
||||
'selector-id-pattern': null,
|
||||
...config.rules,
|
||||
},
|
||||
};
|
||||
+38
-42
@@ -156,24 +156,26 @@ apps/desktop/src/main/
|
||||
- 事件广播:向渲染进程通知授权状态变化
|
||||
|
||||
```typescript
|
||||
// 认证流程示例
|
||||
@ipcClientEvent('requestAuthorization')
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
// 生成状态参数防止 CSRF 攻击
|
||||
this.authRequestState = crypto.randomBytes(16).toString('hex');
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
// 构建授权 URL
|
||||
const authUrl = new URL('/oidc/auth', remoteUrl);
|
||||
authUrl.search = querystring.stringify({
|
||||
client_id: 'lobe-chat',
|
||||
response_type: 'code',
|
||||
redirect_uri: `${protocolPrefix}://auth/callback`,
|
||||
scope: 'openid profile',
|
||||
state: this.authRequestState,
|
||||
});
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
static override groupName = 'auth';
|
||||
|
||||
// 在默认浏览器中打开授权 URL
|
||||
await shell.openExternal(authUrl.toString());
|
||||
@IpcMethod()
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
this.authRequestState = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const authUrl = new URL('/oidc/auth', remoteUrl);
|
||||
authUrl.search = querystring.stringify({
|
||||
client_id: 'lobe-chat',
|
||||
redirect_uri: `${protocolPrefix}://auth/callback`,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile',
|
||||
state: this.authRequestState,
|
||||
});
|
||||
|
||||
await shell.openExternal(authUrl.toString());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,20 +269,27 @@ export class ShortcutManager {
|
||||
- 注入 App 实例
|
||||
|
||||
```typescript
|
||||
// 控制器基类和装饰器
|
||||
import { ControllerModule, IpcMethod, IpcServerMethod } from '@/controllers'
|
||||
|
||||
export class ControllerModule implements IControllerModule {
|
||||
constructor(public app: App) {
|
||||
this.app = app;
|
||||
this.app = app
|
||||
}
|
||||
}
|
||||
|
||||
// IPC 客户端事件装饰器
|
||||
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
|
||||
ipcDecorator(method, 'client');
|
||||
export class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows' // must be readonly
|
||||
|
||||
// IPC 服务器事件装饰器
|
||||
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
|
||||
ipcDecorator(method, 'server');
|
||||
@IpcMethod()
|
||||
openSettingsWindow(params?: OpenSettingsWindowOptions) {
|
||||
// ...
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
handleServerCommand(payload: any) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **IoC 容器**:
|
||||
@@ -346,26 +355,13 @@ makeSureDirExist(storagePath);
|
||||
- 自动映射控制器方法到 IPC 事件
|
||||
|
||||
```typescript
|
||||
// IPC 事件初始化
|
||||
private initializeIPCEvents() {
|
||||
// 注册客户端事件处理程序
|
||||
this.ipcClientEventMap.forEach((eventInfo, key) => {
|
||||
ipcMain.handle(key, async (e, ...data) => {
|
||||
return await eventInfo.controller[eventInfo.methodName](...data);
|
||||
});
|
||||
});
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
// 注册服务器事件处理程序
|
||||
const ipcServerEvents = {} as ElectronIPCEventHandler;
|
||||
this.ipcServerEventMap.forEach((eventInfo, key) => {
|
||||
ipcServerEvents[key] = async (payload) => {
|
||||
return await eventInfo.controller[eventInfo.methodName](payload);
|
||||
};
|
||||
});
|
||||
// 渲染进程中使用 type-safe proxy 调用主进程方法
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
// 创建 IPC 服务器
|
||||
this.ipcServer = new ElectronIPCServer(name, ipcServerEvents);
|
||||
}
|
||||
await ipc.localSystem.readLocalFile({ path });
|
||||
await ipc.system.updateLocale('en-US');
|
||||
```
|
||||
|
||||
2. **事件广播**:
|
||||
|
||||
+42
-6
@@ -32,7 +32,7 @@ pnpm install-isolated
|
||||
pnpm electron:dev
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
pnpm type-check
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
@@ -66,9 +66,9 @@ cp .env.desktop .env
|
||||
pnpm electron:dev # Start with hot reload
|
||||
|
||||
# 2. Code Quality
|
||||
pnpm lint # ESLint checking
|
||||
pnpm format # Prettier formatting
|
||||
pnpm typecheck # TypeScript validation
|
||||
pnpm lint # ESLint checking
|
||||
pnpm format # Prettier formatting
|
||||
pnpm type-check # TypeScript validation
|
||||
|
||||
# 3. Testing
|
||||
pnpm test # Run Vitest tests
|
||||
@@ -183,10 +183,18 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
|
||||
#### 🔌 Dependency Injection & Event System
|
||||
|
||||
- **IoC Container** - WeakMap-based container for decorated controller methods
|
||||
- **Decorator Registration** - `@ipcClientEvent` and `@ipcServerEvent` decorators
|
||||
- **Typed IPC Decorators** - `@IpcMethod` and `@IpcServerMethod` wire controller methods into type-safe channels
|
||||
- **Automatic Event Mapping** - Events registered during controller loading
|
||||
- **Service Locator** - Type-safe service and controller retrieval
|
||||
|
||||
##### 🧠 Type-Safe IPC Flow
|
||||
|
||||
- **Async Context Propagation** - `src/main/utils/ipc/base.ts` captures the `IpcContext` with `AsyncLocalStorage`, so controller logic can call `getIpcContext()` anywhere inside an IPC handler without explicitly threading arguments.
|
||||
- **Service Constructors Registry** - `src/main/controllers/registry.ts` exports `controllerIpcConstructors`, `DesktopIpcServices`, and `DesktopServerIpcServices`, enabling automatic typing of both renderer and server IPC proxies.
|
||||
- **Renderer Proxy Helper** - `src/utils/electron/ipc.ts` exposes `ensureElectronIpc()` which lazily builds a proxy on top of `window.electronAPI.invoke`, giving React/Next.js code a type-safe API surface without exposing raw proxies in preload.
|
||||
- **Server Proxy Helper** - `src/server/modules/ElectronIPCClient/index.ts` mirrors the same typing strategy for the Next.js server runtime, providing a dedicated proxy for `@IpcServerMethod` handlers.
|
||||
- **Shared Typings Package** - `apps/desktop/src/main/exports.d.ts` augments `@lobechat/electron-client-ipc` so every package can consume `DesktopIpcServices` without importing desktop business code directly.
|
||||
|
||||
#### 🪟 Window Management
|
||||
|
||||
- **Theme-Aware Windows** - Automatic adaptation to system dark/light mode
|
||||
@@ -235,6 +243,7 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
|
||||
|
||||
#### 🎮 Controller Pattern
|
||||
|
||||
- **Typed IPC Decorators** - Controllers extend `ControllerModule` and expose renderer methods via `@IpcMethod`
|
||||
- **IPC Event Handling** - Processes events from renderer with decorator-based registration
|
||||
- **Lifecycle Hooks** - `beforeAppReady` and `afterAppReady` for initialization phases
|
||||
- **Type-Safe Communication** - Strong typing for all IPC events and responses
|
||||
@@ -256,6 +265,33 @@ The `App.ts` class orchestrates the entire application lifecycle through key pha
|
||||
- **Context Awareness** - Events include sender context for window-specific operations
|
||||
- **Error Propagation** - Centralized error handling with proper status codes
|
||||
|
||||
##### 🧩 Renderer IPC Helper
|
||||
|
||||
Renderer code uses a lightweight proxy generated at runtime to keep IPC calls type-safe without exposing raw Electron objects through `contextBridge`. Use the helper exported from `src/utils/electron/ipc.ts` to access the main-process services:
|
||||
|
||||
```ts
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
await ipc.windows.openSettingsWindow({ tab: 'provider' });
|
||||
```
|
||||
|
||||
The helper internally builds a proxy on top of `window.electronAPI.invoke`, so no proxy objects need to be cloned across the preload boundary.
|
||||
|
||||
##### 🖥️ Server IPC Helper
|
||||
|
||||
Next.js (Node) modules use the same proxy pattern via `ensureElectronServerIpc` from `src/server/modules/ElectronIPCClient`. It lazily wraps the socket-based `ElectronIpcClient` so server code can call controllers with full type safety:
|
||||
|
||||
```ts
|
||||
import { ensureElectronServerIpc } from '@/server/modules/ElectronIPCClient';
|
||||
|
||||
const ipc = ensureElectronServerIpc();
|
||||
const dbPath = await ipc.system.getDatabasePath();
|
||||
await ipc.upload.deleteFiles(['foo.txt']);
|
||||
```
|
||||
|
||||
All server methods are declared via `@IpcServerMethod` and live in dedicated controller classes, keeping renderer typings clean.
|
||||
|
||||
#### 🛡️ Security Features
|
||||
|
||||
- **OAuth 2.0 + PKCE** - Secure authentication with state parameter validation
|
||||
@@ -277,7 +313,7 @@ tests/ # Integration tests
|
||||
```bash
|
||||
pnpm test # Run all tests
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm typecheck # Type validation
|
||||
pnpm type-check # Type validation
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
@@ -32,7 +32,7 @@ pnpm install-isolated
|
||||
pnpm electron:dev
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
pnpm type-check
|
||||
|
||||
# 运行测试
|
||||
pnpm test
|
||||
@@ -66,9 +66,9 @@ cp .env.desktop .env
|
||||
pnpm electron:dev # 启动热重载开发服务器
|
||||
|
||||
# 2. 代码质量
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm format # Prettier 格式化
|
||||
pnpm typecheck # TypeScript 验证
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm format # Prettier 格式化
|
||||
pnpm type-check # TypeScript 验证
|
||||
|
||||
# 3. 测试
|
||||
pnpm test # 运行 Vitest 测试
|
||||
@@ -183,7 +183,7 @@ src/main/core/
|
||||
#### 🔌 依赖注入和事件系统
|
||||
|
||||
- **IoC 容器** - 基于 WeakMap 的装饰控制器方法容器
|
||||
- **装饰器注册** - `@ipcClientEvent` 和 `@ipcServerEvent` 装饰器
|
||||
- **装饰器注册** - `@IpcMethod` 和 `@IpcServerMethod` 装饰器
|
||||
- **自动事件映射** - 控制器加载期间注册的事件
|
||||
- **服务定位器** - 类型安全的服务和控制器检索
|
||||
|
||||
@@ -256,6 +256,31 @@ src/main/core/
|
||||
- **上下文感知** - 事件包含用于窗口特定操作的发送者上下文
|
||||
- **错误传播** - 具有适当状态码的集中错误处理
|
||||
|
||||
##### 🧩 渲染器 IPC 助手
|
||||
|
||||
渲染端通过 `src/utils/electron/ipc.ts` 提供的 `ensureElectronIpc` 获得一个运行时代理,无需在 preload 中暴露 Proxy 对象即可获得类型安全的调用体验:
|
||||
|
||||
```ts
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
await ipc.windows.openSettingsWindow({ tab: 'provider' });
|
||||
```
|
||||
|
||||
##### 🖥️ Server IPC 助手
|
||||
|
||||
Next.js 服务端模块可通过 `ensureElectronServerIpc`(位于 `src/server/modules/ElectronIPCClient`)获得同样的类型安全代理,并复用 socket IPC 通道:
|
||||
|
||||
```ts
|
||||
import { ensureElectronServerIpc } from '@/server/modules/ElectronIPCClient';
|
||||
|
||||
const ipc = ensureElectronServerIpc();
|
||||
const path = await ipc.system.getDatabasePath();
|
||||
await ipc.upload.deleteFiles(['foo.txt']);
|
||||
```
|
||||
|
||||
所有 `@IpcServerMethod` 方法都放在独立的控制器中,这样渲染端的类型推导不会包含这些仅供服务器调用的通道。
|
||||
|
||||
#### 🛡️ 安全功能
|
||||
|
||||
- **OAuth 2.0 + PKCE** - 具有状态参数验证的安全认证
|
||||
@@ -277,7 +302,7 @@ tests/ # 集成测试
|
||||
```bash
|
||||
pnpm test # 运行所有测试
|
||||
pnpm test:watch # 监视模式
|
||||
pnpm typecheck # 类型验证
|
||||
pnpm type-check # 类型验证
|
||||
```
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~common': resolve(__dirname, 'src/common'),
|
||||
'@': resolve(__dirname, 'src/main'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,21 +11,30 @@
|
||||
"author": "LobeHub",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build": "electron-vite build",
|
||||
"build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"build:linux": "npm run build && electron-builder --linux --config electron-builder.js --publish never",
|
||||
"build:mac": "npm run build && electron-builder --mac --config electron-builder.js --publish never",
|
||||
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.js --publish never",
|
||||
"build:win": "npm run build && electron-builder --win --config electron-builder.js --publish never",
|
||||
"dev": "electron-vite dev",
|
||||
"electron:dev": "electron-vite dev",
|
||||
"electron:run-unpack": "electron .",
|
||||
"format": "prettier --write ",
|
||||
"i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"install-isolated": "pnpm install",
|
||||
"lint": "eslint --cache ",
|
||||
"lint": "npm run lint:ts && npm run lint:style && npm run type-check && npm run lint:circular",
|
||||
"lint:circular": "npm run lint:circular:main && npm run lint:circular:packages",
|
||||
"lint:circular:main": "dpdm src/**/*.ts --no-warning --no-tree --exit-code circular:1 --no-progress -T true --skip-dynamic-imports circular",
|
||||
"lint:circular:packages": "dpdm packages/**/src/**/*.ts --no-warning --no-tree --exit-code circular:1 --no-progress -T true --skip-dynamic-imports circular",
|
||||
"lint:md": "remark . --silent --output",
|
||||
"lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"lint:ts": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"start": "electron-vite preview",
|
||||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "vitest --run",
|
||||
"type-check": "tsgo --noEmit -p tsconfig.json",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -33,7 +42,8 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"get-port-please": "^3.2.0",
|
||||
"pdfjs-dist": "4.10.38"
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"superjson": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -41,16 +51,18 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
"@types/async-retry": "^1.4.9",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@typescript/native-preview": "7.0.0-dev.20250711.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251210.1",
|
||||
"async-retry": "^1.3.3",
|
||||
"consola": "^3.4.2",
|
||||
"cookie": "^1.1.1",
|
||||
@@ -61,24 +73,28 @@
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-vite": "^4.0.1",
|
||||
"eslint": "^8.57.1",
|
||||
"execa": "^9.6.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"happy-dom": "^20.0.11",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next": "^25.7.2",
|
||||
"just-diff": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"prettier": "^3.7.4",
|
||||
"remark-cli": "^12.0.1",
|
||||
"resolve": "^1.22.11",
|
||||
"semver": "^7.7.3",
|
||||
"set-cookie-parser": "^2.7.2",
|
||||
"tsx": "^4.20.6",
|
||||
"stylelint": "^15.11.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.2.4",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -2,4 +2,5 @@ packages:
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '.'
|
||||
|
||||
@@ -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 安装目录
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
|
||||
@@ -25,7 +25,7 @@ export const defaultProxySettings: NetworkProxySettings = {
|
||||
* 存储默认值
|
||||
*/
|
||||
export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
dataSyncConfig: { storageMode: 'local' },
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
encryptedTokens: {},
|
||||
locale: 'auto',
|
||||
networkProxy: defaultProxySettings,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { URL } from 'node:url';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:AuthCtr');
|
||||
@@ -17,6 +17,7 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
* Implements OAuth authorization flow using intermediate page + polling mechanism
|
||||
*/
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
static override readonly groupName = 'auth';
|
||||
/**
|
||||
* Remote server configuration controller
|
||||
*/
|
||||
@@ -56,7 +57,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Request OAuth authorization
|
||||
*/
|
||||
@ipcClientEvent('requestAuthorization')
|
||||
@IpcMethod()
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
// Clear any old authorization state
|
||||
this.clearAuthorizationState();
|
||||
@@ -119,7 +120,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Request Market OAuth authorization (desktop)
|
||||
*/
|
||||
@ipcClientEvent('requestMarketAuthorization')
|
||||
@IpcMethod()
|
||||
async requestMarketAuthorization(params: MarketAuthorizationParams) {
|
||||
const { authUrl } = params;
|
||||
|
||||
@@ -562,7 +563,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
// Hash codeVerifier using SHA-256
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(codeVerifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data as unknown as NodeJS.BufferSource);
|
||||
|
||||
// Convert hash result to base64url encoding
|
||||
const challenge = Buffer.from(digest)
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { findMatchingRoute } from '~common/routes';
|
||||
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
} from '@/appBrowsers';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import { AppBrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
|
||||
import { getIpcContext } from '@/utils/ipc';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, shortcut } from './index';
|
||||
import { ControllerModule, IpcMethod, shortcut } from './index';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@shortcut('showApp')
|
||||
async toggleMainWindow() {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
@ipcClientEvent('openSettingsWindow')
|
||||
@IpcMethod()
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions: OpenSettingsWindowOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
@@ -43,8 +42,8 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
const fullPath = `/settings${subPath}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl(fullPath);
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: fullPath });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -53,26 +52,32 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('closeWindow')
|
||||
closeWindow(data: undefined, sender: IpcClientEventSender) {
|
||||
this.app.browserManager.closeWindow(sender.identifier);
|
||||
@IpcMethod()
|
||||
closeWindow() {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.closeWindow(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
@ipcClientEvent('minimizeWindow')
|
||||
minimizeWindow(data: undefined, sender: IpcClientEventSender) {
|
||||
this.app.browserManager.minimizeWindow(sender.identifier);
|
||||
@IpcMethod()
|
||||
minimizeWindow() {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.minimizeWindow(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
@ipcClientEvent('maximizeWindow')
|
||||
maximizeWindow(data: undefined, sender: IpcClientEventSender) {
|
||||
this.app.browserManager.maximizeWindow(sender.identifier);
|
||||
@IpcMethod()
|
||||
maximizeWindow() {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
this.app.browserManager.maximizeWindow(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route interception requests
|
||||
* Responsible for handling route interception requests from the renderer process
|
||||
*/
|
||||
@ipcClientEvent('interceptRoute')
|
||||
@IpcMethod()
|
||||
async interceptRoute(params: InterceptRouteParams) {
|
||||
const { path, source } = params;
|
||||
console.log(
|
||||
@@ -115,7 +120,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
/**
|
||||
* Create a new multi-instance window
|
||||
*/
|
||||
@ipcClientEvent('createMultiInstanceWindow')
|
||||
@IpcMethod()
|
||||
async createMultiInstanceWindow(params: {
|
||||
path: string;
|
||||
templateId: WindowTemplateIdentifiers;
|
||||
@@ -149,7 +154,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
/**
|
||||
* Get all windows by template
|
||||
*/
|
||||
@ipcClientEvent('getWindowsByTemplate')
|
||||
@IpcMethod()
|
||||
async getWindowsByTemplate(templateId: string) {
|
||||
try {
|
||||
const windowIds = this.app.browserManager.getWindowsByTemplate(templateId);
|
||||
@@ -169,7 +174,7 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
/**
|
||||
* Close all windows by template
|
||||
*/
|
||||
@ipcClientEvent('closeWindowsByTemplate')
|
||||
@IpcMethod()
|
||||
async closeWindowsByTemplate(templateId: string) {
|
||||
try {
|
||||
this.app.browserManager.closeWindowsByTemplate(templateId);
|
||||
@@ -191,4 +196,12 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
const browser = this.app.browserManager.retrieveByIdentifier(targetWindow);
|
||||
browser.show();
|
||||
}
|
||||
|
||||
private withSenderIdentifier(fn: (identifier: string) => void) {
|
||||
const context = getIpcContext();
|
||||
if (!context) return;
|
||||
const identifier = this.app.browserManager.getIdentifierByWebContents(context.sender);
|
||||
if (!identifier) return;
|
||||
fn(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class DevtoolsCtr extends ControllerModule {
|
||||
@ipcClientEvent('openDevtools')
|
||||
static override readonly groupName = 'devtools';
|
||||
|
||||
@IpcMethod()
|
||||
async openDevtools() {
|
||||
const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
|
||||
devtoolsBrowser.show();
|
||||
|
||||
@@ -30,19 +30,20 @@ import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:LocalFileCtr');
|
||||
|
||||
export default class LocalFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'localSystem';
|
||||
private get searchService() {
|
||||
return this.app.getService(FileSearchService);
|
||||
}
|
||||
|
||||
// ==================== File Operation ====================
|
||||
|
||||
@ipcClientEvent('openLocalFile')
|
||||
@IpcMethod()
|
||||
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
|
||||
error?: string;
|
||||
success: boolean;
|
||||
@@ -59,7 +60,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('openLocalFolder')
|
||||
@IpcMethod()
|
||||
async handleOpenLocalFolder({ path: targetPath, isDirectory }: OpenLocalFolderParams): Promise<{
|
||||
error?: string;
|
||||
success: boolean;
|
||||
@@ -77,7 +78,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('readLocalFiles')
|
||||
@IpcMethod()
|
||||
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
|
||||
logger.debug('Starting batch file reading:', { count: paths.length });
|
||||
|
||||
@@ -94,7 +95,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return results;
|
||||
}
|
||||
|
||||
@ipcClientEvent('readLocalFile')
|
||||
@IpcMethod()
|
||||
async readFile({
|
||||
path: filePath,
|
||||
loc,
|
||||
@@ -192,7 +193,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('listLocalFiles')
|
||||
@IpcMethod()
|
||||
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
|
||||
logger.debug('Listing directory contents:', { dirPath });
|
||||
|
||||
@@ -250,7 +251,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('moveLocalFiles')
|
||||
@IpcMethod()
|
||||
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
|
||||
logger.debug('Starting batch file move:', { itemsCount: items?.length });
|
||||
|
||||
@@ -355,7 +356,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return results;
|
||||
}
|
||||
|
||||
@ipcClientEvent('renameLocalFile')
|
||||
@IpcMethod()
|
||||
async handleRenameFile({
|
||||
path: currentPath,
|
||||
newName,
|
||||
@@ -440,7 +441,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('writeLocalFile')
|
||||
@IpcMethod()
|
||||
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
|
||||
const logPrefix = `[Writing file ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
|
||||
@@ -485,7 +486,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
@IpcMethod()
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
@@ -523,7 +524,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('grepContent')
|
||||
@IpcMethod()
|
||||
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
@@ -639,7 +640,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('globLocalFiles')
|
||||
@IpcMethod()
|
||||
async handleGlobFiles({
|
||||
path: searchPath = process.cwd(),
|
||||
pattern,
|
||||
@@ -680,7 +681,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
// ==================== File Editing ====================
|
||||
|
||||
@ipcClientEvent('editLocalFile')
|
||||
@IpcMethod()
|
||||
async handleEditFile({
|
||||
file_path: filePath,
|
||||
new_string,
|
||||
|
||||
@@ -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 ``;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class MenuController extends ControllerModule {
|
||||
static override readonly groupName = 'menu';
|
||||
/**
|
||||
* Refresh menu
|
||||
*/
|
||||
@ipcClientEvent('refreshAppMenu')
|
||||
@IpcMethod()
|
||||
refreshAppMenu() {
|
||||
// Note: May need to decide whether to allow renderer process to refresh all menus based on specific circumstances
|
||||
return this.app.menuManager.refreshMenus();
|
||||
@@ -13,7 +14,7 @@ export default class MenuController extends ControllerModule {
|
||||
/**
|
||||
* Show context menu
|
||||
*/
|
||||
@ipcClientEvent('showContextMenu')
|
||||
@IpcMethod()
|
||||
showContextMenu(params: { data?: any; type: string }) {
|
||||
return this.app.menuManager.showContextMenu(params.type, params.data);
|
||||
}
|
||||
@@ -21,7 +22,7 @@ export default class MenuController extends ControllerModule {
|
||||
/**
|
||||
* Set development menu visibility
|
||||
*/
|
||||
@ipcClientEvent('setDevMenuVisibility')
|
||||
@IpcMethod()
|
||||
setDevMenuVisibility(visible: boolean) {
|
||||
// Call MenuManager method to rebuild application menu
|
||||
return this.app.menuManager.rebuildAppMenu({ showDevItems: visible });
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ProxyDispatcherManager,
|
||||
ProxyTestResult,
|
||||
} from '../modules/networkProxy';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:NetworkProxyCtr');
|
||||
@@ -21,10 +21,11 @@ const logger = createLogger('controllers:NetworkProxyCtr');
|
||||
* 处理桌面应用的网络代理相关功能
|
||||
*/
|
||||
export default class NetworkProxyCtr extends ControllerModule {
|
||||
static override readonly groupName = 'networkProxy';
|
||||
/**
|
||||
* 获取代理设置
|
||||
*/
|
||||
@ipcClientEvent('getProxySettings')
|
||||
@IpcMethod()
|
||||
async getDesktopSettings(): Promise<NetworkProxySettings> {
|
||||
try {
|
||||
const settings = this.app.storeManager.get(
|
||||
@@ -45,32 +46,30 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 设置代理配置
|
||||
*/
|
||||
@ipcClientEvent('setProxySettings')
|
||||
async setProxySettings(config: NetworkProxySettings): Promise<void> {
|
||||
@IpcMethod()
|
||||
async setProxySettings(config: Partial<NetworkProxySettings>): Promise<void> {
|
||||
try {
|
||||
// 验证配置
|
||||
const validation = ProxyConfigValidator.validate(config);
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 获取当前配置
|
||||
const currentConfig = this.app.storeManager.get(
|
||||
'networkProxy',
|
||||
defaultProxySettings,
|
||||
) as NetworkProxySettings;
|
||||
|
||||
// 检查是否有变化
|
||||
if (isEqual(currentConfig, config)) {
|
||||
// 合并配置并验证
|
||||
const newConfig = merge({}, currentConfig, config);
|
||||
|
||||
const validation = ProxyConfigValidator.validate(newConfig);
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (isEqual(currentConfig, newConfig)) {
|
||||
logger.debug('Proxy settings unchanged, skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const newConfig = merge({}, currentConfig, config);
|
||||
|
||||
// 应用代理设置
|
||||
await ProxyDispatcherManager.applyProxySettings(newConfig);
|
||||
|
||||
@@ -92,7 +91,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 测试代理连接
|
||||
*/
|
||||
@ipcClientEvent('testProxyConnection')
|
||||
@IpcMethod()
|
||||
async testProxyConnection(url: string): Promise<{ message?: string; success: boolean }> {
|
||||
try {
|
||||
const result = await ProxyConnectionTester.testConnection(url);
|
||||
@@ -112,7 +111,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 测试指定代理配置
|
||||
*/
|
||||
@ipcClientEvent('testProxyConfig')
|
||||
@IpcMethod()
|
||||
async testProxyConfig({
|
||||
config,
|
||||
testUrl,
|
||||
|
||||
@@ -2,16 +2,66 @@ import {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { Notification, app } from 'electron';
|
||||
import { Notification, app, systemPreferences } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
|
||||
import { getIpcContext } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:NotificationCtr');
|
||||
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
static override readonly groupName = 'notification';
|
||||
|
||||
@IpcMethod()
|
||||
async getNotificationPermissionStatus(): Promise<string> {
|
||||
if (!Notification.isSupported()) return 'denied';
|
||||
// Keep a stable status string for renderer-side UI mapping.
|
||||
// Screen3 expects macOS to return 'authorized' when granted.
|
||||
if (!macOS()) return 'authorized';
|
||||
|
||||
// Electron 38 no longer exposes `systemPreferences.getNotificationSettings()` in types,
|
||||
// and some runtimes don't provide it at all. Use the renderer's Notification.permission
|
||||
// as a reliable fallback.
|
||||
const context = getIpcContext();
|
||||
const sender = context?.sender;
|
||||
if (!sender) return 'notDetermined';
|
||||
const permission = await sender.executeJavaScript('Notification.permission', true);
|
||||
return permission === 'granted' ? 'authorized' : 'denied';
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestNotificationPermission(): Promise<void> {
|
||||
logger.debug('Requesting notification permission by sending a test notification');
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('System does not support desktop notifications');
|
||||
return;
|
||||
}
|
||||
|
||||
// On macOS, ask permission via Web Notification API first when possible.
|
||||
// This helps keep `Notification.permission` in sync for subsequent status checks.
|
||||
if (macOS()) {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
|
||||
await mainWindow.webContents.executeJavaScript('Notification.requestPermission()', true);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Notification.requestPermission() failed or is unavailable, continuing with test notification',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
body: 'LobeHub can now send you notifications.',
|
||||
title: 'Notification Permission',
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
/**
|
||||
* Set up desktop notifications after the application is ready
|
||||
*/
|
||||
@@ -51,7 +101,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
/**
|
||||
* Show system desktop notification (only when window is hidden)
|
||||
*/
|
||||
@ipcClientEvent('showDesktopNotification')
|
||||
@IpcMethod()
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
@@ -126,7 +176,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
/**
|
||||
* Check if the main window is hidden
|
||||
*/
|
||||
@ipcClientEvent('isMainWindowHidden')
|
||||
@IpcMethod()
|
||||
isMainWindowHidden(): boolean {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { URL } from 'node:url';
|
||||
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
/**
|
||||
* Non-retryable OIDC error codes
|
||||
@@ -39,32 +39,52 @@ const logger = createLogger('controllers:RemoteServerConfigCtr');
|
||||
* Used to manage custom remote LobeChat server configuration
|
||||
*/
|
||||
export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
static override readonly groupName = 'remoteServer';
|
||||
/**
|
||||
* Key used to store encrypted tokens in electron-store.
|
||||
*/
|
||||
private readonly encryptedTokensKey = 'encryptedTokens';
|
||||
|
||||
/**
|
||||
* Normalize legacy config that used local storageMode.
|
||||
* Local mode has been removed; fall back to cloud.
|
||||
*/
|
||||
private normalizeConfig = (config: DataSyncConfig): DataSyncConfig => {
|
||||
if (config.storageMode !== 'local') return config;
|
||||
|
||||
const nextConfig: DataSyncConfig = {
|
||||
...config,
|
||||
remoteServerUrl: config.remoteServerUrl || OFFICIAL_CLOUD_SERVER,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
this.app.storeManager.set('dataSyncConfig', nextConfig);
|
||||
|
||||
return nextConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get remote server configuration
|
||||
*/
|
||||
@ipcClientEvent('getRemoteServerConfig')
|
||||
@IpcMethod()
|
||||
async getRemoteServerConfig() {
|
||||
logger.debug('Getting remote server configuration');
|
||||
const { storeManager } = this.app;
|
||||
|
||||
const config: DataSyncConfig = storeManager.get('dataSyncConfig');
|
||||
const normalized = this.normalizeConfig(config);
|
||||
|
||||
logger.debug(
|
||||
`Remote server config: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
|
||||
`Remote server config: active=${normalized.active}, storageMode=${normalized.storageMode}, url=${normalized.remoteServerUrl}`,
|
||||
);
|
||||
|
||||
return config;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remote server configuration
|
||||
*/
|
||||
@ipcClientEvent('setRemoteServerConfig')
|
||||
@IpcMethod()
|
||||
async setRemoteServerConfig(config: Partial<DataSyncConfig>) {
|
||||
logger.info(
|
||||
`Setting remote server storageMode: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`,
|
||||
@@ -72,8 +92,9 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
const { storeManager } = this.app;
|
||||
const prev: DataSyncConfig = storeManager.get('dataSyncConfig');
|
||||
|
||||
// Save configuration
|
||||
storeManager.set('dataSyncConfig', { ...prev, ...config });
|
||||
// Save configuration with legacy local storage fallback
|
||||
const merged = this.normalizeConfig({ ...prev, ...config });
|
||||
storeManager.set('dataSyncConfig', merged);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -81,13 +102,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
/**
|
||||
* Clear remote server configuration
|
||||
*/
|
||||
@ipcClientEvent('clearRemoteServerConfig')
|
||||
@IpcMethod()
|
||||
async clearRemoteServerConfig() {
|
||||
logger.info('Clearing remote server configuration');
|
||||
const { storeManager } = this.app;
|
||||
|
||||
// Clear instance configuration
|
||||
storeManager.set('dataSyncConfig', { storageMode: 'local' });
|
||||
storeManager.set('dataSyncConfig', { active: false, storageMode: 'cloud' });
|
||||
|
||||
// Clear tokens (if any)
|
||||
await this.clearTokens();
|
||||
@@ -467,7 +488,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
async getRemoteServerUrl(config?: DataSyncConfig) {
|
||||
const dataConfig = config ? config : await this.getRemoteServerConfig();
|
||||
const dataConfig = this.normalizeConfig(config ? config : await this.getRemoteServerConfig());
|
||||
|
||||
return dataConfig.storageMode === 'cloud' ? OFFICIAL_CLOUD_SERVER : dataConfig.remoteServerUrl;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
ProxyTRPCRequestParams,
|
||||
ProxyTRPCRequestResult,
|
||||
ProxyTRPCStreamRequestParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { ProxyTRPCStreamRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { IpcMainEvent, WebContents, ipcMain } from 'electron';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
@@ -15,7 +11,7 @@ import { defaultProxySettings } from '@/const/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:RemoteServerSyncCtr');
|
||||
@@ -25,6 +21,7 @@ const logger = createLogger('controllers:RemoteServerSyncCtr');
|
||||
* For handling data synchronization with remote servers via IPC.
|
||||
*/
|
||||
export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
static override readonly groupName = 'remoteServerSync';
|
||||
/**
|
||||
* Cached instance of RemoteServerConfigCtr
|
||||
*/
|
||||
@@ -173,129 +170,12 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
if (requestBody) {
|
||||
clientReq.write(Buffer.from(requestBody));
|
||||
clientReq.write(Buffer.from(requestBody as string));
|
||||
}
|
||||
|
||||
clientReq.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to perform the actual request forwarding to the remote server.
|
||||
* Accepts arguments from IPC and returns response details.
|
||||
*/
|
||||
private async forwardRequest(args: {
|
||||
accessToken: string | null;
|
||||
body?: string | ArrayBuffer;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
remoteServerUrl: string;
|
||||
urlPath: string; // Pass the base URL
|
||||
}): Promise<{
|
||||
// Node headers type
|
||||
body: Buffer;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
status: number;
|
||||
statusText: string; // Return body as Buffer
|
||||
}> {
|
||||
const {
|
||||
urlPath,
|
||||
method,
|
||||
headers: originalHeaders,
|
||||
body: requestBody,
|
||||
accessToken,
|
||||
remoteServerUrl,
|
||||
} = args;
|
||||
|
||||
const pathname = new URL(urlPath, remoteServerUrl).pathname; // Extract pathname from URL
|
||||
const logPrefix = `[ForwardRequest ${method} ${pathname}]`; // Add prefix for easier correlation
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`${logPrefix} No access token provided`); // Enhanced log
|
||||
return {
|
||||
body: Buffer.from(''),
|
||||
headers: {},
|
||||
status: 401,
|
||||
statusText: 'Authentication required, missing token',
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Determine target URL and prepare request options
|
||||
const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
|
||||
const { requestOptions, requester } = this.createRequester({
|
||||
accessToken,
|
||||
headers: originalHeaders,
|
||||
method,
|
||||
url: targetUrl,
|
||||
});
|
||||
|
||||
// 2. Make the request and capture response
|
||||
return new Promise((resolve) => {
|
||||
const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
|
||||
const chunks: Buffer[] = [];
|
||||
clientRes.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
clientRes.on('end', () => {
|
||||
const responseBody = Buffer.concat(chunks);
|
||||
resolve({
|
||||
// These are IncomingHttpHeaders
|
||||
body: responseBody,
|
||||
|
||||
headers: clientRes.headers,
|
||||
|
||||
status: clientRes.statusCode || 500,
|
||||
statusText: clientRes.statusMessage || 'Unknown Status',
|
||||
});
|
||||
});
|
||||
|
||||
clientRes.on('error', (error) => {
|
||||
// Error during response streaming
|
||||
logger.error(
|
||||
`${logPrefix} Error reading response stream from ${targetUrl.toString()}:`,
|
||||
error,
|
||||
); // Enhanced log
|
||||
// Rejecting might be better, but we need to resolve the outer promise for proxyTRPCRequest
|
||||
resolve({
|
||||
body: Buffer.from(`Error reading response stream: ${error.message}`),
|
||||
headers: {},
|
||||
|
||||
status: 502,
|
||||
// Bad Gateway
|
||||
statusText: 'Error reading response stream',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
clientReq.on('error', (error) => {
|
||||
logger.error(`${logPrefix} Error forwarding request to ${targetUrl.toString()}:`, error); // Enhanced log
|
||||
// Reject or resolve with error status for the outer promise
|
||||
resolve({
|
||||
body: Buffer.from(`Error forwarding request: ${error.message}`),
|
||||
headers: {},
|
||||
|
||||
status: 502,
|
||||
// Bad Gateway
|
||||
statusText: 'Error forwarding request',
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Send request body if present
|
||||
if (requestBody) {
|
||||
if (typeof requestBody === 'string') {
|
||||
clientReq.write(requestBody, 'utf8'); // Specify encoding for strings
|
||||
} else if (requestBody instanceof ArrayBuffer) {
|
||||
clientReq.write(Buffer.from(requestBody)); // Convert ArrayBuffer to Buffer
|
||||
} else {
|
||||
// Should not happen based on type, but handle defensively
|
||||
logger.warn(`${logPrefix} Unsupported request body type received:`, typeof requestBody); // Enhanced log
|
||||
}
|
||||
}
|
||||
|
||||
clientReq.end(); // Finalize the request
|
||||
});
|
||||
}
|
||||
|
||||
private createRequester({
|
||||
headers,
|
||||
accessToken,
|
||||
@@ -340,144 +220,4 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
const requester = url.protocol === 'https:' ? https : http;
|
||||
return { requestOptions, requester };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the 'proxy-trpc-request' IPC call from the renderer process.
|
||||
* This method should be invoked by the ipcMain.handle setup in your main process entry point.
|
||||
*/
|
||||
@ipcClientEvent('proxyTRPCRequest')
|
||||
public async proxyTRPCRequest(args: ProxyTRPCRequestParams): Promise<ProxyTRPCRequestResult> {
|
||||
logger.debug('Received proxyTRPCRequest IPC call:', {
|
||||
headers: args.headers,
|
||||
method: args.method,
|
||||
urlPath: args.urlPath, // Log headers too for context
|
||||
});
|
||||
|
||||
const url = new URL(args.urlPath, 'http://a.b');
|
||||
const logPrefix = `[ProxyTRPC ${args.method} ${url.pathname}]`; // Prefix for this specific request
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
|
||||
logger.warn(
|
||||
`${logPrefix} Remote server sync not active or configured. Rejecting proxy request.`,
|
||||
); // Enhanced log
|
||||
return {
|
||||
body: Buffer.from('Remote server sync not active or configured').buffer,
|
||||
headers: {},
|
||||
|
||||
status: 503,
|
||||
// Service Unavailable
|
||||
statusText: 'Remote server sync not active or configured', // Return ArrayBuffer
|
||||
};
|
||||
}
|
||||
const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
|
||||
|
||||
// Get initial token
|
||||
let token = await this.remoteServerConfigCtr.getAccessToken();
|
||||
logger.debug(
|
||||
`${logPrefix} Initial token check: ${token ? 'Token exists' : 'No token found'}`,
|
||||
); // Added log
|
||||
|
||||
logger.info(`${logPrefix} Attempting to forward request...`); // Added log
|
||||
let response = await this.forwardRequest({ ...args, accessToken: token, remoteServerUrl });
|
||||
|
||||
// Handle 401: Refresh token and retry if necessary
|
||||
if (response.status === 401) {
|
||||
logger.info(`${logPrefix} Received 401 from forwarded request. Attempting token refresh.`); // Enhanced log
|
||||
const refreshed = await this.refreshTokenIfNeeded(logPrefix); // Pass prefix for context
|
||||
|
||||
if (refreshed) {
|
||||
const newToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (newToken) {
|
||||
logger.info(`${logPrefix} Token refreshed successfully, retrying the request.`); // Enhanced log
|
||||
response = await this.forwardRequest({
|
||||
...args,
|
||||
accessToken: newToken,
|
||||
remoteServerUrl,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
`${logPrefix} Token refresh reported success, but failed to retrieve new token. Keeping original 401 response.`,
|
||||
); // Enhanced log
|
||||
// Keep the original 401 response
|
||||
}
|
||||
} else {
|
||||
logger.error(`${logPrefix} Token refresh failed. Keeping original 401 response.`); // Enhanced log
|
||||
// Keep the original 401 response
|
||||
}
|
||||
}
|
||||
|
||||
// Convert headers and body to format defined in IPC event
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value !== undefined) {
|
||||
responseHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the final response, ensuring body is serializable (string or ArrayBuffer)
|
||||
const responseBody = response.body; // Buffer
|
||||
|
||||
// IMPORTANT: Check IPC limits. Large bodies might fail. Consider chunking if needed.
|
||||
// Convert Buffer to ArrayBuffer for IPC
|
||||
const finalBody = responseBody.buffer.slice(
|
||||
responseBody.byteOffset,
|
||||
responseBody.byteOffset + responseBody.byteLength,
|
||||
);
|
||||
|
||||
logger.debug(`${logPrefix} Forwarding successful. Status: ${response.status}`); // Added log
|
||||
return {
|
||||
body: finalBody as ArrayBuffer,
|
||||
headers: responseHeaders,
|
||||
status: response.status,
|
||||
statusText: response.statusText, // Return ArrayBuffer
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Unhandled error processing proxyTRPCRequest:`, error); // Enhanced log
|
||||
// Ensure a serializable error response is returned
|
||||
return {
|
||||
body: Buffer.from(
|
||||
`Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
).buffer,
|
||||
headers: {},
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error during proxy', // Return ArrayBuffer
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to refresh the access token by calling the RemoteServerConfigCtr.
|
||||
* @returns Whether token refresh was successful
|
||||
*/
|
||||
private async refreshTokenIfNeeded(callerLogPrefix: string = '[RefreshToken]'): Promise<boolean> {
|
||||
// Added prefix parameter
|
||||
const logPrefix = `${callerLogPrefix} [RefreshTrigger]`; // Updated prefix
|
||||
logger.debug(`${logPrefix} Entered refreshTokenIfNeeded.`);
|
||||
|
||||
try {
|
||||
logger.info(`${logPrefix} Triggering refreshAccessToken in RemoteServerConfigCtr.`);
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`${logPrefix} refreshAccessToken call completed successfully.`);
|
||||
return true;
|
||||
} else {
|
||||
logger.error(`${logPrefix} refreshAccessToken call failed: ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Exception occurred while calling refreshAccessToken:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources - No protocol handler to unregister anymore
|
||||
*/
|
||||
destroy() {
|
||||
logger.info('Destroying RemoteServerSyncCtr');
|
||||
// Nothing specific to clean up here regarding request handling now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ShellCommandCtr');
|
||||
|
||||
@@ -24,10 +24,11 @@ interface ShellProcess {
|
||||
}
|
||||
|
||||
export default class ShellCommandCtr extends ControllerModule {
|
||||
static override readonly groupName = 'shellCommand';
|
||||
// Shell process management
|
||||
private shellProcesses = new Map<string, ShellProcess>();
|
||||
|
||||
@ipcClientEvent('runCommand')
|
||||
@IpcMethod()
|
||||
async handleRunCommand({
|
||||
command,
|
||||
description,
|
||||
@@ -153,7 +154,7 @@ export default class ShellCommandCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('getCommandOutput')
|
||||
@IpcMethod()
|
||||
async handleGetCommandOutput({
|
||||
filter,
|
||||
shell_id,
|
||||
@@ -212,7 +213,7 @@ export default class ShellCommandCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
@ipcClientEvent('killCommand')
|
||||
@IpcMethod()
|
||||
async handleKillCommand({ shell_id }: KillCommandParams): Promise<KillCommandResult> {
|
||||
const logPrefix = `[killCommand: ${shell_id}]`;
|
||||
logger.debug(`${logPrefix} Attempting to kill shell`);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ShortcutUpdateResult } from '@/core/ui/ShortcutManager';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from '.';
|
||||
import { ControllerModule, IpcMethod } from '.';
|
||||
|
||||
export default class ShortcutController extends ControllerModule {
|
||||
static override readonly groupName = 'shortcut';
|
||||
/**
|
||||
* Get all shortcut configurations
|
||||
*/
|
||||
@ipcClientEvent('getShortcutsConfig')
|
||||
@IpcMethod()
|
||||
getShortcutsConfig() {
|
||||
return this.app.shortcutManager.getShortcutsConfig();
|
||||
}
|
||||
@@ -14,7 +15,7 @@ export default class ShortcutController extends ControllerModule {
|
||||
/**
|
||||
* Update a single shortcut configuration
|
||||
*/
|
||||
@ipcClientEvent('updateShortcutConfig')
|
||||
@IpcMethod()
|
||||
updateShortcutConfig({
|
||||
id,
|
||||
accelerator,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import { app, nativeTheme, shell, systemPreferences } from 'electron';
|
||||
import { macOS } from 'electron-is';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:SystemCtr');
|
||||
|
||||
export default class SystemController extends ControllerModule {
|
||||
static override readonly groupName = 'system';
|
||||
private systemThemeListenerInitialized = false;
|
||||
|
||||
/**
|
||||
@@ -26,7 +24,7 @@ export default class SystemController extends ControllerModule {
|
||||
* Handles the 'getDesktopAppState' IPC request.
|
||||
* Gathers essential application and system information.
|
||||
*/
|
||||
@ipcClientEvent('getDesktopAppState')
|
||||
@IpcMethod()
|
||||
async getAppState(): Promise<ElectronAppState> {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
@@ -53,16 +51,45 @@ export default class SystemController extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查可用性
|
||||
*/
|
||||
@ipcClientEvent('checkSystemAccessibility')
|
||||
checkAccessibilityForMacOS() {
|
||||
if (!macOS()) return;
|
||||
@IpcMethod()
|
||||
requestAccessibilityAccess() {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(true);
|
||||
}
|
||||
|
||||
@ipcClientEvent('openExternalLink')
|
||||
@IpcMethod()
|
||||
getAccessibilityStatus() {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(false);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getMediaAccessStatus(mediaType: 'microphone' | 'screen'): Promise<string> {
|
||||
if (!macOS()) return 'granted';
|
||||
return systemPreferences.getMediaAccessStatus(mediaType);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestMicrophoneAccess(): Promise<boolean> {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.askForMediaAccess('microphone');
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestScreenAccess(): Promise<void> {
|
||||
if (!macOS()) return;
|
||||
shell.openExternal(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
openFullDiskAccessSettings() {
|
||||
if (!macOS()) return;
|
||||
shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles');
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
openExternalLink(url: string) {
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
@@ -70,7 +97,7 @@ export default class SystemController extends ControllerModule {
|
||||
/**
|
||||
* 更新应用语言设置
|
||||
*/
|
||||
@ipcClientEvent('updateLocale')
|
||||
@IpcMethod()
|
||||
async updateLocale(locale: string) {
|
||||
// 保存语言设置
|
||||
this.app.storeManager.set('locale', locale);
|
||||
@@ -82,41 +109,26 @@ export default class SystemController extends ControllerModule {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ipcClientEvent('updateThemeMode')
|
||||
@IpcMethod()
|
||||
async updateThemeModeHandler(themeMode: ThemeMode) {
|
||||
this.app.storeManager.set('themeMode', themeMode);
|
||||
this.app.browserManager.broadcastToAllWindows('themeChanged', { themeMode });
|
||||
|
||||
// Apply visual effects to all browser windows when theme mode changes
|
||||
this.app.browserManager.handleAppThemeChange();
|
||||
// Set app theme mode to the system theme mode
|
||||
|
||||
this.setSystemThemeMode(themeMode);
|
||||
}
|
||||
|
||||
@ipcServerEvent('getDatabasePath')
|
||||
async getDatabasePath() {
|
||||
return join(this.app.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
@IpcMethod()
|
||||
async getSystemThemeMode() {
|
||||
return nativeTheme.themeSource;
|
||||
}
|
||||
|
||||
@ipcServerEvent('getDatabaseSchemaHash')
|
||||
async getDatabaseSchemaHash() {
|
||||
try {
|
||||
return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ipcServerEvent('getUserDataPath')
|
||||
async getUserDataPath() {
|
||||
return userDataDir;
|
||||
}
|
||||
|
||||
@ipcServerEvent('setDatabaseSchemaHash')
|
||||
async setDatabaseSchemaHash(hash: string) {
|
||||
writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8');
|
||||
}
|
||||
|
||||
private get DB_SCHEMA_HASH_PATH() {
|
||||
return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME);
|
||||
@IpcMethod()
|
||||
async setSystemThemeMode(themeMode: ThemeMode) {
|
||||
nativeTheme.themeSource = themeMode === 'auto' ? 'system' : themeMode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:TrayMenuCtr');
|
||||
|
||||
export default class TrayMenuCtr extends ControllerModule {
|
||||
static override readonly groupName = 'tray';
|
||||
async toggleMainWindow() {
|
||||
logger.debug('Toggle main window visibility via shortcut');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
@@ -23,7 +24,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
* @param options Balloon options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('showTrayNotification')
|
||||
@IpcMethod()
|
||||
async showNotification(options: ShowTrayNotificationParams) {
|
||||
logger.debug('Show tray balloon notification');
|
||||
|
||||
@@ -52,7 +53,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
* @param options Icon options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('updateTrayIcon')
|
||||
@IpcMethod()
|
||||
async updateTrayIcon(options: UpdateTrayIconParams) {
|
||||
logger.debug('Update tray icon');
|
||||
|
||||
@@ -84,7 +85,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
* @param options Tooltip text options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('updateTrayTooltip')
|
||||
@IpcMethod()
|
||||
async updateTrayTooltip(options: UpdateTrayTooltipParams) {
|
||||
logger.debug('Update tray tooltip text');
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:UpdaterCtr');
|
||||
|
||||
export default class UpdaterCtr extends ControllerModule {
|
||||
static override readonly groupName = 'autoUpdate';
|
||||
/**
|
||||
* Check for updates
|
||||
*/
|
||||
@ipcClientEvent('checkUpdate')
|
||||
@IpcMethod()
|
||||
async checkForUpdates() {
|
||||
logger.info('Check for updates requested');
|
||||
await this.app.updaterManager.checkForUpdates();
|
||||
@@ -17,7 +18,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* Download update
|
||||
*/
|
||||
@ipcClientEvent('downloadUpdate')
|
||||
@IpcMethod()
|
||||
async downloadUpdate() {
|
||||
logger.info('Download update requested');
|
||||
await this.app.updaterManager.downloadUpdate();
|
||||
@@ -26,7 +27,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* Quit application and install update
|
||||
*/
|
||||
@ipcClientEvent('installNow')
|
||||
@IpcMethod()
|
||||
quitAndInstallUpdate() {
|
||||
logger.info('Quit and install update requested');
|
||||
this.app.updaterManager.installNow();
|
||||
@@ -35,7 +36,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* Install update on next startup
|
||||
*/
|
||||
@ipcClientEvent('installLater')
|
||||
@IpcMethod()
|
||||
installLater() {
|
||||
logger.info('Install later requested');
|
||||
this.app.updaterManager.installLater();
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
import { UploadFileParams } from '@lobechat/electron-client-ipc';
|
||||
import { CreateFileParams } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class UploadFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@ipcClientEvent('createFile')
|
||||
@IpcMethod()
|
||||
async uploadFile(params: UploadFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
|
||||
// ======== server event
|
||||
|
||||
@ipcServerEvent('getStaticFilePath')
|
||||
async getFileUrlById(id: string) {
|
||||
return this.fileService.getFilePath(id);
|
||||
}
|
||||
|
||||
@ipcServerEvent('getFileHTTPURL')
|
||||
async getFileHTTPURL(path: string) {
|
||||
return this.fileService.getFileHTTPURL(path);
|
||||
}
|
||||
|
||||
@ipcServerEvent('deleteFiles')
|
||||
async deleteFiles(paths: string[]) {
|
||||
return this.fileService.deleteFiles(paths);
|
||||
}
|
||||
|
||||
@ipcServerEvent('createFile')
|
||||
async createFile(params: CreateFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CreateFileParams } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, IpcServerMethod } from './index';
|
||||
|
||||
export default class UploadFileServerCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getFileUrlById(id: string) {
|
||||
return this.fileService.getFilePath(id);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async getFileHTTPURL(path: string) {
|
||||
return this.fileService.getFileHTTPURL(path);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async deleteFiles(paths: string[]) {
|
||||
return this.fileService.deleteFiles(paths);
|
||||
}
|
||||
|
||||
@IpcServerMethod()
|
||||
async createFile(params: CreateFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,18 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
@@ -99,6 +106,7 @@ describe('AuthCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
randomBytesCounter = 0; // Reset counter for each test
|
||||
|
||||
// Reset shell.openExternal to default successful behavior
|
||||
@@ -123,7 +131,7 @@ describe('AuthCtr', () => {
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up authCtr intervals (using real timers, not fake timers)
|
||||
authCtr.cleanup();
|
||||
authCtr?.cleanup?.();
|
||||
// Clean up any fake timers if used
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
@@ -3,23 +3,39 @@ import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { runWithIpcContext } from '@/utils/ipc';
|
||||
|
||||
import BrowserWindowsCtr from '../BrowserWindowsCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockToggleVisible = vi.fn();
|
||||
const mockLoadUrl = vi.fn();
|
||||
const mockShow = vi.fn();
|
||||
const mockBroadcast = vi.fn();
|
||||
const mockRedirectToPage = vi.fn();
|
||||
const mockCloseWindow = vi.fn();
|
||||
const mockMinimizeWindow = vi.fn();
|
||||
const mockMaximizeWindow = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn();
|
||||
const testSenderIdentifierString: string = 'test-window-event-id';
|
||||
|
||||
const mockGetIdentifierByWebContents = vi.fn(() => testSenderIdentifierString);
|
||||
const mockGetMainWindow = vi.fn(() => ({
|
||||
toggleVisible: mockToggleVisible,
|
||||
loadUrl: mockLoadUrl,
|
||||
show: mockShow,
|
||||
broadcast: mockBroadcast,
|
||||
}));
|
||||
const mockShowOther = vi.fn();
|
||||
|
||||
@@ -32,6 +48,7 @@ const { findMatchingRoute } = await import('~common/routes');
|
||||
|
||||
const mockApp = {
|
||||
browserManager: {
|
||||
getIdentifierByWebContents: mockGetIdentifierByWebContents,
|
||||
getMainWindow: mockGetMainWindow,
|
||||
redirectToPage: mockRedirectToPage,
|
||||
closeWindow: mockCloseWindow,
|
||||
@@ -53,6 +70,7 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
browserWindowsCtr = new BrowserWindowsCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -69,41 +87,49 @@ describe('BrowserWindowsCtr', () => {
|
||||
const tab = 'appearance';
|
||||
const result = await browserWindowsCtr.openSettingsWindow(tab);
|
||||
expect(mockGetMainWindow).toHaveBeenCalled();
|
||||
expect(mockLoadUrl).toHaveBeenCalledWith('/settings?active=appearance');
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('navigate', {
|
||||
path: '/settings?active=appearance',
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error if navigation fails', async () => {
|
||||
const errorMessage = 'Failed to navigate';
|
||||
mockLoadUrl.mockRejectedValueOnce(new Error(errorMessage));
|
||||
mockBroadcast.mockImplementationOnce(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
const result = await browserWindowsCtr.openSettingsWindow('display');
|
||||
expect(result).toEqual({ error: errorMessage, success: false });
|
||||
});
|
||||
});
|
||||
|
||||
const testSenderIdentifierString: string = 'test-window-event-id';
|
||||
const sender: IpcClientEventSender = {
|
||||
identifier: testSenderIdentifierString,
|
||||
};
|
||||
|
||||
describe('closeWindow', () => {
|
||||
it('should close the window with the given sender identifier', () => {
|
||||
browserWindowsCtr.closeWindow(undefined, sender);
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
runWithIpcContext(context, () => browserWindowsCtr.closeWindow());
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockCloseWindow).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
});
|
||||
});
|
||||
|
||||
describe('minimizeWindow', () => {
|
||||
it('should minimize the window with the given sender identifier', () => {
|
||||
browserWindowsCtr.minimizeWindow(undefined, sender);
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
runWithIpcContext(context, () => browserWindowsCtr.minimizeWindow());
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockMinimizeWindow).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maximizeWindow', () => {
|
||||
it('should maximize the window with the given sender identifier', () => {
|
||||
browserWindowsCtr.maximizeWindow(undefined, sender);
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
runWithIpcContext(context, () => browserWindowsCtr.maximizeWindow());
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockMaximizeWindow).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import DevtoolsCtr from '../DevtoolsCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockShow = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn(() => ({
|
||||
@@ -24,10 +34,9 @@ describe('DevtoolsCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 只清除 vi.fn() 创建的模拟函数的记录,不影响 IoCContainer 状态
|
||||
ipcMainHandleMock.mockClear();
|
||||
|
||||
// 实例化 DevtoolsCtr。
|
||||
// 它将继承自真实的 ControllerModule。
|
||||
// 其 @ipcClientEvent 装饰器会执行并与真实的 IoCContainer 交互。
|
||||
// 实例化 DevtoolsCtr。其 @IpcMethod 装饰器会执行并与真实的 IoCContainer 交互。
|
||||
devtoolsCtr = new DevtoolsCtr(mockApp);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -22,6 +26,9 @@ vi.mock('@lobechat/file-loaders', () => ({
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import MenuController from '../MenuCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockRefreshMenus = vi.fn();
|
||||
const mockShowContextMenu = vi.fn();
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import NetworkProxyCtr from '../NetworkProxyCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// 模拟 logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -54,6 +58,7 @@ describe('NetworkProxyCtr', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
|
||||
// 动态导入 undici Mock
|
||||
mockUndici = await import('undici');
|
||||
@@ -418,3 +423,8 @@ describe('NetworkProxyCtr', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import NotificationCtr from '../NotificationCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -25,6 +29,9 @@ vi.mock('electron', () => {
|
||||
MockNotification.isSupported = vi.fn(() => true);
|
||||
|
||||
return {
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
Notification: MockNotification,
|
||||
app: {
|
||||
setAppUserModelId: vi.fn(),
|
||||
@@ -65,6 +72,7 @@ describe('NotificationCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
controller = new NotificationCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { App } from '@/core/App';
|
||||
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -17,6 +21,9 @@ vi.mock('@/utils/logger', () => ({
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
safeStorage: {
|
||||
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
||||
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
||||
@@ -45,6 +52,7 @@ describe('RemoteServerConfigCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'local',
|
||||
@@ -97,7 +105,10 @@ describe('RemoteServerConfigCtr', () => {
|
||||
const result = await controller.clearRemoteServerConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', { storageMode: 'local' });
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import RemoteServerSyncCtr from '../RemoteServerSyncCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: vi.fn(() => '/mock/app/path'),
|
||||
getPath: vi.fn(() => '/mock/user/data'),
|
||||
},
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
dev: vi.fn(() => false),
|
||||
linux: vi.fn(() => false),
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock http and https modules
|
||||
vi.mock('node:http', () => ({
|
||||
default: {
|
||||
request: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:https', () => ({
|
||||
default: {
|
||||
request: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock proxy agents
|
||||
vi.mock('http-proxy-agent', () => ({
|
||||
HttpProxyAgent: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
// Mock RemoteServerConfigCtr
|
||||
const mockRemoteServerConfigCtr = {
|
||||
getRemoteServerConfig: vi.fn(),
|
||||
getRemoteServerUrl: vi.fn(),
|
||||
getAccessToken: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
};
|
||||
|
||||
const mockStoreManager = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
enableProxy: false,
|
||||
proxyServer: '',
|
||||
proxyPort: '',
|
||||
proxyType: 'http',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getController: vi.fn(() => mockRemoteServerConfigCtr),
|
||||
storeManager: mockStoreManager,
|
||||
} as unknown as App;
|
||||
|
||||
describe('RemoteServerSyncCtr', () => {
|
||||
let controller: RemoteServerSyncCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new RemoteServerSyncCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('proxyTRPCRequest', () => {
|
||||
const baseParams: ProxyTRPCRequestParams = {
|
||||
urlPath: '/trpc/test.query',
|
||||
method: 'GET',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
};
|
||||
|
||||
it('should return 503 when remote server sync is not active', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.statusText).toBe('Remote server sync not active or configured');
|
||||
});
|
||||
|
||||
it('should return 503 when selfHost mode without remoteServerUrl', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'selfHost',
|
||||
remoteServerUrl: '',
|
||||
});
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.statusText).toBe('Remote server sync not active or configured');
|
||||
});
|
||||
|
||||
it('should return 401 when no access token is available', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue(null);
|
||||
|
||||
// Mock https.request to simulate the forwardRequest behavior
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
// Simulate response
|
||||
const mockResponse = {
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required, missing token',
|
||||
headers: {},
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(''));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should forward request successfully when configured properly', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
statusMessage: 'OK',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from('{"success":true}'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.statusText).toBe('OK');
|
||||
});
|
||||
|
||||
it('should retry request after token refresh on 401', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken
|
||||
.mockResolvedValueOnce('expired-token')
|
||||
.mockResolvedValueOnce('new-valid-token');
|
||||
mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({ success: true });
|
||||
|
||||
const https = await import('node:https');
|
||||
let callCount = 0;
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
callCount++;
|
||||
const mockResponse = {
|
||||
statusCode: callCount === 1 ? 401 : 200,
|
||||
statusMessage: callCount === 1 ? 'Unauthorized' : 'OK',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(callCount === 1 ? '' : '{"success":true}'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should keep 401 response when token refresh fails', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('expired-token');
|
||||
mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Refresh failed',
|
||||
});
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
const mockResponse = {
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
headers: {},
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(''));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle request error gracefully', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
return {
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'error') {
|
||||
handler(new Error('Network error'));
|
||||
}
|
||||
}),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const result = await controller.proxyTRPCRequest(baseParams);
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
expect(result.statusText).toBe('Error forwarding request');
|
||||
});
|
||||
|
||||
it('should include request body when provided', async () => {
|
||||
mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
|
||||
mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
|
||||
|
||||
const https = await import('node:https');
|
||||
const mockWrite = vi.fn();
|
||||
const mockRequest = vi.fn().mockImplementation((options, callback) => {
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
statusMessage: 'OK',
|
||||
headers: {},
|
||||
on: vi.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from('{"success":true}'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockResponse);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
write: mockWrite,
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mocked(https.default.request).mockImplementation(mockRequest);
|
||||
|
||||
const paramsWithBody: ProxyTRPCRequestParams = {
|
||||
...baseParams,
|
||||
method: 'POST',
|
||||
body: '{"data":"test"}',
|
||||
};
|
||||
|
||||
await controller.proxyTRPCRequest(paramsWithBody);
|
||||
|
||||
expect(mockWrite).toHaveBeenCalledWith('{"data":"test"}', 'utf8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should register stream:start IPC handler', async () => {
|
||||
const { ipcMain } = await import('electron');
|
||||
|
||||
controller.afterAppReady();
|
||||
|
||||
expect(ipcMain.on).toHaveBeenCalledWith('stream:start', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should clean up resources', () => {
|
||||
// destroy method doesn't throw
|
||||
expect(() => controller.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import ShellCommandCtr from '../ShellCommandCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { App } from '@/core/App';
|
||||
|
||||
import ShortcutController from '../ShortcutCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
|
||||
toggleMainWindow: 'CommandOrControl+Shift+L',
|
||||
@@ -26,6 +36,7 @@ describe('ShortcutController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
shortcutController = new ShortcutController(mockApp);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,38 @@ import { ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import SystemController from '../SystemCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(
|
||||
channel: string,
|
||||
payload?: any,
|
||||
context?: Partial<IpcContext>,
|
||||
): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = {
|
||||
sender: context?.sender ?? ({ id: 'test' } as any),
|
||||
};
|
||||
|
||||
if (payload === undefined) {
|
||||
return handler(fakeEvent);
|
||||
}
|
||||
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -21,6 +50,9 @@ vi.mock('electron', () => ({
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn((name: string) => `/mock/path/${name}`),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
@@ -38,19 +70,6 @@ vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @/const/dir
|
||||
vi.mock('@/const/dir', () => ({
|
||||
DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
|
||||
LOCAL_DATABASE_DIR: 'database',
|
||||
userDataDir: '/mock/user/data',
|
||||
}));
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserManager = {
|
||||
broadcastToAllWindows: vi.fn(),
|
||||
@@ -80,12 +99,15 @@ describe('SystemController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
controller = new SystemController(mockApp);
|
||||
});
|
||||
|
||||
describe('getAppState', () => {
|
||||
it('should return app state with system info', async () => {
|
||||
const result = await controller.getAppState();
|
||||
const result = await invokeIpc('system.getAppState');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
arch: expect.any(String),
|
||||
@@ -108,7 +130,7 @@ describe('SystemController', () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
|
||||
|
||||
const result = await controller.getAppState();
|
||||
const result = await invokeIpc('system.getAppState');
|
||||
|
||||
expect(result.systemAppearance).toBe('dark');
|
||||
|
||||
@@ -117,22 +139,24 @@ describe('SystemController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAccessibilityForMacOS', () => {
|
||||
it('should check accessibility on macOS', async () => {
|
||||
describe('accessibility', () => {
|
||||
it('should request accessibility access on macOS', async () => {
|
||||
const { systemPreferences } = await import('electron');
|
||||
|
||||
controller.checkAccessibilityForMacOS();
|
||||
await invokeIpc('system.requestAccessibilityAccess');
|
||||
|
||||
expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should return undefined on non-macOS', async () => {
|
||||
it('should return true on non-macOS when requesting accessibility access', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
const { systemPreferences } = await import('electron');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = controller.checkAccessibilityForMacOS();
|
||||
const result = await invokeIpc('system.requestAccessibilityAccess');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toBe(true);
|
||||
expect(systemPreferences.isTrustedAccessibilityClient).not.toHaveBeenCalled();
|
||||
|
||||
// Reset
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
@@ -143,7 +167,7 @@ describe('SystemController', () => {
|
||||
it('should open external link', async () => {
|
||||
const { shell } = await import('electron');
|
||||
|
||||
await controller.openExternalLink('https://example.com');
|
||||
await invokeIpc('system.openExternalLink', 'https://example.com');
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
@@ -151,7 +175,7 @@ describe('SystemController', () => {
|
||||
|
||||
describe('updateLocale', () => {
|
||||
it('should update locale and broadcast change', async () => {
|
||||
const result = await controller.updateLocale('zh-CN');
|
||||
const result = await invokeIpc('system.updateLocale', 'zh-CN');
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('locale', 'zh-CN');
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('zh-CN');
|
||||
@@ -162,7 +186,7 @@ describe('SystemController', () => {
|
||||
});
|
||||
|
||||
it('should use system locale when set to auto', async () => {
|
||||
await controller.updateLocale('auto');
|
||||
await invokeIpc('system.updateLocale', 'auto');
|
||||
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('en-US');
|
||||
});
|
||||
@@ -172,7 +196,7 @@ describe('SystemController', () => {
|
||||
it('should update theme mode and broadcast change', async () => {
|
||||
const themeMode: ThemeMode = 'dark';
|
||||
|
||||
await controller.updateThemeModeHandler(themeMode);
|
||||
await invokeIpc('system.updateThemeModeHandler', themeMode);
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('themeMode', 'dark');
|
||||
expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('themeChanged', {
|
||||
@@ -182,58 +206,6 @@ describe('SystemController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabasePath', () => {
|
||||
it('should return database path', async () => {
|
||||
const result = await controller.getDatabasePath();
|
||||
|
||||
expect(result).toBe('/mock/storage/database');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabaseSchemaHash', () => {
|
||||
it('should return schema hash when file exists', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockReturnValue('abc123');
|
||||
|
||||
const result = await controller.getDatabaseSchemaHash();
|
||||
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
const result = await controller.getDatabaseSchemaHash();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserDataPath', () => {
|
||||
it('should return user data path', async () => {
|
||||
const result = await controller.getUserDataPath();
|
||||
|
||||
expect(result).toBe('/mock/user/data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDatabaseSchemaHash', () => {
|
||||
it('should write schema hash to file', async () => {
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
|
||||
await controller.setDatabaseSchemaHash('newhash123');
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
'/mock/storage/db-schema-hash.txt',
|
||||
'newhash123',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
it('should initialize system theme listener', async () => {
|
||||
const { nativeTheme } = await import('electron');
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
import {
|
||||
ShowTrayNotificationParams,
|
||||
UpdateTrayIconParams,
|
||||
UpdateTrayTooltipParams
|
||||
UpdateTrayTooltipParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import TrayMenuCtr from '../TrayMenuCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -15,8 +27,6 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import TrayMenuCtr from '../TrayMenuCtr';
|
||||
|
||||
// 保存原始平台,确保测试结束后能恢复
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
@@ -45,6 +55,7 @@ describe('TrayMenuCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
// 为每个测试重置 mockedTray
|
||||
mockGetMainTray.mockReset();
|
||||
trayMenuCtr = new TrayMenuCtr(mockApp);
|
||||
@@ -69,7 +80,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should display balloon notification on Windows platform', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
displayBalloon: mockDisplayBalloon,
|
||||
};
|
||||
@@ -125,9 +136,9 @@ describe('TrayMenuCtr', () => {
|
||||
|
||||
expect(mockGetMainTray).toHaveBeenCalled();
|
||||
expect(mockDisplayBalloon).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
expect(result).toEqual({
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
success: false
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,7 +147,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should update tray icon on Windows platform', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
updateIcon: mockUpdateIcon,
|
||||
};
|
||||
@@ -156,7 +167,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should handle errors when updating icon', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const error = new Error('Failed to update icon');
|
||||
const mockedTray = {
|
||||
updateIcon: vi.fn().mockImplementation(() => {
|
||||
@@ -198,7 +209,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should update tray tooltip on Windows platform', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
updateTooltip: mockUpdateTooltip,
|
||||
};
|
||||
@@ -234,7 +245,7 @@ describe('TrayMenuCtr', () => {
|
||||
it('should return error when tooltip is not provided', async () => {
|
||||
// 模拟 Windows 平台
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
|
||||
const mockedTray = {
|
||||
updateTooltip: mockUpdateTooltip,
|
||||
};
|
||||
@@ -253,4 +264,4 @@ describe('TrayMenuCtr', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import UpdaterCtr from '../UpdaterCtr';
|
||||
|
||||
// 模拟 logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -9,7 +11,15 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import UpdaterCtr from '../UpdaterCtr';
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockCheckForUpdates = vi.fn();
|
||||
@@ -31,6 +41,7 @@ describe('UpdaterCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
updaterCtr = new UpdaterCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -79,4 +90,4 @@ describe('UpdaterCtr', () => {
|
||||
await expect(updaterCtr.downloadUpdate()).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = { sender: { id: 'test' } as any };
|
||||
if (payload === undefined) return handler(fakeEvent);
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
@@ -12,9 +36,6 @@ vi.mock('@/services/fileSrv', () => ({
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
getFilePath: vi.fn(),
|
||||
getFileHTTPURL: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
@@ -26,6 +47,9 @@ describe('UploadFileCtr', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -41,7 +65,7 @@ describe('UploadFileCtr', () => {
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.uploadFile(params);
|
||||
const result = await invokeIpc('upload.uploadFile', params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
@@ -58,110 +82,7 @@ describe('UploadFileCtr', () => {
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.uploadFile(params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileUrlById', () => {
|
||||
it('should get file path by id successfully', async () => {
|
||||
const fileId = 'file-id-123';
|
||||
const expectedPath = '/files/abc123.txt';
|
||||
mockFileService.getFilePath.mockResolvedValue(expectedPath);
|
||||
|
||||
const result = await controller.getFileUrlById(fileId);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
expect(mockFileService.getFilePath).toHaveBeenCalledWith(fileId);
|
||||
});
|
||||
|
||||
it('should handle get file path error', async () => {
|
||||
const fileId = 'non-existent-id';
|
||||
const error = new Error('File not found');
|
||||
mockFileService.getFilePath.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getFileUrlById(fileId)).rejects.toThrow('File not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileHTTPURL', () => {
|
||||
it('should get file HTTP URL successfully', async () => {
|
||||
const filePath = '/files/abc123.txt';
|
||||
const expectedUrl = 'http://localhost:3000/files/abc123.txt';
|
||||
mockFileService.getFileHTTPURL.mockResolvedValue(expectedUrl);
|
||||
|
||||
const result = await controller.getFileHTTPURL(filePath);
|
||||
|
||||
expect(result).toBe(expectedUrl);
|
||||
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it('should handle get HTTP URL error', async () => {
|
||||
const filePath = '/files/abc123.txt';
|
||||
const error = new Error('Failed to generate URL');
|
||||
mockFileService.getFileHTTPURL.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.getFileHTTPURL(filePath)).rejects.toThrow('Failed to generate URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFiles', () => {
|
||||
it('should delete files successfully', async () => {
|
||||
const paths = ['/files/file1.txt', '/files/file2.txt'];
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteFiles(paths);
|
||||
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(paths);
|
||||
});
|
||||
|
||||
it('should handle delete files error', async () => {
|
||||
const paths = ['/files/file1.txt'];
|
||||
const error = new Error('Delete failed');
|
||||
mockFileService.deleteFiles.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.deleteFiles(paths)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
|
||||
it('should handle empty paths array', async () => {
|
||||
const paths: string[] = [];
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteFiles(paths);
|
||||
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFile', () => {
|
||||
it('should create file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'xyz789',
|
||||
path: '/test/newfile.txt',
|
||||
content: 'bmV3IGZpbGUgY29udGVudA==',
|
||||
filename: 'newfile.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const expectedResult = { id: 'new-file-id', url: '/files/new-file-id' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.createFile(params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle create file error', async () => {
|
||||
const params = {
|
||||
hash: 'xyz789',
|
||||
path: '/test/newfile.txt',
|
||||
content: 'bmV3IGZpbGUgY29udGVudA==',
|
||||
filename: 'newfile.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const error = new Error('Create failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.createFile(params)).rejects.toThrow('Create failed');
|
||||
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import UploadFileServerCtr from '../UploadFileServerCtr';
|
||||
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
const mockFileService = {
|
||||
getFileHTTPURL: vi.fn(),
|
||||
getFilePath: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileServerCtr', () => {
|
||||
let controller: UploadFileServerCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new UploadFileServerCtr(mockApp);
|
||||
});
|
||||
|
||||
it('gets file path by id', async () => {
|
||||
mockFileService.getFilePath.mockResolvedValue('path');
|
||||
await expect(controller.getFileUrlById('id')).resolves.toBe('path');
|
||||
expect(mockFileService.getFilePath).toHaveBeenCalledWith('id');
|
||||
});
|
||||
|
||||
it('gets HTTP URL', async () => {
|
||||
mockFileService.getFileHTTPURL.mockResolvedValue('url');
|
||||
await expect(controller.getFileHTTPURL('/path')).resolves.toBe('url');
|
||||
expect(mockFileService.getFileHTTPURL).toHaveBeenCalledWith('/path');
|
||||
});
|
||||
|
||||
it('deletes files', async () => {
|
||||
mockFileService.deleteFiles.mockResolvedValue(undefined);
|
||||
await controller.deleteFiles(['a']);
|
||||
expect(mockFileService.deleteFiles).toHaveBeenCalledWith(['a']);
|
||||
});
|
||||
|
||||
it('creates files via upload service', async () => {
|
||||
const params = { filename: 'file' } as any;
|
||||
mockFileService.uploadFile.mockResolvedValue({ success: true });
|
||||
|
||||
await expect(controller.createFile(params)).resolves.toEqual({ success: true });
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class DevtoolsCtr extends ControllerModule {
|
||||
@ipcClientEvent('openDevtools')
|
||||
@IpcMethod()
|
||||
async openDevtools() {
|
||||
const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools');
|
||||
devtoolsBrowser.show();
|
||||
|
||||
@@ -1,34 +1,7 @@
|
||||
import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc';
|
||||
import type { ServerDispatchEvents } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IoCContainer } from '@/core/infrastructure/IoCContainer';
|
||||
import { ShortcutActionType } from '@/shortcuts';
|
||||
|
||||
const ipcDecorator =
|
||||
(name: string, mode: 'client' | 'server') =>
|
||||
(target: any, methodName: string, descriptor?: any) => {
|
||||
const actions = IoCContainer.controllers.get(target.constructor) || [];
|
||||
actions.push({
|
||||
methodName,
|
||||
mode,
|
||||
name,
|
||||
});
|
||||
IoCContainer.controllers.set(target.constructor, actions);
|
||||
return descriptor;
|
||||
};
|
||||
|
||||
/**
|
||||
* IPC client event decorator for controllers
|
||||
*/
|
||||
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
|
||||
ipcDecorator(method, 'client');
|
||||
|
||||
/**
|
||||
* IPC server event decorator for controllers
|
||||
*/
|
||||
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
|
||||
ipcDecorator(method, 'server');
|
||||
import { IpcService } from '@/utils/ipc';
|
||||
|
||||
const shortcutDecorator = (name: string) => (target: any, methodName: string, descriptor?: any) => {
|
||||
const actions = IoCContainer.shortcuts.get(target.constructor) || [];
|
||||
@@ -68,10 +41,13 @@ interface IControllerModule {
|
||||
beforeAppReady?(): void;
|
||||
}
|
||||
|
||||
export class ControllerModule implements IControllerModule {
|
||||
export class ControllerModule extends IpcService implements IControllerModule {
|
||||
constructor(public app: App) {
|
||||
super();
|
||||
this.app = app;
|
||||
}
|
||||
}
|
||||
|
||||
export type IControlModule = typeof ControllerModule;
|
||||
|
||||
export { IpcMethod, IpcServerMethod } from '@/utils/ipc';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } from '@/utils/ipc';
|
||||
|
||||
import AuthCtr from './AuthCtr';
|
||||
import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||
import DevtoolsCtr from './DevtoolsCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import McpInstallCtr from './McpInstallCtr';
|
||||
import MenuController from './MenuCtr';
|
||||
import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
import NotificationCtr from './NotificationCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import ShortcutController from './ShortcutCtr';
|
||||
import SystemController from './SystemCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
import UploadFileServerCtr from './UploadFileServerCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
AuthCtr,
|
||||
BrowserWindowsCtr,
|
||||
DevtoolsCtr,
|
||||
LocalFileCtr,
|
||||
McpCtr,
|
||||
McpInstallCtr,
|
||||
MenuController,
|
||||
NetworkProxyCtr,
|
||||
NotificationCtr,
|
||||
RemoteServerConfigCtr,
|
||||
RemoteServerSyncCtr,
|
||||
ShellCommandCtr,
|
||||
ShortcutController,
|
||||
SystemController,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
type DesktopControllerServices = CreateServicesResult<DesktopControllerIpcConstructors>;
|
||||
export type DesktopIpcServices = MergeIpcService<DesktopControllerServices>;
|
||||
|
||||
export const controllerServerIpcConstructors = [
|
||||
UploadFileServerCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerServerConstructors = typeof controllerServerIpcConstructors;
|
||||
type DesktopServerControllerServices = CreateServicesResult<DesktopControllerServerConstructors>;
|
||||
export type DesktopServerIpcServices = MergeIpcService<DesktopServerControllerServices>;
|
||||
+237
-147
@@ -1,23 +1,31 @@
|
||||
import {
|
||||
DEFAULT_VARIANTS,
|
||||
LOBE_LOCALE_COOKIE,
|
||||
LOBE_THEME_APPEARANCE,
|
||||
Locales,
|
||||
RouteVariants,
|
||||
} from '@lobechat/desktop-bridge';
|
||||
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { Session, app, ipcMain, protocol } from 'electron';
|
||||
import { app, protocol, session } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { buildDir, LOCAL_DATABASE_DIR, nextStandaloneDir } from '@/const/dir';
|
||||
import { buildDir, nextExportDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import { getServerMethodMetadata } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
|
||||
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererProtocolManager } from './infrastructure/RendererProtocolManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
import { StoreManager } from './infrastructure/StoreManager';
|
||||
import { UpdaterManager } from './infrastructure/UpdaterManager';
|
||||
@@ -35,8 +43,10 @@ type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
const importAll = (r: any) => Object.values(r).map((v: any) => v.default);
|
||||
|
||||
const devDefaultRendererUrl = 'http://localhost:3015';
|
||||
|
||||
export class App {
|
||||
nextServerUrl = 'http://localhost:3015';
|
||||
rendererLoadedUrl: string;
|
||||
|
||||
browserManager: BrowserManager;
|
||||
menuManager: MenuManager;
|
||||
@@ -47,7 +57,13 @@ export class App {
|
||||
trayManager: TrayManager;
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererProtocolManager: RendererProtocolManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
/**
|
||||
* Escape hatch: allow testing static renderer in dev via env
|
||||
*/
|
||||
private readonly rendererStaticOverride =
|
||||
process.env.DESKTOP_RENDERER_STATIC === '1' || process.env.DESKTOP_RENDERER_STATIC === 'true';
|
||||
|
||||
/**
|
||||
* whether app is in quiting
|
||||
@@ -79,9 +95,32 @@ export class App {
|
||||
// Initialize store manager
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererProtocolManager = new RendererProtocolManager({
|
||||
getExportMimeType: this.getExportMimeType.bind(this),
|
||||
|
||||
nextExportDir,
|
||||
resolveRendererFilePath: this.resolveRendererFilePath.bind(this),
|
||||
});
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererProtocolManager.protocolScheme,
|
||||
]);
|
||||
|
||||
// Initialize rendererLoadedUrl from RendererProtocolManager
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
|
||||
// load controllers
|
||||
const controllers: IControlModule[] = importAll(
|
||||
(import.meta as any).glob('@/controllers/*Ctr.ts', { eager: true }),
|
||||
import.meta.glob('@/controllers/*Ctr.ts', { eager: true }),
|
||||
);
|
||||
|
||||
logger.debug(`Loading ${controllers.length} controllers`);
|
||||
@@ -89,13 +128,13 @@ export class App {
|
||||
|
||||
// load services
|
||||
const services: IServiceModule[] = importAll(
|
||||
(import.meta as any).glob('@/services/*Srv.ts', { eager: true }),
|
||||
import.meta.glob('@/services/*Srv.ts', { eager: true }),
|
||||
);
|
||||
|
||||
logger.debug(`Loading ${services.length} services`);
|
||||
services.forEach((service) => this.addService(service));
|
||||
|
||||
this.initializeIPCEvents();
|
||||
this.initializeServerIpcEvents();
|
||||
|
||||
this.i18n = new I18nManager(this);
|
||||
this.browserManager = new BrowserManager(this);
|
||||
@@ -106,9 +145,9 @@ export class App {
|
||||
this.staticFileServerManager = new StaticFileServerManager(this);
|
||||
this.protocolManager = new ProtocolManager(this);
|
||||
|
||||
// register the schema to interceptor url
|
||||
// it should register before app ready
|
||||
this.registerNextHandler();
|
||||
// Configure renderer loading strategy (dev server vs static export)
|
||||
// should register before app ready
|
||||
this.configureRendererLoader();
|
||||
|
||||
// initialize protocol handlers
|
||||
this.protocolManager.initialize();
|
||||
@@ -130,9 +169,6 @@ export class App {
|
||||
|
||||
this.initDevBranding();
|
||||
|
||||
// Clean up stale database lock file before starting IPC server
|
||||
await this.cleanupDatabaseLock();
|
||||
|
||||
// ==============
|
||||
await this.ipcServer.start();
|
||||
logger.debug('IPC server started');
|
||||
@@ -268,81 +304,21 @@ export class App {
|
||||
private services = new Map<Class<any>, any>();
|
||||
|
||||
private ipcServer: ElectronIPCServer;
|
||||
/**
|
||||
* events dispatched from webview layer
|
||||
*/
|
||||
private ipcClientEventMap: IPCEventMap = new Map();
|
||||
private ipcServerEventMap: IPCEventMap = new Map();
|
||||
shortcutMethodMap: ShortcutMethodMap = new Map();
|
||||
protocolHandlerMap: ProtocolHandlerMap = new Map();
|
||||
|
||||
/**
|
||||
* use in next router interceptor in prod browser render
|
||||
*/
|
||||
nextInterceptor: (params: { session: Session }) => () => void;
|
||||
|
||||
/**
|
||||
* Collection of unregister functions for custom request handlers
|
||||
*/
|
||||
private customHandlerUnregisterFns: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Function to register custom request handler
|
||||
*/
|
||||
private registerCustomHandlerFn?: (handler: CustomRequestHandler) => () => void;
|
||||
|
||||
/**
|
||||
* Register custom request handler
|
||||
* @param handler Custom request handler function
|
||||
* @returns Function to unregister the handler
|
||||
*/
|
||||
registerRequestHandler = (handler: CustomRequestHandler): (() => void) => {
|
||||
if (!this.registerCustomHandlerFn) {
|
||||
logger.warn('Custom request handler registration is not available');
|
||||
return () => {};
|
||||
}
|
||||
|
||||
logger.debug('Registering custom request handler');
|
||||
const unregisterFn = this.registerCustomHandlerFn(handler);
|
||||
this.customHandlerUnregisterFns.push(unregisterFn);
|
||||
|
||||
return () => {
|
||||
unregisterFn();
|
||||
const index = this.customHandlerUnregisterFns.indexOf(unregisterFn);
|
||||
if (index !== -1) {
|
||||
this.customHandlerUnregisterFns.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Unregister all custom request handlers
|
||||
*/
|
||||
unregisterAllRequestHandlers = () => {
|
||||
this.customHandlerUnregisterFns.forEach((unregister) => unregister());
|
||||
this.customHandlerUnregisterFns = [];
|
||||
};
|
||||
|
||||
private addController = (ControllerClass: IControlModule) => {
|
||||
const controller = new ControllerClass(this);
|
||||
this.controllers.set(ControllerClass, controller);
|
||||
|
||||
IoCContainer.controllers.get(ControllerClass)?.forEach((event) => {
|
||||
if (event.mode === 'client') {
|
||||
// Store all objects from event decorator in ipcClientEventMap
|
||||
this.ipcClientEventMap.set(event.name, {
|
||||
controller,
|
||||
methodName: event.methodName,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.mode === 'server') {
|
||||
// Store all objects from event decorator in ipcServerEventMap
|
||||
this.ipcServerEventMap.set(event.name, {
|
||||
controller,
|
||||
methodName: event.methodName,
|
||||
});
|
||||
}
|
||||
const serverMethods = getServerMethodMetadata(ControllerClass);
|
||||
serverMethods?.forEach((methodName, propertyKey) => {
|
||||
const channel = `${ControllerClass.groupName}.${methodName}`;
|
||||
this.ipcServerEventMap.set(channel, {
|
||||
controller,
|
||||
methodName: propertyKey,
|
||||
});
|
||||
});
|
||||
|
||||
IoCContainer.shortcuts.get(ControllerClass)?.forEach((shortcut) => {
|
||||
@@ -375,79 +351,194 @@ export class App {
|
||||
}
|
||||
};
|
||||
|
||||
private resolveExportFilePath(pathname: string) {
|
||||
// Normalize by removing leading/trailing slashes so extname works as expected
|
||||
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '').replace(/\/$/, '');
|
||||
|
||||
if (!normalizedPath) return join(nextExportDir, 'index.html');
|
||||
|
||||
const basePath = join(nextExportDir, normalizedPath);
|
||||
const ext = extname(normalizedPath);
|
||||
|
||||
// If the request explicitly includes an extension (e.g. html, ico, txt),
|
||||
// treat it as a direct asset without variant injection.
|
||||
if (ext) {
|
||||
return pathExistsSync(basePath) ? basePath : null;
|
||||
}
|
||||
|
||||
const candidates = [`${basePath}.html`, join(basePath, 'index.html'), basePath];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (pathExistsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const fallback404 = join(nextExportDir, '404.html');
|
||||
if (pathExistsSync(fallback404)) return fallback404;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getExportMimeType(filePath: string) {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
return map[ext];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale database lock file from previous crashes or abnormal exits
|
||||
* Configure renderer loading strategy for dev/prod
|
||||
*/
|
||||
private cleanupDatabaseLock = async () => {
|
||||
try {
|
||||
const dbPath = join(this.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
const lockPath = `${dbPath}.lock`;
|
||||
private configureRendererLoader() {
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
this.rendererLoadedUrl = devDefaultRendererUrl;
|
||||
this.setupDevRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathExistsSync(lockPath)) {
|
||||
logger.info(`Cleaning up stale database lock file: ${lockPath}`);
|
||||
await remove(lockPath);
|
||||
logger.info('Database lock file removed successfully');
|
||||
} else {
|
||||
logger.debug('No database lock file found, skipping cleanup');
|
||||
if (isDev && this.rendererStaticOverride) {
|
||||
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
|
||||
}
|
||||
|
||||
this.setupProdRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Development: use Next dev server directly
|
||||
*/
|
||||
private setupDevRenderer() {
|
||||
logger.info('Development mode: renderer served from Next dev server, no protocol hook');
|
||||
}
|
||||
|
||||
/**
|
||||
* Production: serve static Next export assets
|
||||
*/
|
||||
private setupProdRenderer() {
|
||||
// Use the URL from RendererProtocolManager
|
||||
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
|
||||
this.rendererProtocolManager.registerHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve renderer file path in production by combining variant prefix and pathname.
|
||||
* Falls back to default variant when cookies are missing or invalid.
|
||||
*/
|
||||
private async resolveRendererFilePath(url: URL) {
|
||||
const pathname = url.pathname;
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
// Static assets should be resolved from root (no variant prefix)
|
||||
if (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/manifest.json'
|
||||
) {
|
||||
return this.resolveExportFilePath(pathname);
|
||||
}
|
||||
|
||||
// If the incoming path already contains an extension (like .html or .ico),
|
||||
// treat it as a direct asset lookup to avoid double variant prefixes.
|
||||
const extension = extname(normalizedPathname);
|
||||
if (extension) {
|
||||
const directPath = this.resolveExportFilePath(pathname);
|
||||
if (directPath) return directPath;
|
||||
|
||||
// Next.js RSC payloads are emitted under variant folders (e.g. /en-US__0__light/__next._tree.txt),
|
||||
// but the runtime may request them without the variant prefix. For missing .txt requests,
|
||||
// retry resolution with variant injection.
|
||||
if (extension === '.txt' && normalizedPathname.includes('__next.')) {
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
|
||||
return (
|
||||
this.resolveExportFilePath(`/${variant}${pathname}`) ||
|
||||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
const variantPrefixedPath = `/${variant}${pathname}`;
|
||||
|
||||
// Try variant-specific path first, then default variant as fallback
|
||||
return (
|
||||
this.resolveExportFilePath(variantPrefixedPath) ||
|
||||
this.resolveExportFilePath(`/${this.defaultRouteVariant}${pathname}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private readonly defaultRouteVariant = RouteVariants.serializeVariants(DEFAULT_VARIANTS);
|
||||
private readonly localeCookieName = LOBE_LOCALE_COOKIE;
|
||||
private readonly themeCookieName = LOBE_THEME_APPEARANCE;
|
||||
|
||||
/**
|
||||
* Build variant string from Electron session cookies to match Next export structure.
|
||||
* Desktop is always treated as non-mobile (0).
|
||||
*/
|
||||
private async getRouteVariantFromCookies(): Promise<string> {
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({
|
||||
url: `${this.rendererLoadedUrl}/`,
|
||||
});
|
||||
const locale = cookies.find((c) => c.name === this.localeCookieName)?.value;
|
||||
const themeCookie = cookies.find((c) => c.name === this.themeCookieName)?.value;
|
||||
|
||||
const serialized = RouteVariants.serializeVariants(
|
||||
RouteVariants.createVariants({
|
||||
isMobile: false,
|
||||
locale: locale as Locales | undefined,
|
||||
theme: themeCookie === 'dark' || themeCookie === 'light' ? themeCookie : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return RouteVariants.serializeVariants(RouteVariants.deserializeVariants(serialized));
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup database lock file:', error);
|
||||
// Non-fatal error, allow application to continue
|
||||
}
|
||||
};
|
||||
|
||||
private registerNextHandler() {
|
||||
logger.debug('Registering Next.js handler');
|
||||
const handler = createHandler({
|
||||
debug: true,
|
||||
localhostUrl: this.nextServerUrl,
|
||||
protocol,
|
||||
standaloneDir: nextStandaloneDir,
|
||||
});
|
||||
|
||||
// Log output based on development or production mode
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`Development mode: Custom request handler enabled, but Next.js interception disabled`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Production mode: ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.nextInterceptor = handler.createInterceptor;
|
||||
|
||||
// Save custom handler registration function
|
||||
if (handler.registerCustomHandler) {
|
||||
this.registerCustomHandlerFn = handler.registerCustomHandler;
|
||||
logger.debug('Custom request handler registration is available');
|
||||
} else {
|
||||
logger.warn('Custom request handler registration is not available');
|
||||
logger.warn('Failed to read route variant cookies, using default', error);
|
||||
return this.defaultRouteVariant;
|
||||
}
|
||||
}
|
||||
|
||||
private initializeIPCEvents() {
|
||||
logger.debug('Initializing IPC events');
|
||||
// Register batch controller client events for render side consumption
|
||||
this.ipcClientEventMap.forEach((eventInfo, key) => {
|
||||
const { controller, methodName } = eventInfo;
|
||||
/**
|
||||
* Build renderer URL with variant prefix injected into the path.
|
||||
* In dev mode (without static override), Next.js dev server handles routing automatically.
|
||||
* In prod or dev with static override, we need to inject variant to match export structure: /[variants]/path
|
||||
*/
|
||||
async buildRendererUrl(path: string): Promise<string> {
|
||||
// Ensure path starts with /
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
ipcMain.handle(key, async (e, data) => {
|
||||
// 从 WebContents 获取对应的 BrowserWindow id
|
||||
const senderIdentifier = this.browserManager.getIdentifierByWebContents(e.sender);
|
||||
try {
|
||||
return await controller[methodName](data, {
|
||||
identifier: senderIdentifier,
|
||||
} as IpcClientEventSender);
|
||||
} catch (error) {
|
||||
logger.error(`Error handling IPC event ${key}:`, error);
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
});
|
||||
// In dev mode without static override, use dev server directly (no variant needed)
|
||||
if (isDev && !this.rendererStaticOverride) {
|
||||
return `${this.rendererLoadedUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
// Batch register server events from controllers for next server consumption
|
||||
// In prod or dev with static override, inject variant for static export structure
|
||||
const variant = await this.getRouteVariantFromCookies();
|
||||
return `${this.rendererLoadedUrl}/${variant}.html${cleanPath}`;
|
||||
}
|
||||
|
||||
private initializeServerIpcEvents() {
|
||||
logger.debug('Initializing IPC server events');
|
||||
const ipcServerEvents = {} as ElectronIPCEventHandler;
|
||||
|
||||
this.ipcServerEventMap.forEach((eventInfo, key) => {
|
||||
@@ -477,6 +568,5 @@ export class App {
|
||||
|
||||
// 执行清理操作
|
||||
this.staticFileServerManager.destroy();
|
||||
this.unregisterAllRequestHandlers();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { app } from 'electron';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LOCAL_DATABASE_DIR } from '@/const/dir';
|
||||
// Import after mocks are set up
|
||||
import { App } from '../App';
|
||||
|
||||
const mockPathExistsSync = vi.fn();
|
||||
|
||||
// Mock electron modules
|
||||
vi.mock('electron', () => ({
|
||||
@@ -24,6 +24,7 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
@@ -32,6 +33,17 @@ vi.mock('electron', () => ({
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
defaultSession: {
|
||||
cookies: {
|
||||
get: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
pathExistsSync: (...args: any[]) => mockPathExistsSync(...args),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
@@ -44,16 +56,6 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fs-extra module
|
||||
vi.mock('fs-extra', async () => {
|
||||
const actual = await vi.importActual('fs-extra');
|
||||
return {
|
||||
...actual,
|
||||
pathExistsSync: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock common/routes
|
||||
vi.mock('~common/routes', () => ({
|
||||
findMatchingRoute: vi.fn(),
|
||||
@@ -76,11 +78,9 @@ vi.mock('@/const/env', () => ({
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
nextStandaloneDir: '/mock/standalone',
|
||||
LOCAL_DATABASE_DIR: 'lobehub-local-db',
|
||||
nextExportDir: '/mock/export/out',
|
||||
appStorageDir: '/mock/storage/path',
|
||||
userDataDir: '/mock/user/data',
|
||||
DB_SCHEMA_HASH_FILENAME: 'lobehub-local-db-schema-hash',
|
||||
FILE_STORAGE_DIR: 'file-storage',
|
||||
INSTALL_PLUGINS_DIR: 'plugins',
|
||||
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
|
||||
@@ -155,121 +155,25 @@ vi.mock('../ui/TrayManager', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/next-electron-rsc', () => ({
|
||||
createHandler: vi.fn(() => ({
|
||||
createInterceptor: vi.fn(),
|
||||
registerCustomHandler: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock controllers and services
|
||||
vi.mock('../../controllers/*Ctr.ts', () => ({}));
|
||||
vi.mock('../../services/*Srv.ts', () => ({}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { App } from '../App';
|
||||
|
||||
describe('App - Database Lock Cleanup', () => {
|
||||
describe('App', () => {
|
||||
let appInstance: App;
|
||||
let mockLockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPathExistsSync.mockReset();
|
||||
|
||||
// Mock glob imports to return empty arrays
|
||||
(import.meta as any).glob = vi.fn(() => ({}));
|
||||
|
||||
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
|
||||
import.meta.glob = vi.fn(() => ({}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bootstrap - database lock cleanup', () => {
|
||||
it('should remove stale lock file if it exists during bootstrap', async () => {
|
||||
// Setup: simulate existing lock file
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockResolvedValue(undefined);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap which should trigger cleanup
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: lock file was removed
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should not attempt to remove lock file if it does not exist', async () => {
|
||||
// Setup: no lock file exists
|
||||
vi.mocked(pathExistsSync).mockReturnValue(false);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: remove was NOT called since file doesn't exist
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue bootstrap even if lock cleanup fails', async () => {
|
||||
// Setup: simulate lock file exists but cleanup fails
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Bootstrap should not throw even if cleanup fails
|
||||
await expect(appInstance.bootstrap()).resolves.not.toThrow();
|
||||
|
||||
// Verify: cleanup was attempted
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should clean up lock file before starting IPC server', async () => {
|
||||
// Setup
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(remove).mockImplementation(async () => {
|
||||
callOrder.push('remove');
|
||||
});
|
||||
|
||||
// Mock IPC server start to track call order
|
||||
const { ElectronIPCServer } = await import('@lobechat/electron-server-ipc');
|
||||
const mockStart = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('ipcServer.start');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(ElectronIPCServer).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
start: mockStart,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Create app instance and bootstrap
|
||||
appInstance = new App();
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: cleanup happens before IPC server starts
|
||||
expect(callOrder).toEqual(['remove', 'ipcServer.start']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appStoragePath', () => {
|
||||
it('should return storage path from store manager', () => {
|
||||
appInstance = new App();
|
||||
@@ -279,4 +183,46 @@ describe('App - Database Lock Cleanup', () => {
|
||||
expect(storagePath).toBe('/mock/storage/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRendererFilePath', () => {
|
||||
it('should retry missing .txt requests with variant-prefixed lookup', async () => {
|
||||
appInstance = new App();
|
||||
|
||||
// Avoid touching the electron session cookie code path in this unit test
|
||||
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => 'en-US__0__light');
|
||||
|
||||
mockPathExistsSync.mockImplementation((p: string) => {
|
||||
// root miss
|
||||
if (p === '/mock/export/out/__next._tree.txt') return false;
|
||||
// variant hit
|
||||
if (p === '/mock/export/out/en-US__0__light/__next._tree.txt') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const resolved = await (appInstance as any).resolveRendererFilePath(
|
||||
new URL('app://next/__next._tree.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light/__next._tree.txt');
|
||||
});
|
||||
|
||||
it('should keep direct lookup for existing root .txt assets (no variant retry)', async () => {
|
||||
appInstance = new App();
|
||||
|
||||
(appInstance as any).getRouteVariantFromCookies = vi.fn(async () => {
|
||||
throw new Error('should not be called');
|
||||
});
|
||||
|
||||
mockPathExistsSync.mockImplementation((p: string) => {
|
||||
if (p === '/mock/export/out/en-US__0__light.txt') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const resolved = await (appInstance as any).resolveRendererFilePath(
|
||||
new URL('app://next/en-US__0__light.txt'),
|
||||
);
|
||||
|
||||
expect(resolved).toBe('/mock/export/out/en-US__0__light.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,18 @@ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-c
|
||||
import {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
type Session,
|
||||
session as electronSession,
|
||||
ipcMain,
|
||||
nativeTheme,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import console from 'node:console';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isDev, isMac, isWindows } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import {
|
||||
BACKGROUND_DARK,
|
||||
BACKGROUND_LIGHT,
|
||||
@@ -18,12 +22,15 @@ import {
|
||||
THEME_CHANGE_DELAY,
|
||||
TITLE_BAR_HEIGHT,
|
||||
} from '@/const/theme';
|
||||
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from '../App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:Browser');
|
||||
// Track sessions that already have protocol handlers installed to avoid duplicates
|
||||
const protocolHandledSessions = new WeakSet<Session>();
|
||||
|
||||
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
devTools?: boolean;
|
||||
@@ -41,7 +48,6 @@ export default class Browser {
|
||||
private app: App;
|
||||
private _browserWindow?: BrowserWindow;
|
||||
private themeListenerSetup = false;
|
||||
private stopInterceptHandler;
|
||||
identifier: string;
|
||||
options: BrowserWindowOpts;
|
||||
private readonly windowStateKey: string;
|
||||
@@ -167,11 +173,14 @@ export default class Browser {
|
||||
}
|
||||
|
||||
loadUrl = async (path: string) => {
|
||||
const initUrl = this.app.nextServerUrl + path;
|
||||
const initUrl = await this.app.buildRendererUrl(path);
|
||||
|
||||
console.log('[Browser] initUrl', initUrl);
|
||||
|
||||
try {
|
||||
logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`);
|
||||
await this._browserWindow.loadURL(initUrl);
|
||||
|
||||
logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error);
|
||||
@@ -295,7 +304,6 @@ export default class Browser {
|
||||
*/
|
||||
destroy() {
|
||||
logger.debug(`Destroying window instance: ${this.identifier}`);
|
||||
this.stopInterceptHandler?.();
|
||||
this.cleanupThemeListener();
|
||||
this._browserWindow = undefined;
|
||||
}
|
||||
@@ -339,6 +347,7 @@ export default class Browser {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
sandbox: false,
|
||||
},
|
||||
width: savedState?.width || width,
|
||||
...this.getPlatformThemeConfig(isDarkMode),
|
||||
@@ -354,13 +363,10 @@ export default class Browser {
|
||||
// Apply initial visual effects
|
||||
this.applyVisualEffects();
|
||||
|
||||
logger.debug(`[${this.identifier}] Setting up nextInterceptor.`);
|
||||
this.stopInterceptHandler = this.app.nextInterceptor({
|
||||
session: browserWindow.webContents.session,
|
||||
});
|
||||
|
||||
// Setup CORS bypass for local file server
|
||||
this.setupCORSBypass(browserWindow);
|
||||
// Setup request hook for remote server sync (base URL rewrite + OIDC header)
|
||||
this.setupRemoteServerRequestHook(browserWindow);
|
||||
|
||||
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
|
||||
this.loadPlaceholder().then(() => {
|
||||
@@ -409,8 +415,7 @@ export default class Browser {
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
|
||||
}
|
||||
// Need to clean up intercept handler and theme manager
|
||||
this.stopInterceptHandler?.();
|
||||
// Need to clean up theme manager
|
||||
this.cleanupThemeListener();
|
||||
return;
|
||||
}
|
||||
@@ -445,8 +450,7 @@ export default class Browser {
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
|
||||
}
|
||||
// Need to clean up intercept handler and theme manager
|
||||
this.stopInterceptHandler?.();
|
||||
// Need to clean up theme manager
|
||||
this.cleanupThemeListener();
|
||||
}
|
||||
});
|
||||
@@ -528,4 +532,115 @@ export default class Browser {
|
||||
|
||||
logger.debug(`[${this.identifier}] CORS bypass setup completed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite tRPC requests to remote server and inject OIDC token via webRequest hooks.
|
||||
* Replaces the previous proxyTRPCRequest IPC forwarding.
|
||||
*/
|
||||
private setupRemoteServerRequestHook(browserWindow: BrowserWindow) {
|
||||
const session = browserWindow.webContents.session;
|
||||
const remoteServerConfigCtr = this.app.getController(RemoteServerConfigCtr);
|
||||
const logPrefix = `[${this.identifier}] RemoteServerRequestHook`;
|
||||
|
||||
// Guard to ensure hooks are registered only once per session
|
||||
const targetSession = session || electronSession.defaultSession;
|
||||
if (!targetSession || protocolHandledSessions.has(targetSession)) return;
|
||||
|
||||
const rewriteUrl = async (rawUrl: string) => {
|
||||
let remoteServerUrl: string | undefined;
|
||||
try {
|
||||
const requestUrl = new URL(rawUrl);
|
||||
|
||||
const config = await remoteServerConfigCtr.getRemoteServerConfig();
|
||||
remoteServerUrl = await remoteServerConfigCtr.getRemoteServerUrl(config);
|
||||
const remoteBase = new URL(remoteServerUrl);
|
||||
if (requestUrl.origin === remoteBase.origin) return;
|
||||
|
||||
const rewrittenUrl = new URL(
|
||||
requestUrl.pathname + requestUrl.search,
|
||||
remoteBase,
|
||||
).toString();
|
||||
logger.debug(`${logPrefix} rewrite ${rawUrl} -> ${rewrittenUrl}`);
|
||||
return rewrittenUrl;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`${logPrefix} rewriteUrl error (rawUrl=${rawUrl}, remoteServerUrl=${remoteServerUrl})`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Transparent rewrite via protocol handlers (no HTTP 302)
|
||||
const registerProtocolHandlers = async () => {
|
||||
const proxyHandler = async (request: Request): Promise<Response | null> => {
|
||||
// lobe-backend://lobe/trpc/xxx -> http://<target_host>/trpc/xxx
|
||||
try {
|
||||
const rewrittenUrl = await rewriteUrl(request.url);
|
||||
if (!rewrittenUrl) return null;
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
const token = await remoteServerConfigCtr.getAccessToken();
|
||||
if (token) headers.set('Oidc-Auth', token);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
headers,
|
||||
method: request.method,
|
||||
};
|
||||
|
||||
// Only forward body for non-GET/HEAD requests
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
const body = request.body ?? undefined;
|
||||
if (body) {
|
||||
requestInit.body = body;
|
||||
// Node.js (undici) requires `duplex` when sending a streaming body
|
||||
requestInit.duplex = 'half';
|
||||
}
|
||||
}
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(rewrittenUrl, requestInit);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
|
||||
|
||||
return new Response('Upstream fetch failed, target url: ' + rewrittenUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
});
|
||||
}
|
||||
const responseHeaders = new Headers(upstreamResponse.headers);
|
||||
|
||||
const allowOrigin = request.headers.get('Origin') || undefined;
|
||||
if (allowOrigin) {
|
||||
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
|
||||
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', '*');
|
||||
|
||||
responseHeaders.set('X-Src-Url', rewrittenUrl);
|
||||
return new Response(upstreamResponse.body, {
|
||||
headers: responseHeaders,
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} protocol.handle error:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
targetSession.protocol.handle(ELECTRON_BE_PROTOCOL_SCHEME, proxyHandler);
|
||||
logger.debug(`${logPrefix} protocol handler registered for ${ELECTRON_BE_PROTOCOL_SCHEME}`);
|
||||
};
|
||||
|
||||
registerProtocolHandlers();
|
||||
protocolHandledSessions.add(targetSession);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,11 @@ describe('Browser', () => {
|
||||
let mockApp: AppCore;
|
||||
let mockStoreManagerGet: ReturnType<typeof vi.fn>;
|
||||
let mockStoreManagerSet: ReturnType<typeof vi.fn>;
|
||||
let mockNextInterceptor: ReturnType<typeof vi.fn>;
|
||||
let mockRemoteServerConfigCtr: {
|
||||
getAccessToken: ReturnType<typeof vi.fn>;
|
||||
getRemoteServerConfig: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let autoLoadUrlSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
const defaultOptions: BrowserWindowOpts = {
|
||||
height: 600,
|
||||
@@ -133,14 +137,34 @@ describe('Browser', () => {
|
||||
// Create mock App
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
|
||||
mockStoreManagerSet = vi.fn();
|
||||
mockNextInterceptor = vi.fn().mockReturnValue(vi.fn());
|
||||
|
||||
// Browser setup now installs protocol handlers that depend on RemoteServerConfigCtr
|
||||
mockRemoteServerConfigCtr = {
|
||||
getAccessToken: vi.fn().mockResolvedValue(null),
|
||||
getRemoteServerConfig: vi.fn().mockResolvedValue({
|
||||
remoteServerUrl: 'http://localhost:3000',
|
||||
}),
|
||||
};
|
||||
|
||||
// Ensure Browser can register protocol handlers on the session
|
||||
(mockBrowserWindow.webContents.session as any).protocol = {
|
||||
handle: vi.fn(),
|
||||
};
|
||||
|
||||
mockApp = {
|
||||
browserManager: {
|
||||
retrieveByIdentifier: vi.fn(),
|
||||
},
|
||||
buildRendererUrl: vi.fn(async (path: string) => {
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `http://localhost:3000${cleanPath}`;
|
||||
}),
|
||||
getController: vi.fn((ctr: any) => {
|
||||
// Only the remote server config controller is required in these unit tests
|
||||
if (ctr?.name === 'RemoteServerConfigCtr') return mockRemoteServerConfigCtr;
|
||||
throw new Error(`Unexpected controller requested in Browser tests: ${ctr?.name ?? ctr}`);
|
||||
}),
|
||||
isQuiting: false,
|
||||
nextInterceptor: mockNextInterceptor,
|
||||
nextServerUrl: 'http://localhost:3000',
|
||||
storeManager: {
|
||||
get: mockStoreManagerGet,
|
||||
@@ -149,6 +173,8 @@ describe('Browser', () => {
|
||||
} as unknown as AppCore;
|
||||
|
||||
browser = new Browser(defaultOptions, mockApp);
|
||||
// The constructor triggers an async placeholder->loadUrl chain; stub it to avoid cross-test flakiness.
|
||||
autoLoadUrlSpy = vi.spyOn(browser, 'loadUrl').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -164,10 +190,6 @@ describe('Browser', () => {
|
||||
it('should create BrowserWindow on construction', () => {
|
||||
expect(MockBrowserWindow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should setup next interceptor', () => {
|
||||
expect(mockNextInterceptor).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserWindow getter', () => {
|
||||
@@ -344,12 +366,14 @@ describe('Browser', () => {
|
||||
|
||||
describe('loadUrl', () => {
|
||||
it('should load full URL successfully', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
expect(mockBrowserWindow.loadURL).toHaveBeenCalledWith('http://localhost:3000/test-path');
|
||||
});
|
||||
|
||||
it('should load error page on failure', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
@@ -358,6 +382,7 @@ describe('Browser', () => {
|
||||
});
|
||||
|
||||
it('should setup retry handler on error', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
@@ -367,9 +392,13 @@ describe('Browser', () => {
|
||||
});
|
||||
|
||||
it('should load fallback HTML when error page fails', async () => {
|
||||
autoLoadUrlSpy?.mockRestore();
|
||||
mockBrowserWindow.loadURL.mockRejectedValueOnce(new Error('Load failed'));
|
||||
mockBrowserWindow.loadFile.mockRejectedValueOnce(new Error('Error page failed'));
|
||||
mockBrowserWindow.loadURL.mockResolvedValueOnce(undefined);
|
||||
mockBrowserWindow.loadFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === '/mock/resources/error.html') throw new Error('Error page failed');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await browser.loadUrl('/test-path');
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
* 存储应用中需要用装饰器的类
|
||||
*/
|
||||
export class IoCContainer {
|
||||
static controllers: WeakMap<
|
||||
any,
|
||||
{ methodName: string; mode: 'client' | 'server'; name: string }[]
|
||||
> = new WeakMap();
|
||||
|
||||
static shortcuts: WeakMap<any, { methodName: string; name: string }[]> = new WeakMap();
|
||||
|
||||
static protocolHandlers: WeakMap<any, { action: string; methodName: string; urlType: string }[]> =
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { app, protocol } from 'electron';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
type ResolveRendererFilePath = (url: URL) => Promise<string | null>;
|
||||
type GetExportMimeType = (filePath: string) => string | undefined;
|
||||
|
||||
const RENDERER_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
} as const;
|
||||
|
||||
interface RendererProtocolManagerOptions {
|
||||
getExportMimeType: GetExportMimeType;
|
||||
host?: string;
|
||||
nextExportDir: string;
|
||||
resolveRendererFilePath: ResolveRendererFilePath;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
const RENDERER_DIR = 'next';
|
||||
export class RendererProtocolManager {
|
||||
private readonly scheme: string;
|
||||
private readonly host: string;
|
||||
private readonly nextExportDir: string;
|
||||
private readonly resolveRendererFilePath: ResolveRendererFilePath;
|
||||
private readonly getExportMimeType: GetExportMimeType;
|
||||
private handlerRegistered = false;
|
||||
|
||||
constructor(options: RendererProtocolManagerOptions) {
|
||||
const { nextExportDir, resolveRendererFilePath, getExportMimeType } = options;
|
||||
|
||||
this.scheme = 'app';
|
||||
this.host = RENDERER_DIR;
|
||||
this.nextExportDir = nextExportDir;
|
||||
this.resolveRendererFilePath = resolveRendererFilePath;
|
||||
this.getExportMimeType = getExportMimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full renderer URL with scheme and host
|
||||
*/
|
||||
getRendererUrl(): string {
|
||||
return `${this.scheme}://${this.host}`;
|
||||
}
|
||||
|
||||
get protocolScheme() {
|
||||
return {
|
||||
privileges: RENDERER_PROTOCOL_PRIVILEGES,
|
||||
scheme: this.scheme,
|
||||
};
|
||||
}
|
||||
registerHandler() {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
if (!pathExistsSync(this.nextExportDir)) {
|
||||
createLogger('core:RendererProtocolManager').warn(
|
||||
`Next export directory not found, skip static handler: ${this.nextExportDir}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = createLogger('core:RendererProtocolManager');
|
||||
logger.debug(
|
||||
`Registering renderer ${this.scheme}:// handler for production export at host ${this.host}`,
|
||||
);
|
||||
|
||||
const register = () => {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
protocol.handle(this.scheme, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const hostname = url.hostname;
|
||||
const pathname = url.pathname;
|
||||
const isAssetRequest = this.isAssetRequest(pathname);
|
||||
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
|
||||
|
||||
if (hostname !== this.host) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const buildFileResponse = async (targetPath: string) => {
|
||||
const buffer = await readFile(targetPath);
|
||||
const headers = new Headers();
|
||||
const mimeType = this.getExportMimeType(targetPath);
|
||||
|
||||
if (mimeType) headers.set('Content-Type', mimeType);
|
||||
|
||||
return new Response(buffer, { headers });
|
||||
};
|
||||
|
||||
const resolveEntryFilePath = () =>
|
||||
this.resolveRendererFilePath(new URL(`${this.scheme}://${this.host}/`));
|
||||
|
||||
let filePath = await this.resolveRendererFilePath(url);
|
||||
|
||||
// If the resolved file is the export 404 page, treat it as missing so we can
|
||||
// fall back to the entry HTML for SPA routing (unless explicitly requested).
|
||||
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
|
||||
filePath = null;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Fallback to entry HTML for unknown routes (SPA-like behavior)
|
||||
filePath = await resolveEntryFilePath();
|
||||
if (!filePath || this.is404Html(filePath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildFileResponse(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
|
||||
|
||||
if (isAssetRequest) {
|
||||
return new Response('File Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackPath = await resolveEntryFilePath();
|
||||
if (!fallbackPath || this.is404Html(fallbackPath)) {
|
||||
return new Response('Render file Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await buildFileResponse(fallbackPath);
|
||||
} catch (fallbackError) {
|
||||
logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Failed to serve export asset ${filePath}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
this.handlerRegistered = true;
|
||||
};
|
||||
|
||||
if (app.isReady()) {
|
||||
register();
|
||||
} else {
|
||||
// protocol.handle needs the default session, which is only available after ready
|
||||
|
||||
app.whenReady().then(register);
|
||||
}
|
||||
}
|
||||
|
||||
private isAssetRequest(pathname: string) {
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
const ext = extname(normalizedPathname);
|
||||
|
||||
return (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/manifest.json' ||
|
||||
!!ext
|
||||
);
|
||||
}
|
||||
|
||||
private is404Html(filePath: string) {
|
||||
return basename(filePath) === '404.html';
|
||||
}
|
||||
}
|
||||
@@ -13,52 +13,6 @@ describe('IoCContainer', () => {
|
||||
// For each test, use fresh class instances
|
||||
});
|
||||
|
||||
describe('controllers WeakMap', () => {
|
||||
it('should store controller metadata', () => {
|
||||
const metadata = [{ methodName: 'handleMessage', mode: 'client' as const, name: 'message' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should allow multiple controllers', () => {
|
||||
const metadata1 = [{ methodName: 'method1', mode: 'client' as const, name: 'action1' }];
|
||||
const metadata2 = [{ methodName: 'method2', mode: 'server' as const, name: 'action2' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata1);
|
||||
IoCContainer.controllers.set(AnotherController, metadata2);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(metadata1);
|
||||
expect(IoCContainer.controllers.get(AnotherController)).toEqual(metadata2);
|
||||
});
|
||||
|
||||
it('should allow overwriting controller metadata', () => {
|
||||
const oldMetadata = [{ methodName: 'oldMethod', mode: 'client' as const, name: 'old' }];
|
||||
const newMetadata = [{ methodName: 'newMethod', mode: 'server' as const, name: 'new' }];
|
||||
|
||||
IoCContainer.controllers.set(TestController, oldMetadata);
|
||||
IoCContainer.controllers.set(TestController, newMetadata);
|
||||
|
||||
expect(IoCContainer.controllers.get(TestController)).toEqual(newMetadata);
|
||||
});
|
||||
|
||||
it('should support multiple methods per controller', () => {
|
||||
const metadata = [
|
||||
{ methodName: 'method1', mode: 'client' as const, name: 'action1' },
|
||||
{ methodName: 'method2', mode: 'server' as const, name: 'action2' },
|
||||
{ methodName: 'method3', mode: 'client' as const, name: 'action3' },
|
||||
];
|
||||
|
||||
IoCContainer.controllers.set(TestController, metadata);
|
||||
|
||||
const stored = IoCContainer.controllers.get(TestController);
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored?.[0].mode).toBe('client');
|
||||
expect(stored?.[1].mode).toBe('server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts WeakMap', () => {
|
||||
it('should store shortcut metadata', () => {
|
||||
const metadata = [{ methodName: 'toggleDarkMode', name: 'CmdOrCtrl+Shift+D' }];
|
||||
@@ -141,10 +95,6 @@ describe('IoCContainer', () => {
|
||||
});
|
||||
|
||||
describe('static properties', () => {
|
||||
it('should have controllers as a WeakMap', () => {
|
||||
expect(IoCContainer.controllers).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
|
||||
it('should have shortcuts as a WeakMap', () => {
|
||||
expect(IoCContainer.shortcuts).toBeInstanceOf(WeakMap);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RendererProtocolManager } from '../RendererProtocolManager';
|
||||
|
||||
const {
|
||||
mockApp,
|
||||
mockPathExistsSync,
|
||||
mockProtocol,
|
||||
mockReadFile,
|
||||
protocolHandlerRef,
|
||||
} = vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockPathExistsSync: vi.fn().mockReturnValue(true),
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
protocol: mockProtocol,
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
pathExistsSync: mockPathExistsSync,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('RendererProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
mockPathExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
it('should fall back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (url: URL) => {
|
||||
if (url.pathname === '/missing') return '/export/404.html';
|
||||
if (url.pathname === '/') return '/export/index.html';
|
||||
return null;
|
||||
});
|
||||
const getExportMimeType = vi.fn(() => 'text/html; charset=utf-8');
|
||||
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
getExportMimeType,
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
expect(mockProtocol.handle).toHaveBeenCalled();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({ url: 'app://next/missing' } as any);
|
||||
const body = await response.text();
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(2);
|
||||
expect(resolveRendererFilePath.mock.calls[0][0].pathname).toBe('/missing');
|
||||
expect(resolveRendererFilePath.mock.calls[1][0].pathname).toBe('/');
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/export/index.html');
|
||||
expect(body).toContain('/export/index.html');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should serve 404.html when explicitly requested', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (url: URL) => {
|
||||
if (url.pathname === '/404.html') return '/export/404.html';
|
||||
if (url.pathname === '/') return '/export/index.html';
|
||||
return null;
|
||||
});
|
||||
const getExportMimeType = vi.fn(() => 'text/html; charset=utf-8');
|
||||
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
getExportMimeType,
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({ url: 'app://next/404.html' } as any);
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/export/404.html');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 404 for missing asset requests without fallback', async () => {
|
||||
const resolveRendererFilePath = vi.fn(async (_url: URL) => null);
|
||||
const getExportMimeType = vi.fn();
|
||||
|
||||
const manager = new RendererProtocolManager({
|
||||
getExportMimeType,
|
||||
nextExportDir: '/export',
|
||||
resolveRendererFilePath,
|
||||
});
|
||||
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({ url: 'app://next/logo.png' } as any);
|
||||
|
||||
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import type { DesktopIpcServices } from './controllers/registry';
|
||||
|
||||
declare module '@lobechat/electron-client-ipc' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface DesktopIpcServicesMap extends DesktopIpcServices {}
|
||||
}
|
||||
|
||||
export { type DesktopIpcServices, type DesktopServerIpcServices } from './controllers/registry';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Export types for renderer/server to use
|
||||
export type { DesktopIpcServices, DesktopServerIpcServices } from './controllers/registry';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import 'vite/client';
|
||||
|
||||
export {};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -83,8 +83,8 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
accelerator: 'Command+,',
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl('/settings');
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('macOS.preferences'),
|
||||
},
|
||||
@@ -341,8 +341,8 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{
|
||||
click: async () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
await mainWindow.loadUrl('/settings');
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,23 @@ const repoRoot = path.resolve(__dirname, '../../../../..');
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integration', () => {
|
||||
const searchService = new MacOSSearchServiceImpl();
|
||||
const ensureResults = (results: unknown[], context: string) => {
|
||||
if (results.length > 0) return true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`⚠️ Spotlight returned 0 results for ${context} - indexing may be incomplete`);
|
||||
return false;
|
||||
};
|
||||
|
||||
const ensureResultsOrSkipAssertions = (results: unknown[], hint: string) => {
|
||||
if (results.length > 0) return true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`⚠️ Spotlight returned 0 results for "${hint}". This usually means indexing is incomplete/disabled. Skipping strict assertions.`,
|
||||
);
|
||||
// Keep a minimal assertion so we still validate the call didn't throw.
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
return false;
|
||||
};
|
||||
|
||||
describe('checkSearchServiceStatus', () => {
|
||||
it('should verify Spotlight is available on macOS', async () => {
|
||||
@@ -34,7 +51,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(results, 'package.json')) return;
|
||||
|
||||
// Should find at least one package.json
|
||||
const packageJson = results.find((r) => r.name === 'package.json');
|
||||
@@ -49,7 +66,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(results, 'README')) return;
|
||||
|
||||
// Should contain markdown files
|
||||
const mdFile = results.find((r) => r.type === 'md');
|
||||
@@ -64,7 +81,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(results, 'macOS')) return;
|
||||
|
||||
// Should find the macOS.ts implementation file
|
||||
const macOSFile = results.find((r) => r.name.includes('macOS') && r.type === 'ts');
|
||||
@@ -106,7 +123,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(results, 'test.ts')) return;
|
||||
|
||||
// Should find test files (can be in __tests__ directory or co-located with source files)
|
||||
const testFile = results.find((r) => r.name.endsWith('.test.ts'));
|
||||
@@ -161,6 +178,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'TypeScript identification')) return;
|
||||
const tsFile = results.find((r) => r.name === 'LocalFileCtr.ts');
|
||||
if (tsFile) {
|
||||
expect(tsFile.type).toBe('ts');
|
||||
@@ -176,6 +194,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'JSON identification')) return;
|
||||
const jsonFile = results.find((r) => r.name.includes('tsconfig') && r.type === 'json');
|
||||
if (jsonFile) {
|
||||
expect(jsonFile.type).toBe('json');
|
||||
@@ -191,6 +210,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (!ensureResults(results, 'directory identification')) return;
|
||||
const testDir = results.find((r) => r.name === '__tests__' && r.isDirectory);
|
||||
if (testDir) {
|
||||
expect(testDir.isDirectory).toBe(true);
|
||||
@@ -221,7 +241,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(results, 'package.json (metadata)')) return;
|
||||
|
||||
const file = results[0];
|
||||
|
||||
@@ -279,7 +299,7 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(results, 'LocalFile')) return;
|
||||
|
||||
// Should find LocalFileCtr.ts or similar files
|
||||
const found = results.some(
|
||||
@@ -319,8 +339,8 @@ describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integrati
|
||||
});
|
||||
|
||||
// Both searches should find similar files
|
||||
expect(lowerResults.length).toBeGreaterThan(0);
|
||||
expect(upperResults.length).toBeGreaterThan(0);
|
||||
if (!ensureResultsOrSkipAssertions(lowerResults, 'readme')) return;
|
||||
if (!ensureResultsOrSkipAssertions(upperResults, 'README (case-insensitive)')) return;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@lobehub/desktop-ipc-typings",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./exports.d.ts",
|
||||
"types": "./exports.d.ts",
|
||||
"exports": {
|
||||
".": "./exports.d.ts"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user