mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
529 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8b1ab6616 | |||
| 3891015a3d | |||
| 5babb7d826 | |||
| a7504b696a | |||
| 9dc4308942 | |||
| 082117998d | |||
| 9a74d6c045 | |||
| b1a4f24dc9 | |||
| c47551775b | |||
| 2d83300795 | |||
| 0915538da8 | |||
| b76e3c85b9 | |||
| 29ce0225b2 | |||
| 06878829c9 | |||
| ae613c7c35 | |||
| 8184f9d097 | |||
| 7fac37b983 | |||
| d3570879da | |||
| 1ad80809cf | |||
| 2c97a9e920 | |||
| 246cce28db | |||
| 9ffb6891e4 | |||
| 766ca942b3 | |||
| 147975ae46 | |||
| a6c3317192 | |||
| 4cd6347d7e | |||
| cd7d955e3d | |||
| 61901ddb07 | |||
| 77ed938cfb | |||
| 4c3ac3bce7 | |||
| a142b3384f | |||
| ee80f613df | |||
| 7d05d0270c | |||
| acd5954f15 | |||
| a52c9e5f24 | |||
| bcb998d767 | |||
| c6410b29c5 | |||
| 9872409d98 | |||
| 319a622778 | |||
| 85153f2464 | |||
| 38cfd266f4 | |||
| 53fc0642e0 | |||
| a8c725abd5 | |||
| b8a7f6e9eb | |||
| 2c93d9bb1a | |||
| a2c3b9e375 | |||
| caa9c78623 | |||
| 2072b56708 | |||
| f95aeb2ca6 | |||
| ca7551fb40 | |||
| 0d6cb06d59 | |||
| 23a7c00181 | |||
| 51dbf94576 | |||
| abdfd064e7 | |||
| bb594f87e2 | |||
| b0ee9b434e | |||
| fe1d05a547 | |||
| 1c15ea5907 | |||
| 9bb03bcb96 | |||
| fc57d2a28c | |||
| d7ceee2cdb | |||
| e033931d4e | |||
| 3736a85473 | |||
| ca348ec0df | |||
| d262fdbeaf | |||
| 97b0413020 | |||
| cf2c5a1d37 | |||
| 0511e43a48 | |||
| 1f128f407f | |||
| 52280da8bc | |||
| c23d908b3b | |||
| 85e2572d26 | |||
| 2e8031f865 | |||
| 9c3ddcc99b | |||
| d5ecd0a17c | |||
| f258a2e042 | |||
| b87e0e422e | |||
| 7996e1c431 | |||
| f46edeb2d1 | |||
| 93dddfc2e5 | |||
| 5e4186559b | |||
| 9bfd9bb4a5 | |||
| 9ca54135b5 | |||
| 9250263fd7 | |||
| c782d091dd | |||
| 63cac811cd | |||
| 0eca6f9f4a | |||
| d62733adcc | |||
| 841b7f1c37 | |||
| afd3a47e3d | |||
| 14dd288d50 | |||
| 799395d982 | |||
| 6868d78adb | |||
| f162556607 | |||
| 4388270cf4 | |||
| ac4993a769 | |||
| f1db5e1f11 | |||
| 5f28b2c59e | |||
| 428f05ac8a | |||
| ca2a7d43e9 | |||
| bf2f6daa1b | |||
| 89f3eed4c1 | |||
| 39cdb2057e | |||
| bb33feb0f4 | |||
| 72afed9546 | |||
| 0692ba7406 | |||
| 3292ed83f9 | |||
| 561a38f788 | |||
| 39d91a86c0 | |||
| 71aaf0fac5 | |||
| 287601f8ec | |||
| b36f8781e6 | |||
| 705450a571 | |||
| 331af68b73 | |||
| 5272c7373f | |||
| fb24b6f1b7 | |||
| 2fd65fe8a3 | |||
| 35d5a2c937 | |||
| 42f40d2717 | |||
| ef8a644d8c | |||
| 4ea759af29 | |||
| c73e1e2bfc | |||
| 81c84348bc | |||
| b69c7ff83e | |||
| 4dca708d2c | |||
| 9b9df57c59 | |||
| 8d7a0467db | |||
| 8bc15893b8 | |||
| e9522729c5 | |||
| cf01894077 | |||
| bab0054557 | |||
| 0baacf7301 | |||
| 0c11d5fcee | |||
| b5d945b1fd | |||
| cbee964582 | |||
| 87a38ad0c4 | |||
| f2d4745ad3 | |||
| 0167ac8e28 | |||
| b480227fd0 | |||
| be9678e395 | |||
| 9d6a0a7d99 | |||
| 02f05a875a | |||
| ad34554132 | |||
| 656a33359b | |||
| 84c3932b41 | |||
| 97ff98cada | |||
| 845d3ef58a | |||
| 8d362cf6b6 | |||
| a15eda7fbf | |||
| 4f7bc5acd2 | |||
| 8219124a10 | |||
| 6ce223ed11 | |||
| e5640d499a | |||
| 906917362f | |||
| b63be1c90a | |||
| c69049d6da | |||
| e70a703a7e | |||
| 522a3ec6fa | |||
| e682b1a10d | |||
| 4c5cf41be3 | |||
| 8fda83ec55 | |||
| cee154fc73 | |||
| 18eaa649b5 | |||
| 2f4c25d826 | |||
| 29b1eb2521 | |||
| 037703c8f0 | |||
| cf6bd53141 | |||
| 88e376272c | |||
| 84b039c4f2 | |||
| f178777c8d | |||
| 129df7b888 | |||
| 190b28244e | |||
| 5db5cf582d | |||
| b94d477f01 | |||
| 5c817bc304 | |||
| b3cea58514 | |||
| 2b74d0be05 | |||
| 16a9c8b920 | |||
| e183eacf36 | |||
| 766772eaeb | |||
| 00bac7e9fd | |||
| 508b34a5c8 | |||
| dcea52bb2e | |||
| c62092f63a | |||
| 1d7f67da56 | |||
| c48956e715 | |||
| 07578fe163 | |||
| 9f762e12be | |||
| 2ae4aeb58d | |||
| 4f7356ffab | |||
| 17efa0bd52 | |||
| dc08f10268 | |||
| 0851028205 | |||
| d20c82c115 | |||
| d600a476f0 | |||
| 92a62e70a9 | |||
| deb6b5e5a0 | |||
| bd4ee89a43 | |||
| f20a9108ed | |||
| e56e50b2d6 | |||
| 48b2ec92a1 | |||
| b8f25dec30 | |||
| d617a6cd97 | |||
| 408391eeb6 | |||
| 4a2e671f55 | |||
| 695a261df1 | |||
| 39b723eff4 | |||
| 68937d842c | |||
| b66bc66260 | |||
| 4d06279abd | |||
| 1a8d33fbf4 | |||
| 2c086373cc | |||
| 6eb6b9010b | |||
| c7d49258f8 | |||
| 2280fd6ff9 | |||
| 8eb901c401 | |||
| 185f04e060 | |||
| 235a41ca54 | |||
| b95e741717 | |||
| c3c4319625 | |||
| 29974373f5 | |||
| 729dbe2a0f | |||
| 9154606285 | |||
| 3961a648ca | |||
| 553d13c9f8 | |||
| 68e98b1af4 | |||
| 23ed51887f | |||
| d394743d4d | |||
| 65d87b4571 | |||
| 08ec29f3a2 | |||
| 54f4e18c03 | |||
| 7eb78c43e6 | |||
| 46ccddcd24 | |||
| 11aa0ecad5 | |||
| 0e2bad0a23 | |||
| 4153f182fe | |||
| a48841a368 | |||
| 9ab77b2ea7 | |||
| a91f90340e | |||
| 236b825fa0 | |||
| df7cfa165e | |||
| d98c88b78f | |||
| 3d1b050003 | |||
| 8ec9491b48 | |||
| 62110e08c8 | |||
| d17b07fda9 | |||
| 10201a2ba1 | |||
| 5c66dc2b02 | |||
| 035994f1a8 | |||
| c7b7998505 | |||
| a41c7a3fb7 | |||
| 85454c5e7c | |||
| 84148a8dd3 | |||
| d79ffa37e2 | |||
| c4873d1854 | |||
| de61dfaad4 | |||
| 1bea16f292 | |||
| 1da176191b | |||
| 7e1dd02d7c | |||
| b0dd7be095 | |||
| 3cf6242877 | |||
| da063a726a | |||
| 59b6ac3a1c | |||
| 8bab7ad448 | |||
| f69e7a22df | |||
| ffcdd5fac0 | |||
| bbf1c0bbe9 | |||
| fd226d03d4 | |||
| 599e199b91 | |||
| 7cca60fcb8 | |||
| d517f77d1d | |||
| c8e5a630ed | |||
| 9a231ee6d3 | |||
| fa89dbf6b7 | |||
| 6d0f09fa1e | |||
| 0d8188c60b | |||
| aae047265e | |||
| f1ffaff96f | |||
| 74637839f5 | |||
| 4d9181ece0 | |||
| f833a461aa | |||
| cb8c606d06 | |||
| 20666db14f | |||
| e6fc44be76 | |||
| 1be312cb25 | |||
| 9c0ac419d0 | |||
| 3556e5986c | |||
| 17090f1e8c | |||
| 6cce80ee9a | |||
| 2da01ca1c7 | |||
| 665e6c99f5 | |||
| ccd2c0b510 | |||
| 9346900a50 | |||
| 5008be7fe9 | |||
| 9e81151487 | |||
| 1fdc49ec0f | |||
| f2ab2fcef6 | |||
| 3eaa645fb0 | |||
| 1500e8cdb3 | |||
| 5262a73308 | |||
| 1a84f2cb00 | |||
| 80202ed4ff | |||
| 0009816364 | |||
| af839cc5c3 | |||
| 5c9baf490f | |||
| 1d8d5cda30 | |||
| bd071fa3c4 | |||
| fa179fc934 | |||
| 4ee4590630 | |||
| 788d7046ea | |||
| 65cf324d07 | |||
| 44776b40bc | |||
| 637460bdf0 | |||
| 4363f7d306 | |||
| d7f927d934 | |||
| 9137dba6a0 | |||
| c265f6c8ad | |||
| c58cfd98a4 | |||
| 9ca8f7bbe2 | |||
| 799e6fd0fd | |||
| 838a7d4eed | |||
| 955c92e4f1 | |||
| c1e7381a33 | |||
| adad02a93c | |||
| f966f7f4a5 | |||
| fde1d8bfac | |||
| dc5917948c | |||
| 9ab6935deb | |||
| 2516b89c7c | |||
| 4565692cb9 | |||
| 78df7a7e86 | |||
| b99bb9a856 | |||
| f110d79228 | |||
| 3513bf4363 | |||
| 2d4d70a66d | |||
| 831948a305 | |||
| 7eaa7ca326 | |||
| 08f97dcee4 | |||
| 5f4e1d37fb | |||
| a5e0e5acb2 | |||
| f49996cc84 | |||
| 6b01095243 | |||
| 9bb717696b | |||
| abb71ec846 | |||
| 39a7399765 | |||
| 57858916eb | |||
| 2e408554e2 | |||
| 86b5e9eb4c | |||
| b3243cf6fb | |||
| efb4c22aff | |||
| 8abfdecd54 | |||
| e73e877db1 | |||
| 4e7f383ff4 | |||
| 103c4d70fc | |||
| 3a6468ff48 | |||
| 7342315875 | |||
| aa7f98c00d | |||
| f78682c704 | |||
| 6ec01a3c83 | |||
| 39a80c5604 | |||
| 1b8a981e5b | |||
| 44247fd1f6 | |||
| 1b837f7dd0 | |||
| 7a2ca19d5b | |||
| 65dbc6383d | |||
| e303979e0f | |||
| f31b5294f3 | |||
| 9962f64eeb | |||
| 8c57dfd3fe | |||
| a1fc90d8b9 | |||
| 26daac5a6d | |||
| 1d95153960 | |||
| 51d7d187e8 | |||
| 7c6805f819 | |||
| 177f3f5589 | |||
| e0af4e6c45 | |||
| 7f7e571755 | |||
| 0df0378def | |||
| 4376ba2bb2 | |||
| 0fdf70944d | |||
| 5db4f58d85 | |||
| d4a5d4d443 | |||
| 267d4d80b1 | |||
| 5a8fd853d7 | |||
| d149c4dbdf | |||
| f46cc50e15 | |||
| 67cb0711d6 | |||
| 747c59c36a | |||
| 6c5caf9184 | |||
| 4684ee7f4c | |||
| 09cbde3a3b | |||
| d6fded2973 | |||
| ed00394c6e | |||
| 8558231037 | |||
| ef843c2b81 | |||
| ea42e60ddb | |||
| 840382b5b5 | |||
| bf53c116dd | |||
| fbb96eb4f8 | |||
| 6fa438c522 | |||
| 7fa6c749db | |||
| b03d6cd940 | |||
| 01e009e89a | |||
| 1997ec5fe2 | |||
| e568ce6f31 | |||
| 1fe6a5997b | |||
| e75ca3026b | |||
| a8534da718 | |||
| 785b0e04f1 | |||
| 3e665a4f5c | |||
| 2dd134e0b8 | |||
| 12577ec03c | |||
| 9d87676d6a | |||
| 55d92c0241 | |||
| dec137a21e | |||
| 863521105e | |||
| 9faf4c6b52 | |||
| 932ceb218b | |||
| 3c197360e1 | |||
| 8d3bc91e46 | |||
| 2558b31963 | |||
| 2ac1ff324b | |||
| 26533b4938 | |||
| 15dd7ecea4 | |||
| e264c3ab9d | |||
| 490fee002e | |||
| 55c1149d95 | |||
| fbc8d20f94 | |||
| 411f875584 | |||
| 8d34065833 | |||
| 11fb073e2a | |||
| 295e8fc3b2 | |||
| b2f8b4e191 | |||
| 263ba692cb | |||
| 28245be7f3 | |||
| f723b38fa8 | |||
| 5382df8cf8 | |||
| 4c313ced5e | |||
| b6f1fc4a14 | |||
| 0dc112436b | |||
| 24c42f6025 | |||
| 48ee38cf9b | |||
| 654064ff7d | |||
| b5219c220a | |||
| b59ee0aabe | |||
| d481315a66 | |||
| 4f5d1ff6af | |||
| 1bd46e0346 | |||
| a8be073b65 | |||
| 2e911ea9f5 | |||
| a89f4c73b4 | |||
| ebf5a8f649 | |||
| 80b0999467 | |||
| be952f95fd | |||
| d46bb9ad8b | |||
| 8e7f541a0b | |||
| 0eb02ca1c5 | |||
| b90436421b | |||
| e284330678 | |||
| ea9da27e72 | |||
| e99f12f840 | |||
| bbc037912c | |||
| 9881b7b303 | |||
| e9de9e3b52 | |||
| 69f21da3e1 | |||
| 2b7761c36e | |||
| 6734a47759 | |||
| 7f7dcfbff9 | |||
| af33543cba | |||
| 8b619f0a8e | |||
| b81d8f79a4 | |||
| 15ffe289f5 | |||
| 2606f93146 | |||
| c882e75580 | |||
| 6334f62aa1 | |||
| 0af13ca057 | |||
| 68d6457659 | |||
| 3466055968 | |||
| d99a3a80f8 | |||
| cc37acb30b | |||
| eb708e04fe | |||
| 11df190f36 | |||
| a094d5c04c | |||
| f6daefb0a2 | |||
| b8c59be3a7 | |||
| 284faf799b | |||
| 88d194c3b6 | |||
| 6c53a5f026 | |||
| d430836f20 | |||
| 7571b66284 | |||
| 3ec394d77a | |||
| 778dea3853 | |||
| f599617042 | |||
| cc8f5d0639 | |||
| 32d365b544 | |||
| 0ff41dba9c | |||
| fd68cc364c | |||
| ce7a74242f | |||
| 7f9bfda652 | |||
| 5f10aace5b | |||
| 6d14dfe50d | |||
| 12f54759df | |||
| b43d4b27ad | |||
| dab987f64b | |||
| 2e24f72b2a | |||
| 5ebcfa531e | |||
| 1b3f88346b | |||
| 808cb21b99 | |||
| c6dd22eea6 | |||
| da5427ccdf | |||
| d1c7f41bd4 | |||
| 6681989fe6 | |||
| 48a7a08110 | |||
| 0596692e95 | |||
| 59ea77d746 | |||
| 4aa570c904 | |||
| c9ea5336d3 | |||
| ceffce27c3 | |||
| b56c9c51b8 | |||
| 97a6c8e172 | |||
| 727c92ad5b | |||
| 25e45a4cd4 | |||
| 296bad9fa4 | |||
| d8d28371d6 | |||
| a588319cf1 | |||
| 527ab0add7 | |||
| f2230ae536 | |||
| c83fe13d7c | |||
| f4b9d6795b |
@@ -0,0 +1,228 @@
|
||||
# Auto Testing Coverage Assistant
|
||||
|
||||
You are an auto testing assistant. Your task is to add unit tests to improve code coverage in the codebase.
|
||||
|
||||
## Target Directories
|
||||
|
||||
Prioritize modules with business logic:
|
||||
|
||||
- apps/desktop/src/core/
|
||||
- apps/desktop/src/modules/
|
||||
- apps/desktop/src/controllers/
|
||||
- apps/desktop/src/services/
|
||||
- packages/\*/src/
|
||||
- src/services/
|
||||
- src/store/
|
||||
- src/server/routers/
|
||||
- src/server/services/
|
||||
- src/server/modules/
|
||||
- src/libs/
|
||||
- src/utils/
|
||||
|
||||
**Do NOT test**:
|
||||
|
||||
- UI components (\*.tsx React components)
|
||||
- Test files themselves
|
||||
- Generated files
|
||||
- Configuration files
|
||||
- Type definition files
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Select a Module to Process
|
||||
|
||||
**Selection Strategy**:
|
||||
|
||||
- Randomly pick ONE module from the target directories
|
||||
- Prioritize modules that:
|
||||
- Have significant business logic
|
||||
- Have no or minimal test coverage
|
||||
- Already have example test files (easier to follow patterns)
|
||||
- Are large modules with complex logic
|
||||
|
||||
**Module granularity examples**:
|
||||
|
||||
- A single package: `packages/database/src/models`
|
||||
- A desktop module: `apps/desktop/src/modules/auth`
|
||||
- A service directory: `src/services/user`
|
||||
- A store slice: `src/store/chat`
|
||||
|
||||
**Special handling**:
|
||||
|
||||
- If a directory has NO tests but needs coverage → create ONE example test file
|
||||
- If a directory already has some tests → expand coverage to untested functions/classes
|
||||
- Focus on directories with existing test examples (follow their patterns)
|
||||
|
||||
### 2. Analyze Module Structure
|
||||
|
||||
Before writing tests:
|
||||
|
||||
- Identify core business logic functions/classes
|
||||
- Check for existing test files and patterns
|
||||
- Determine testing approach based on module type:
|
||||
- Database models → test CRUD operations
|
||||
- Services → test business logic flows
|
||||
- Controllers → test request handling
|
||||
- Store slices → test state mutations and actions
|
||||
- Utils → test utility functions with edge cases
|
||||
|
||||
### 3. Write Unit Tests
|
||||
|
||||
**Testing Guidelines**:
|
||||
|
||||
- Follow existing test patterns in the codebase
|
||||
- Use Vitest as the testing framework
|
||||
- Focus on business logic, not UI rendering
|
||||
- Write comprehensive tests covering:
|
||||
- Happy path scenarios
|
||||
- Edge cases
|
||||
- Error handling
|
||||
- Input validation
|
||||
- Use descriptive test names: `describe()` and `it()` blocks
|
||||
- Mock external dependencies appropriately
|
||||
- Keep tests isolated and independent
|
||||
|
||||
**Test File Naming**:
|
||||
|
||||
- Place test files next to source files: `filename.test.ts`
|
||||
- Or in `__tests__` directory: `__tests__/filename.test.ts`
|
||||
|
||||
**Example Test Structure**:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { functionToTest } from './module';
|
||||
|
||||
describe('ModuleName', () => {
|
||||
describe('functionName', () => {
|
||||
it('should handle normal case correctly', () => {
|
||||
// Arrange
|
||||
const input = 'test';
|
||||
|
||||
// Act
|
||||
const result = functionToTest(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('expected');
|
||||
});
|
||||
|
||||
it('should handle edge case', () => {
|
||||
// Test edge case
|
||||
});
|
||||
|
||||
it('should throw error on invalid input', () => {
|
||||
// Test error handling
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Run Tests and Fix Issues
|
||||
|
||||
**CRITICAL**: Tests MUST pass before submitting!
|
||||
|
||||
- Run tests using the appropriate command:
|
||||
- Web: `bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
- Packages: `cd packages/[name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
- Wrap file paths in single quotes
|
||||
- Fix any failing tests
|
||||
- Ensure all tests pass before proceeding
|
||||
|
||||
**If tests fail**:
|
||||
|
||||
- Debug and fix the test logic
|
||||
- Check mocks and dependencies
|
||||
- Verify test isolation
|
||||
- If unable to fix after 2 attempts, skip this module and document the issue
|
||||
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/add-tests-[module-name]-[date]`
|
||||
- Commit changes with message format:
|
||||
```
|
||||
✅ test: add unit tests for [module-name]
|
||||
```
|
||||
- Push the branch
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `✅ test: add unit tests for [module-name]`
|
||||
- Body following this template:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
- Added unit tests for `[module-name]`
|
||||
- Total test files added/modified: [number]
|
||||
- Test cases added: [number]
|
||||
- Coverage focus: [brief description of what was tested]
|
||||
|
||||
## Changes
|
||||
|
||||
- [ ] All tests pass successfully
|
||||
- [ ] Business logic coverage improved
|
||||
- [ ] Edge cases and error handling covered
|
||||
- [ ] Tests follow existing patterns
|
||||
|
||||
## Module Processed
|
||||
|
||||
`[module-path]`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- Functions tested: [list key functions]
|
||||
- Coverage type: [unit/integration]
|
||||
- Test approach: [brief description]
|
||||
|
||||
---
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **DO** focus on business logic testing only
|
||||
- **DO** ensure all tests pass before creating PR
|
||||
- **DO** follow existing test patterns in the codebase
|
||||
- **DO** write descriptive test names and comments
|
||||
- **DO** test edge cases and error scenarios
|
||||
- **DO NOT** test UI components (\*.tsx)
|
||||
- **DO NOT** create tests that will fail
|
||||
- **DO NOT** modify production code unless absolutely necessary for testability
|
||||
- **DO NOT** exceed 45 minutes of workflow time
|
||||
- **DO NOT** create tests for generated or configuration files
|
||||
|
||||
## Module Selection Examples
|
||||
|
||||
**Good choices**:
|
||||
|
||||
- `packages/database/src/models/` - Core CRUD operations
|
||||
- `src/services/user/client.ts` - User service business logic
|
||||
- `apps/desktop/src/modules/auth/` - Authentication logic
|
||||
- `src/store/chat/slices/message/` - Message state management
|
||||
- `src/server/services/` - Backend service logic
|
||||
|
||||
**Bad choices**:
|
||||
|
||||
- `src/components/` - UI components (avoid)
|
||||
- `src/app/` - Next.js pages (avoid)
|
||||
- `src/styles/` - Styling files (avoid)
|
||||
- Configuration files (avoid)
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
1. **Arrange-Act-Assert** pattern
|
||||
2. **Mock external dependencies** (APIs, databases, file system)
|
||||
3. **Test one thing per test case**
|
||||
4. **Use descriptive test names**
|
||||
5. **Keep tests fast and isolated**
|
||||
6. **Follow DRY principle with beforeEach/afterEach**
|
||||
7. **Test behavior, not implementation**
|
||||
|
||||
## Example Modules with Test Patterns
|
||||
|
||||
Look for existing test files to understand patterns:
|
||||
|
||||
- `packages/database/src/models/**/*.test.ts` - Database testing patterns
|
||||
- `apps/desktop/src/controllers/**/*.test.ts` - Controller testing patterns
|
||||
- `src/services/**/*.test.ts` - Service testing patterns
|
||||
|
||||
Follow their structure and conventions when adding new tests.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Code Comment Translation Assistant
|
||||
|
||||
You are a code comment translation assistant. Your task is to find non-English comments in the codebase and translate them to English.
|
||||
|
||||
## Target Directories
|
||||
|
||||
- apps/desktop/src/
|
||||
- packages/\*/src/
|
||||
- src
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Select a Module to Process
|
||||
|
||||
Module granularity examples:
|
||||
|
||||
- A single package: `packages/database`
|
||||
- A desktop module: `apps/desktop/src/modules/auth`
|
||||
- A service directory: `src/services/user`
|
||||
|
||||
### 2. Find Non-English Comments
|
||||
|
||||
- Search for files containing non-English characters in comments (excluding test files)
|
||||
- File types to check: `.ts`, `.tsx`
|
||||
- Exclude: `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`, `node_modules`, `dist`, `build`
|
||||
|
||||
### 3. Translate Comments
|
||||
|
||||
- Translate all non-English comments to English while preserving:
|
||||
- Code functionality (do not change any code)
|
||||
- Comment structure and formatting
|
||||
- JSDoc tags and annotations
|
||||
- Markdown formatting in comments
|
||||
- Translation guidelines:
|
||||
- Keep technical terms accurate
|
||||
- Maintain professional tone
|
||||
- Preserve line breaks and indentation
|
||||
- Keep TODO/FIXME/NOTE markers in English
|
||||
|
||||
### 4. Limit Changes
|
||||
|
||||
- **CRITICAL**: Ensure total changes do not exceed 500 lines
|
||||
- If a module would exceed 500 lines, process only part of it
|
||||
- Count lines using: `git diff --stat`
|
||||
- Stop processing files once approaching the 500-line limit
|
||||
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/translate-comments-[module-name]-[date]`
|
||||
- Commit changes with message format:
|
||||
```
|
||||
🌐 chore: translate non-English comments to English in [module-name]
|
||||
```
|
||||
- Push the branch
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `🌐 chore: translate non-English comments to English in [module-name]`
|
||||
- Body following this template:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
- Translated non-English comments to English in `[module-name]`
|
||||
- Total lines changed: [number] lines
|
||||
- Files affected: [number] files
|
||||
|
||||
## Changes
|
||||
|
||||
- [ ] All non-English comments translated to English
|
||||
- [ ] Code functionality unchanged
|
||||
- [ ] Comment formatting preserved
|
||||
|
||||
## Module Processed
|
||||
|
||||
`[module-path]`
|
||||
|
||||
---
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **DO NOT** modify any code logic, only comments
|
||||
- **DO NOT** translate non-English strings in code (only comments)
|
||||
- **DO NOT** exceed 500 lines of changes in one PR
|
||||
- **DO NOT** process test files or generated files
|
||||
- **DO** preserve all code formatting and structure
|
||||
- **DO** ensure translations are technically accurate
|
||||
- **DO** verify changes compile without errors
|
||||
+1
-2
@@ -4,6 +4,5 @@ FEATURE_FLAGS=-check_updates,+pin_list
|
||||
KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
|
||||
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
|
||||
SEARCH_PROVIDERS=search1api
|
||||
NEXT_PUBLIC_SERVICE_MODE='server'
|
||||
NEXT_PUBLIC_IS_DESKTOP_APP=1
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0
|
||||
|
||||
+14
-3
@@ -13,6 +13,17 @@
|
||||
# Default is '0' (enabled)
|
||||
# ENABLED_CSP=1
|
||||
|
||||
# SSRF Protection Settings
|
||||
# Set to '1' to allow connections to private IP addresses (disable SSRF protection)
|
||||
# WARNING: Only enable this in trusted environments
|
||||
# Default is '0' (SSRF protection enabled)
|
||||
# SSRF_ALLOW_PRIVATE_IP_ADDRESS=0
|
||||
|
||||
# Whitelist of allowed private IP addresses (comma-separated)
|
||||
# Only takes effect when SSRF_ALLOW_PRIVATE_IP_ADDRESS is '0'
|
||||
# Example: Allow specific internal servers while keeping SSRF protection
|
||||
# SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
|
||||
|
||||
########################################
|
||||
########## AI Provider Service #########
|
||||
########################################
|
||||
@@ -256,6 +267,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# you need to config the clerk webhook secret key if you want to use the clerk with database
|
||||
#CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Clear allow origin https://clerk.com/docs/guides/dashboard/dns-domains/satellite-domains
|
||||
# Authentication across different domains , use,to splite different origin
|
||||
# NEXT_PUBLIC_CLERK_AUTH_ALLOW_ORIGINS='https://market.lobehub.com,https://lobehub.com'
|
||||
|
||||
# NextAuth related configurations
|
||||
# NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
|
||||
@@ -270,9 +284,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
########## Server Database #############
|
||||
########################################
|
||||
|
||||
# Specify the service mode as server if you want to use the server database
|
||||
# NEXT_PUBLIC_SERVICE_MODE=server
|
||||
|
||||
# Postgres database URL
|
||||
# DATABASE_URL=postgres://username:password@host:port/database
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ UNSAFE_SECRET="ww+0igxjGRAAR/eTNFQ55VmhQB5KE5trFZseuntThJs="
|
||||
UNSAFE_PASSWORD="CHANGE_THIS_PASSWORD_IN_PRODUCTION"
|
||||
|
||||
# Core Server Configuration
|
||||
# Service mode - set to 'server' for server-side deployment
|
||||
NEXT_PUBLIC_SERVICE_MODE=server
|
||||
|
||||
# Service Ports Configuration
|
||||
LOBE_PORT=3010
|
||||
|
||||
@@ -5,34 +5,17 @@ type: Bug
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 Platform'
|
||||
label: '📱 Client Type'
|
||||
description: 'Select how you are accessing LobeChat'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Official Preview'
|
||||
- 'Official Cloud'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Self hosting Docker'
|
||||
- 'Web (Desktop Browser)'
|
||||
- 'Web (Mobile Browser)'
|
||||
- 'Desktop App (Electron)'
|
||||
- 'Mobile App (React Native)'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 Deploymenet mode'
|
||||
multiple: true
|
||||
options:
|
||||
- 'client db (lobe-chat image)'
|
||||
- 'client pgelite db (lobe-chat-pglite image)'
|
||||
- 'server db(lobe-chat-database image)'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 Version'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
@@ -48,6 +31,39 @@ body:
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 Deployment Platform'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Official Cloud'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Self hosting Docker'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🔧 Deployment Mode'
|
||||
multiple: true
|
||||
options:
|
||||
- 'client db (lobe-chat image)'
|
||||
- 'client pgelite db (lobe-chat-pglite image)'
|
||||
- 'server db (lobe-chat-database image)'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 Version'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🌐 Browser'
|
||||
@@ -60,21 +76,49 @@ body:
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🐛 Bug Description'
|
||||
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📷 Recurrence Steps'
|
||||
description: A clear and concise description of how to recurrence.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🚦 Expected Behavior'
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 Additional Information'
|
||||
description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here.
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🛠️ Willing to Submit a PR?'
|
||||
description: Would you be willing to submit a pull request to fix this bug?
|
||||
options:
|
||||
- 'Yes, I am willing to submit a PR'
|
||||
- 'No, but I am happy to help test the fix'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: '✅ Validations'
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Read the [docs](https://lobehub.com/zh/docs).
|
||||
required: true
|
||||
- label: Check that there isn't [already an issue](https://github.com/lobehub/lobe-chat/issues) that reports the same bug to avoid creating a duplicate.
|
||||
required: true
|
||||
- label: Make sure this is a LobeChat issue and not a third-party library or provider issue.
|
||||
required: true
|
||||
- label: Check that this is a concrete bug. For Q&A, please use [GitHub Discussions](https://github.com/lobehub/lobe-chat/discussions) or join our [Discord Server](https://discord.gg/rGHwKq4R).
|
||||
required: true
|
||||
|
||||
@@ -12,10 +12,36 @@
|
||||
- [ ] 📝 docs
|
||||
- [ ] 🔨 chore
|
||||
|
||||
#### 🔗 Related Issue
|
||||
|
||||
<!-- Link to the issue that is fixed by this PR -->
|
||||
|
||||
<!-- Example: Fixes #123, Closes #456, Related to #789 -->
|
||||
|
||||
#### 🔀 Description of Change
|
||||
|
||||
<!-- Thank you for your Pull Request. Please provide a description above. -->
|
||||
|
||||
#### 🧪 How to Test
|
||||
|
||||
<!-- Please describe how you tested your changes -->
|
||||
|
||||
<!-- For AI features, please include test prompts or scenarios -->
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Added/updated tests
|
||||
- [ ] No tests needed
|
||||
|
||||
#### 📸 Screenshots / Videos
|
||||
|
||||
<!-- If this PR includes UI changes, please provide screenshots or videos -->
|
||||
|
||||
| Before | After |
|
||||
| ------ | ----- |
|
||||
| ... | ... |
|
||||
|
||||
#### 📝 Additional Information
|
||||
|
||||
<!-- Add any other context about the Pull Request here. -->
|
||||
|
||||
<!-- Breaking changes? Migration guide? Performance impact? -->
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Claude Auto Testing Coverage
|
||||
description: Automatically add unit tests to improve code coverage
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 05:30 UTC (13:30 Beijing Time)
|
||||
- cron: '30 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_module:
|
||||
description: 'Specific module to add tests (e.g., packages/database, src/services/user)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: auto-testing
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
add-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "claude-bot[bot]"
|
||||
git config --global user.email "claude-bot[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Copy testing prompt
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/auto-testing.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for Auto Testing
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
claude_args: "--allowed-tools Bash,Read,Edit,Write,Glob,Grep"
|
||||
prompt: |
|
||||
Follow the auto testing guide located at:
|
||||
```bash
|
||||
cat /tmp/claude-prompts/auto-testing.md
|
||||
```
|
||||
|
||||
## Task Assignment
|
||||
|
||||
${{ inputs.target_module && format('Process the specified module: {0}', inputs.target_module) || 'Automatically select one module from the target directories that needs test coverage' }}
|
||||
|
||||
## Environment Information
|
||||
- Repository: ${{ github.repository }}
|
||||
- Branch: ${{ github.ref_name }}
|
||||
- Target Module: ${{ inputs.target_module || 'Auto-select' }}
|
||||
- Run ID: ${{ github.run_id }}
|
||||
|
||||
**Start the auto testing process now.**
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Claude Translate Non-English Comments
|
||||
description: Automatically detect and translate non-English comments to English in codebase
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 02:00 UTC (10:00 Beijing Time)
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_module:
|
||||
description: 'Specific module to translate (e.g., packages/database, apps/desktop/src/modules/auth)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: translate-comments
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate-comments:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "claude-bot[bot]"
|
||||
git config --global user.email "claude-bot[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Copy translation prompt
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/translate-comments.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for Comment Translation
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
claude_args: "--allowed-tools Bash,Read,Edit,Glob,Grep"
|
||||
prompt: |
|
||||
Follow the translation guide located at:
|
||||
```bash
|
||||
cat /tmp/claude-prompts/translate-comments.md
|
||||
```
|
||||
|
||||
## Task Assignment
|
||||
|
||||
${{ inputs.target_module && format('Process the specified module: {0}', inputs.target_module) || 'Automatically select one module from the target directories that has not been processed recently' }}
|
||||
|
||||
## Environment Information
|
||||
- Repository: ${{ github.repository }}
|
||||
- Branch: ${{ github.ref_name }}
|
||||
- Target Module: ${{ inputs.target_module || 'Auto-select' }}
|
||||
- Run ID: ${{ github.run_id }}
|
||||
|
||||
**Start the translation process now.**
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
# update issues/comments
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
name: Desktop PR Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发
|
||||
|
||||
# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
# 确保同一 PR 同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
group: pr-${{ github.event.pull_request.number }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Add default permissions
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
PR_TAG_PREFIX: pr- # PR 构建版本的前缀标识
|
||||
@@ -28,9 +30,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -62,9 +64,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -107,9 +109,9 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
@@ -199,7 +201,7 @@ jobs:
|
||||
|
||||
# 上传构建产物
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -226,9 +228,9 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -238,7 +240,7 @@ jobs:
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
@@ -264,7 +266,7 @@ jobs:
|
||||
|
||||
# 上传合并后的构建产物
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: merged-release-pr
|
||||
path: release/
|
||||
@@ -287,7 +289,7 @@ jobs:
|
||||
|
||||
# 下载合并后的构建产物
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: merged-release-pr
|
||||
path: release
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
name: Publish Database Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
types: [synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: lobehub/lobe-chat-database
|
||||
PR_TAG_PREFIX: pr-
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 添加 PR label 触发条件
|
||||
if: |
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'trigger:build-docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
os: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build ${{ matrix.platform }} Image
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
# PR 构建使用特殊的 tag
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
# release 构建使用版本号
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and export
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
context: .
|
||||
file: ./Dockerfile.database
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
rm -rf /tmp/digests
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Comment on PR with Docker build info
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prComment = require('${{ github.workspace }}/.github/scripts/docker-pr-comment.js');
|
||||
const result = await prComment({
|
||||
github,
|
||||
context,
|
||||
dockerMetaJson: ${{ toJSON(steps.meta.outputs.json) }},
|
||||
image: "${{ env.REGISTRY_IMAGE }}",
|
||||
version: "${{ steps.meta.outputs.version }}",
|
||||
dockerhubUrl: "https://hub.docker.com/r/${{ env.REGISTRY_IMAGE }}/tags",
|
||||
platforms: "linux/amd64, linux/arm64",
|
||||
});
|
||||
core.info(`Status: ${result.updated ? 'Updated' : 'Created'}, ID: ${result.id}`);
|
||||
@@ -1,163 +0,0 @@
|
||||
name: Publish Docker Pglite Image
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
types: [synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: lobehub/lobe-chat-pglite
|
||||
PR_TAG_PREFIX: pr-
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 添加 PR label 触发条件
|
||||
if: |
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'trigger:build-docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
os: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build ${{ matrix.platform }} Image
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
# PR 构建使用特殊的 tag
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
# release 构建使用版本号
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and export
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
context: .
|
||||
file: ./Dockerfile.pglite
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
rm -rf /tmp/digests
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
@@ -1,29 +1,32 @@
|
||||
name: Publish Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
# PR 构建时取消旧的运行,但 release 构建不取消
|
||||
cancel-in-progress: ${{ github.event_name != 'release' }}
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: lobehub/lobe-chat
|
||||
REGISTRY_IMAGE: lobehub/lobehub
|
||||
PR_TAG_PREFIX: pr-
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 添加 PR label 触发条件
|
||||
if: |
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'trigger:build-docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
github.event_name == 'release' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'trigger:build-docker'))
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -50,11 +53,12 @@ jobs:
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: pr_meta
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
sanitized_branch=$(echo "${BRANCH_NAME}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
@@ -64,10 +68,10 @@ jobs:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
# PR 构建使用特殊的 tag
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request_target' }}
|
||||
# release 构建使用版本号
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request_target' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
@@ -100,7 +104,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -118,7 +122,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
@@ -129,11 +133,12 @@ jobs:
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: pr_meta
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
sanitized_branch=$(echo "${BRANCH_NAME}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
@@ -142,9 +147,9 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request_target' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request_target' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
@@ -161,3 +166,21 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Comment on PR with Docker build info
|
||||
if: github.event_name == 'pull_request_target'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prComment = require('${{ github.workspace }}/.github/scripts/docker-pr-comment.js');
|
||||
const result = await prComment({
|
||||
github,
|
||||
context,
|
||||
dockerMetaJson: ${{ toJSON(steps.meta.outputs.json) }},
|
||||
image: "${{ env.REGISTRY_IMAGE }}",
|
||||
version: "${{ steps.meta.outputs.version }}",
|
||||
dockerhubUrl: "https://hub.docker.com/r/${{ env.REGISTRY_IMAGE }}/tags",
|
||||
platforms: "linux/amd64, linux/arm64",
|
||||
});
|
||||
core.info(`Status: ${result.updated ? 'Updated' : 'Created'}, ID: ${result.id}`);
|
||||
|
||||
@@ -14,10 +14,21 @@ jobs:
|
||||
e2e:
|
||||
name: Test Web App
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: paradedb/paradedb:latest
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -33,20 +44,23 @@ jobs:
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
PORT: 3010
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_DRIVER: node
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
run: bun run e2e
|
||||
|
||||
- name: Upload Playwright HTML report (on failure)
|
||||
- name: Upload Cucumber HTML report (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report
|
||||
name: cucumber-report
|
||||
path: e2e/reports
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Playwright traces (on failure)
|
||||
- name: Upload screenshots (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
||||
name: test-screenshots
|
||||
path: e2e/screenshots
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -53,9 +53,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -94,9 +94,9 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
# 上传构建产物 (工作流处理重命名,不依赖 electron-builder 钩子)
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -208,9 +208,9 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
|
||||
# 上传合并后的构建产物
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
steps:
|
||||
# 下载合并后的构建产物
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -23,7 +24,6 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@@ -33,9 +33,9 @@ jobs:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -54,8 +54,7 @@ jobs:
|
||||
env:
|
||||
DATABASE_TEST_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_DRIVER: node
|
||||
NEXT_PUBLIC_SERVICE_MODE: server
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
KEY_VAULTS_SECRET: Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=
|
||||
S3_PUBLIC_DOMAIN: https://example.com
|
||||
APP_URL: https://home.com
|
||||
|
||||
|
||||
@@ -50,5 +50,5 @@ jobs:
|
||||

|
||||
|
||||
[lobechat]: https://github.com/lobehub/lobe-chat
|
||||
[tutorial-zh-CN]: https://github.com/lobehub/lobe-chat/wiki/Upstream-Sync.zh-CN
|
||||
[tutorial-en-US]: https://github.com/lobehub/lobe-chat/wiki/Upstream-Sync
|
||||
[tutorial-zh-CN]: https://lobehub.com/zh/docs/self-hosting/advanced/upstream-sync
|
||||
[tutorial-en-US]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
|
||||
|
||||
+54
-20
@@ -21,6 +21,7 @@ jobs:
|
||||
- python-interpreter
|
||||
- context-engine
|
||||
- agent-runtime
|
||||
- conversation-flow
|
||||
|
||||
name: Test package ${{ matrix.package }}
|
||||
|
||||
@@ -28,9 +29,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -63,9 +64,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -96,9 +97,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -119,6 +120,43 @@ jobs:
|
||||
files: ./coverage/app/lcov.info
|
||||
flags: app
|
||||
|
||||
test-desktop:
|
||||
name: Test Desktop App
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
|
||||
|
||||
- name: Test Desktop Client
|
||||
run: pnpm test
|
||||
working-directory: apps/desktop
|
||||
|
||||
- name: Upload Desktop App Coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./apps/desktop/coverage/lcov.info
|
||||
flags: desktop
|
||||
|
||||
test-databsae:
|
||||
name: Test Database
|
||||
|
||||
@@ -132,7 +170,6 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@@ -140,36 +177,33 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm i
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test Client DB
|
||||
run: bun run --filter @lobechat/database test:client-db
|
||||
run: pnpm --filter @lobechat/database test:client-db
|
||||
env:
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
KEY_VAULTS_SECRET: Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=
|
||||
S3_PUBLIC_DOMAIN: https://example.com
|
||||
APP_URL: https://home.com
|
||||
|
||||
- name: Test Coverage
|
||||
run: bun run --filter @lobechat/database test:coverage
|
||||
run: pnpm --filter @lobechat/database test:coverage
|
||||
env:
|
||||
DATABASE_TEST_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_DRIVER: node
|
||||
NEXT_PUBLIC_SERVICE_MODE: server
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
KEY_VAULTS_SECRET: Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=
|
||||
S3_PUBLIC_DOMAIN: https://example.com
|
||||
APP_URL: https://home.com
|
||||
|
||||
|
||||
@@ -117,3 +117,4 @@ CLAUDE.local.md
|
||||
|
||||
prd
|
||||
GEMINI.md
|
||||
e2e/reports
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
const config = require('@lobehub/lint').semanticRelease;
|
||||
|
||||
config.branches = [
|
||||
'main',
|
||||
{
|
||||
name: 'next',
|
||||
prerelease: true,
|
||||
},
|
||||
];
|
||||
|
||||
config.plugins.push([
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
|
||||
+2585
File diff suppressed because it is too large
Load Diff
+62
-6
@@ -37,6 +37,9 @@ FROM base AS builder
|
||||
|
||||
ARG USE_CN_MIRROR
|
||||
ARG NEXT_PUBLIC_BASE_PATH
|
||||
ARG NEXT_PUBLIC_ENABLE_NEXT_AUTH
|
||||
ARG NEXT_PUBLIC_ENABLE_CLERK_AUTH
|
||||
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_ANALYTICS_POSTHOG
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
@@ -48,13 +51,21 @@ ARG FEATURE_FLAGS
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" \
|
||||
FEATURE_FLAGS="${FEATURE_FLAGS}"
|
||||
|
||||
ENV NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-1}" \
|
||||
NEXT_PUBLIC_ENABLE_CLERK_AUTH="${NEXT_PUBLIC_ENABLE_CLERK_AUTH:-0}" \
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}" \
|
||||
CLERK_WEBHOOK_SECRET="whsec_xxx" \
|
||||
APP_URL="http://app.com" \
|
||||
DATABASE_DRIVER="node" \
|
||||
DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" \
|
||||
KEY_VAULTS_SECRET="use-for-build"
|
||||
|
||||
# Sentry
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
|
||||
SENTRY_ORG="" \
|
||||
SENTRY_PROJECT=""
|
||||
|
||||
ENV APP_URL="http://app.com"
|
||||
|
||||
# Posthog
|
||||
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
|
||||
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
|
||||
@@ -90,7 +101,12 @@ RUN \
|
||||
# Use pnpm for corepack
|
||||
&& corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json) \
|
||||
# Install the dependencies
|
||||
&& pnpm i
|
||||
&& pnpm i \
|
||||
# Add db migration dependencies
|
||||
&& mkdir -p /deps \
|
||||
&& cd /deps \
|
||||
&& pnpm init \
|
||||
&& pnpm add pg drizzle-orm
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -106,6 +122,16 @@ COPY --from=base /distroless/ /
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder /app/.next/standalone /app/
|
||||
|
||||
# Copy database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
COPY --from=builder /app/scripts/migrateServerDB/docker.cjs /app/docker.cjs
|
||||
COPY --from=builder /app/scripts/migrateServerDB/errorHint.js /app/errorHint.js
|
||||
|
||||
# copy dependencies
|
||||
COPY --from=builder /deps/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
COPY --from=builder /deps/node_modules/pg /app/node_modules/pg
|
||||
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
|
||||
|
||||
@@ -138,6 +164,7 @@ ENV HOSTNAME="0.0.0.0" \
|
||||
|
||||
# General Variables
|
||||
ENV ACCESS_CODE="" \
|
||||
APP_URL="" \
|
||||
API_KEY_SELECT_MODE="" \
|
||||
DEFAULT_AGENT_CONFIG="" \
|
||||
SYSTEM_AGENT="" \
|
||||
@@ -145,6 +172,30 @@ ENV ACCESS_CODE="" \
|
||||
PROXY_URL="" \
|
||||
ENABLE_AUTH_PROTECTION=""
|
||||
|
||||
# Database
|
||||
ENV KEY_VAULTS_SECRET="" \
|
||||
DATABASE_DRIVER="node" \
|
||||
DATABASE_URL=""
|
||||
|
||||
# Next Auth
|
||||
ENV NEXT_AUTH_SECRET="" \
|
||||
NEXT_AUTH_SSO_PROVIDERS="" \
|
||||
NEXTAUTH_URL=""
|
||||
|
||||
# Clerk
|
||||
ENV CLERK_SECRET_KEY="" \
|
||||
CLERK_WEBHOOK_SECRET=""
|
||||
|
||||
# S3
|
||||
ENV NEXT_PUBLIC_S3_DOMAIN="" \
|
||||
S3_PUBLIC_DOMAIN="" \
|
||||
S3_ACCESS_KEY_ID="" \
|
||||
S3_BUCKET="" \
|
||||
S3_ENDPOINT="" \
|
||||
S3_SECRET_ACCESS_KEY="" \
|
||||
S3_ENABLE_PATH_STYLE="" \
|
||||
S3_SET_ACL=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
# AI21
|
||||
@@ -156,7 +207,7 @@ ENV \
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
# Amazon Bedrock
|
||||
AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
ENABLED_AWS_BEDROCK="" AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
# Azure OpenAI
|
||||
AZURE_API_KEY="" AZURE_API_VERSION="" AZURE_ENDPOINT="" AZURE_MODEL_LIST="" \
|
||||
# Baichuan
|
||||
@@ -165,6 +216,9 @@ ENV \
|
||||
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
|
||||
# Cohere
|
||||
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
|
||||
# ComfyUI
|
||||
ENABLED_COMFYUI="" COMFYUI_BASE_URL="" COMFYUI_AUTH_TYPE="" \
|
||||
COMFYUI_API_KEY="" COMFYUI_USERNAME="" COMFYUI_PASSWORD="" COMFYUI_CUSTOM_HEADERS="" \
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
|
||||
# Fireworks AI
|
||||
@@ -206,7 +260,7 @@ ENV \
|
||||
# Ollama
|
||||
ENABLED_OLLAMA="" OLLAMA_MODEL_LIST="" OLLAMA_PROXY_URL="" \
|
||||
# OpenAI
|
||||
OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
ENABLED_OPENAI="" OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY="" OPENROUTER_MODEL_LIST="" \
|
||||
# Perplexity
|
||||
@@ -260,7 +314,9 @@ ENV \
|
||||
# BFL
|
||||
BFL_API_KEY="" BFL_MODEL_LIST="" \
|
||||
# Vercel AI Gateway
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST=""
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST="" \
|
||||
# Cerebras
|
||||
CEREBRAS_API_KEY="" CEREBRAS_MODEL_LIST=""
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
## Set global build ENV
|
||||
ARG NODEJS_VERSION="24"
|
||||
|
||||
## Base image for all building stages
|
||||
FROM node:${NODEJS_VERSION}-slim AS base
|
||||
|
||||
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/*
|
||||
|
||||
## Builder image, install all the dependencies and build the app
|
||||
FROM base AS builder
|
||||
|
||||
ARG USE_CN_MIRROR
|
||||
ARG NEXT_PUBLIC_BASE_PATH
|
||||
ARG NEXT_PUBLIC_SERVICE_MODE
|
||||
ARG NEXT_PUBLIC_ENABLE_NEXT_AUTH
|
||||
ARG NEXT_PUBLIC_ENABLE_CLERK_AUTH
|
||||
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_ANALYTICS_POSTHOG
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_ANALYTICS_UMAMI
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG FEATURE_FLAGS
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" \
|
||||
FEATURE_FLAGS="${FEATURE_FLAGS}"
|
||||
|
||||
ENV NEXT_PUBLIC_SERVICE_MODE="${NEXT_PUBLIC_SERVICE_MODE:-server}" \
|
||||
NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-1}" \
|
||||
NEXT_PUBLIC_ENABLE_CLERK_AUTH="${NEXT_PUBLIC_ENABLE_CLERK_AUTH:-0}" \
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}" \
|
||||
CLERK_WEBHOOK_SECRET="whsec_xxx" \
|
||||
APP_URL="http://app.com" \
|
||||
DATABASE_DRIVER="node" \
|
||||
DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" \
|
||||
KEY_VAULTS_SECRET="use-for-build"
|
||||
|
||||
# Sentry
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
|
||||
SENTRY_ORG="" \
|
||||
SENTRY_PROJECT=""
|
||||
|
||||
# Posthog
|
||||
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
|
||||
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
|
||||
NEXT_PUBLIC_POSTHOG_KEY="${NEXT_PUBLIC_POSTHOG_KEY}"
|
||||
|
||||
# Umami
|
||||
ENV NEXT_PUBLIC_ANALYTICS_UMAMI="${NEXT_PUBLIC_ANALYTICS_UMAMI}" \
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL="${NEXT_PUBLIC_UMAMI_SCRIPT_URL}" \
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="${NEXT_PUBLIC_UMAMI_WEBSITE_ID}"
|
||||
|
||||
# Node
|
||||
ENV NODE_OPTIONS="--max-old-space-size=6144"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY .npmrc ./
|
||||
COPY packages ./packages
|
||||
|
||||
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
|
||||
|
||||
COPY . .
|
||||
|
||||
# run build standalone for docker version
|
||||
RUN npm run build:docker
|
||||
|
||||
## Application image, copy all the files for production
|
||||
FROM busybox:latest AS app
|
||||
|
||||
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 database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
COPY --from=builder /app/scripts/migrateServerDB/docker.cjs /app/docker.cjs
|
||||
COPY --from=builder /app/scripts/migrateServerDB/errorHint.js /app/errorHint.js
|
||||
|
||||
# copy dependencies
|
||||
COPY --from=builder /deps/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
COPY --from=builder /deps/node_modules/pg /app/node_modules/pg
|
||||
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
|
||||
|
||||
## Production image, copy all the files and run next
|
||||
FROM scratch
|
||||
|
||||
# Copy all the files from app, set the correct permission for prerender cache
|
||||
COPY --from=app / /
|
||||
|
||||
ENV NODE_ENV="production" \
|
||||
NODE_OPTIONS="--dns-result-order=ipv4first --use-openssl-ca" \
|
||||
NODE_EXTRA_CA_CERTS="" \
|
||||
NODE_TLS_REJECT_UNAUTHORIZED="" \
|
||||
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Make the middleware rewrite through local as default
|
||||
# refs: https://github.com/lobehub/lobe-chat/issues/5876
|
||||
ENV MIDDLEWARE_REWRITE_THROUGH_LOCAL="1"
|
||||
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME="0.0.0.0" \
|
||||
PORT="3210"
|
||||
|
||||
# General Variables
|
||||
ENV ACCESS_CODE="" \
|
||||
APP_URL="" \
|
||||
API_KEY_SELECT_MODE="" \
|
||||
DEFAULT_AGENT_CONFIG="" \
|
||||
SYSTEM_AGENT="" \
|
||||
FEATURE_FLAGS="" \
|
||||
PROXY_URL="" \
|
||||
ENABLE_AUTH_PROTECTION=""
|
||||
|
||||
# Database
|
||||
ENV KEY_VAULTS_SECRET="" \
|
||||
DATABASE_DRIVER="node" \
|
||||
DATABASE_URL=""
|
||||
|
||||
# Next Auth
|
||||
ENV NEXT_AUTH_SECRET="" \
|
||||
NEXT_AUTH_SSO_PROVIDERS="" \
|
||||
NEXTAUTH_URL=""
|
||||
|
||||
# Clerk
|
||||
ENV CLERK_SECRET_KEY="" \
|
||||
CLERK_WEBHOOK_SECRET=""
|
||||
|
||||
# S3
|
||||
ENV NEXT_PUBLIC_S3_DOMAIN="" \
|
||||
S3_PUBLIC_DOMAIN="" \
|
||||
S3_ACCESS_KEY_ID="" \
|
||||
S3_BUCKET="" \
|
||||
S3_ENDPOINT="" \
|
||||
S3_SECRET_ACCESS_KEY="" \
|
||||
S3_ENABLE_PATH_STYLE="" \
|
||||
S3_SET_ACL=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
# AI21
|
||||
AI21_API_KEY="" AI21_MODEL_LIST="" \
|
||||
# Ai360
|
||||
AI360_API_KEY="" AI360_MODEL_LIST="" \
|
||||
# AiHubMix
|
||||
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
# Amazon Bedrock
|
||||
AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
# Azure OpenAI
|
||||
AZURE_API_KEY="" AZURE_API_VERSION="" AZURE_ENDPOINT="" AZURE_MODEL_LIST="" \
|
||||
# Baichuan
|
||||
BAICHUAN_API_KEY="" BAICHUAN_MODEL_LIST="" \
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
|
||||
# Cohere
|
||||
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
|
||||
# Fireworks AI
|
||||
FIREWORKSAI_API_KEY="" FIREWORKSAI_MODEL_LIST="" \
|
||||
# Gitee AI
|
||||
GITEE_AI_API_KEY="" GITEE_AI_MODEL_LIST="" \
|
||||
# GitHub
|
||||
GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
|
||||
# Google
|
||||
GOOGLE_API_KEY="" GOOGLE_MODEL_LIST="" GOOGLE_PROXY_URL="" \
|
||||
# Groq
|
||||
GROQ_API_KEY="" GROQ_MODEL_LIST="" GROQ_PROXY_URL="" \
|
||||
# Higress
|
||||
HIGRESS_API_KEY="" HIGRESS_MODEL_LIST="" HIGRESS_PROXY_URL="" \
|
||||
# HuggingFace
|
||||
HUGGINGFACE_API_KEY="" HUGGINGFACE_MODEL_LIST="" HUGGINGFACE_PROXY_URL="" \
|
||||
# Hunyuan
|
||||
HUNYUAN_API_KEY="" HUNYUAN_MODEL_LIST="" \
|
||||
# InternLM
|
||||
INTERNLM_API_KEY="" INTERNLM_MODEL_LIST="" \
|
||||
# Jina
|
||||
JINA_API_KEY="" JINA_MODEL_LIST="" JINA_PROXY_URL="" \
|
||||
# Minimax
|
||||
MINIMAX_API_KEY="" MINIMAX_MODEL_LIST="" \
|
||||
# Mistral
|
||||
MISTRAL_API_KEY="" MISTRAL_MODEL_LIST="" \
|
||||
# ModelScope
|
||||
MODELSCOPE_API_KEY="" MODELSCOPE_MODEL_LIST="" MODELSCOPE_PROXY_URL="" \
|
||||
# Moonshot
|
||||
MOONSHOT_API_KEY="" MOONSHOT_MODEL_LIST="" MOONSHOT_PROXY_URL="" \
|
||||
# Nebius
|
||||
NEBIUS_API_KEY="" NEBIUS_MODEL_LIST="" NEBIUS_PROXY_URL="" \
|
||||
# NewAPI
|
||||
NEWAPI_API_KEY="" NEWAPI_PROXY_URL="" \
|
||||
# Novita
|
||||
NOVITA_API_KEY="" NOVITA_MODEL_LIST="" \
|
||||
# Nvidia NIM
|
||||
NVIDIA_API_KEY="" NVIDIA_MODEL_LIST="" NVIDIA_PROXY_URL="" \
|
||||
# Ollama
|
||||
ENABLED_OLLAMA="" OLLAMA_MODEL_LIST="" OLLAMA_PROXY_URL="" \
|
||||
# OpenAI
|
||||
OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY="" OPENROUTER_MODEL_LIST="" \
|
||||
# Perplexity
|
||||
PERPLEXITY_API_KEY="" PERPLEXITY_MODEL_LIST="" PERPLEXITY_PROXY_URL="" \
|
||||
# PPIO
|
||||
PPIO_API_KEY="" PPIO_MODEL_LIST="" \
|
||||
# Qiniu
|
||||
QINIU_API_KEY="" QINIU_MODEL_LIST="" QINIU_PROXY_URL="" \
|
||||
# Qwen
|
||||
QWEN_API_KEY="" QWEN_MODEL_LIST="" QWEN_PROXY_URL="" \
|
||||
# SambaNova
|
||||
SAMBANOVA_API_KEY="" SAMBANOVA_MODEL_LIST="" \
|
||||
# Search1API
|
||||
SEARCH1API_API_KEY="" SEARCH1API_MODEL_LIST="" \
|
||||
# SenseNova
|
||||
SENSENOVA_API_KEY="" SENSENOVA_MODEL_LIST="" \
|
||||
# SiliconCloud
|
||||
SILICONCLOUD_API_KEY="" SILICONCLOUD_MODEL_LIST="" SILICONCLOUD_PROXY_URL="" \
|
||||
# Spark
|
||||
SPARK_API_KEY="" SPARK_MODEL_LIST="" SPARK_PROXY_URL="" SPARK_SEARCH_MODE="" \
|
||||
# Stepfun
|
||||
STEPFUN_API_KEY="" STEPFUN_MODEL_LIST="" \
|
||||
# Taichu
|
||||
TAICHU_API_KEY="" TAICHU_MODEL_LIST="" \
|
||||
# TogetherAI
|
||||
TOGETHERAI_API_KEY="" TOGETHERAI_MODEL_LIST="" \
|
||||
# Upstage
|
||||
UPSTAGE_API_KEY="" UPSTAGE_MODEL_LIST="" \
|
||||
# v0 (Vercel)
|
||||
V0_API_KEY="" V0_MODEL_LIST="" \
|
||||
# vLLM
|
||||
VLLM_API_KEY="" VLLM_MODEL_LIST="" VLLM_PROXY_URL="" \
|
||||
# Wenxin
|
||||
WENXIN_API_KEY="" WENXIN_MODEL_LIST="" \
|
||||
# xAI
|
||||
XAI_API_KEY="" XAI_MODEL_LIST="" XAI_PROXY_URL="" \
|
||||
# Xinference
|
||||
XINFERENCE_API_KEY="" XINFERENCE_MODEL_LIST="" XINFERENCE_PROXY_URL="" \
|
||||
# 01.AI
|
||||
ZEROONE_API_KEY="" ZEROONE_MODEL_LIST="" \
|
||||
# Zhipu
|
||||
ZHIPU_API_KEY="" ZHIPU_MODEL_LIST="" \
|
||||
# Tencent Cloud
|
||||
TENCENT_CLOUD_API_KEY="" TENCENT_CLOUD_MODEL_LIST="" \
|
||||
# Infini-AI
|
||||
INFINIAI_API_KEY="" INFINIAI_MODEL_LIST="" \
|
||||
# 302.AI
|
||||
AI302_API_KEY="" AI302_MODEL_LIST="" \
|
||||
# FAL
|
||||
ENABLED_FAL="" FAL_API_KEY="" FAL_MODEL_LIST="" \
|
||||
# BFL
|
||||
BFL_API_KEY="" BFL_MODEL_LIST="" \
|
||||
# Vercel AI Gateway
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST="" \
|
||||
# Cerebras
|
||||
CEREBRAS_API_KEY="" CEREBRAS_MODEL_LIST=""
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3210/tcp
|
||||
|
||||
ENTRYPOINT ["/bin/node"]
|
||||
|
||||
CMD ["/app/startServer.js"]
|
||||
@@ -1,269 +0,0 @@
|
||||
## Set global build ENV
|
||||
ARG NODEJS_VERSION="24"
|
||||
|
||||
## Base image for all building stages
|
||||
FROM node:${NODEJS_VERSION}-slim AS base
|
||||
|
||||
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/*
|
||||
|
||||
## Builder image, install all the dependencies and build the app
|
||||
FROM base AS builder
|
||||
|
||||
ARG USE_CN_MIRROR
|
||||
ARG NEXT_PUBLIC_BASE_PATH
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_ANALYTICS_POSTHOG
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_ANALYTICS_UMAMI
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG FEATURE_FLAGS
|
||||
|
||||
ENV NEXT_PUBLIC_CLIENT_DB="pglite"
|
||||
ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" \
|
||||
FEATURE_FLAGS="${FEATURE_FLAGS}"
|
||||
|
||||
# Sentry
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
|
||||
SENTRY_ORG="" \
|
||||
SENTRY_PROJECT=""
|
||||
|
||||
ENV APP_URL="http://app.com"
|
||||
|
||||
# Posthog
|
||||
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
|
||||
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
|
||||
NEXT_PUBLIC_POSTHOG_KEY="${NEXT_PUBLIC_POSTHOG_KEY}"
|
||||
|
||||
# Umami
|
||||
ENV NEXT_PUBLIC_ANALYTICS_UMAMI="${NEXT_PUBLIC_ANALYTICS_UMAMI}" \
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL="${NEXT_PUBLIC_UMAMI_SCRIPT_URL}" \
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="${NEXT_PUBLIC_UMAMI_WEBSITE_ID}"
|
||||
|
||||
# Node
|
||||
ENV NODE_OPTIONS="--max-old-space-size=6144"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY .npmrc ./
|
||||
COPY packages ./packages
|
||||
|
||||
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
|
||||
|
||||
COPY . .
|
||||
|
||||
# run build standalone for docker version
|
||||
RUN npm run build:docker
|
||||
|
||||
## Application image, copy all the files for production
|
||||
FROM busybox:latest AS app
|
||||
|
||||
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 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
|
||||
|
||||
## Production image, copy all the files and run next
|
||||
FROM scratch
|
||||
|
||||
# Copy all the files from app, set the correct permission for prerender cache
|
||||
COPY --from=app / /
|
||||
|
||||
ENV NODE_ENV="production" \
|
||||
NODE_OPTIONS="--dns-result-order=ipv4first --use-openssl-ca" \
|
||||
NODE_EXTRA_CA_CERTS="" \
|
||||
NODE_TLS_REJECT_UNAUTHORIZED="" \
|
||||
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Make the middleware rewrite through local as default
|
||||
# refs: https://github.com/lobehub/lobe-chat/issues/5876
|
||||
ENV MIDDLEWARE_REWRITE_THROUGH_LOCAL="1"
|
||||
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME="0.0.0.0" \
|
||||
PORT="3210"
|
||||
|
||||
# General Variables
|
||||
ENV ACCESS_CODE="" \
|
||||
API_KEY_SELECT_MODE="" \
|
||||
DEFAULT_AGENT_CONFIG="" \
|
||||
SYSTEM_AGENT="" \
|
||||
FEATURE_FLAGS="" \
|
||||
PROXY_URL="" \
|
||||
ENABLE_AUTH_PROTECTION=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
# AI21
|
||||
AI21_API_KEY="" AI21_MODEL_LIST="" \
|
||||
# Ai360
|
||||
AI360_API_KEY="" AI360_MODEL_LIST="" \
|
||||
# AiHubMix
|
||||
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
# Amazon Bedrock
|
||||
AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
# Azure OpenAI
|
||||
AZURE_API_KEY="" AZURE_API_VERSION="" AZURE_ENDPOINT="" AZURE_MODEL_LIST="" \
|
||||
# Baichuan
|
||||
BAICHUAN_API_KEY="" BAICHUAN_MODEL_LIST="" \
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
|
||||
# Cohere
|
||||
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
|
||||
# Fireworks AI
|
||||
FIREWORKSAI_API_KEY="" FIREWORKSAI_MODEL_LIST="" \
|
||||
# Gitee AI
|
||||
GITEE_AI_API_KEY="" GITEE_AI_MODEL_LIST="" \
|
||||
# GitHub
|
||||
GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
|
||||
# Google
|
||||
GOOGLE_API_KEY="" GOOGLE_MODEL_LIST="" GOOGLE_PROXY_URL="" \
|
||||
# Groq
|
||||
GROQ_API_KEY="" GROQ_MODEL_LIST="" GROQ_PROXY_URL="" \
|
||||
# Higress
|
||||
HIGRESS_API_KEY="" HIGRESS_MODEL_LIST="" HIGRESS_PROXY_URL="" \
|
||||
# HuggingFace
|
||||
HUGGINGFACE_API_KEY="" HUGGINGFACE_MODEL_LIST="" HUGGINGFACE_PROXY_URL="" \
|
||||
# Hunyuan
|
||||
HUNYUAN_API_KEY="" HUNYUAN_MODEL_LIST="" \
|
||||
# InternLM
|
||||
INTERNLM_API_KEY="" INTERNLM_MODEL_LIST="" \
|
||||
# Jina
|
||||
JINA_API_KEY="" JINA_MODEL_LIST="" JINA_PROXY_URL="" \
|
||||
# Minimax
|
||||
MINIMAX_API_KEY="" MINIMAX_MODEL_LIST="" \
|
||||
# Mistral
|
||||
MISTRAL_API_KEY="" MISTRAL_MODEL_LIST="" \
|
||||
# ModelScope
|
||||
MODELSCOPE_API_KEY="" MODELSCOPE_MODEL_LIST="" MODELSCOPE_PROXY_URL="" \
|
||||
# Moonshot
|
||||
MOONSHOT_API_KEY="" MOONSHOT_MODEL_LIST="" MOONSHOT_PROXY_URL="" \
|
||||
# Nebius
|
||||
NEBIUS_API_KEY="" NEBIUS_MODEL_LIST="" NEBIUS_PROXY_URL="" \
|
||||
# NewAPI
|
||||
NEWAPI_API_KEY="" NEWAPI_PROXY_URL="" \
|
||||
# Novita
|
||||
NOVITA_API_KEY="" NOVITA_MODEL_LIST="" \
|
||||
# Nvidia NIM
|
||||
NVIDIA_API_KEY="" NVIDIA_MODEL_LIST="" NVIDIA_PROXY_URL="" \
|
||||
# Ollama
|
||||
ENABLED_OLLAMA="" OLLAMA_MODEL_LIST="" OLLAMA_PROXY_URL="" \
|
||||
# OpenAI
|
||||
OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY="" OPENROUTER_MODEL_LIST="" \
|
||||
# Perplexity
|
||||
PERPLEXITY_API_KEY="" PERPLEXITY_MODEL_LIST="" PERPLEXITY_PROXY_URL="" \
|
||||
# Qiniu
|
||||
QINIU_API_KEY="" QINIU_MODEL_LIST="" QINIU_PROXY_URL="" \
|
||||
# Qwen
|
||||
QWEN_API_KEY="" QWEN_MODEL_LIST="" QWEN_PROXY_URL="" \
|
||||
# SambaNova
|
||||
SAMBANOVA_API_KEY="" SAMBANOVA_MODEL_LIST="" \
|
||||
# SenseNova
|
||||
SENSENOVA_API_KEY="" SENSENOVA_MODEL_LIST="" \
|
||||
# SiliconCloud
|
||||
SILICONCLOUD_API_KEY="" SILICONCLOUD_MODEL_LIST="" SILICONCLOUD_PROXY_URL="" \
|
||||
# Spark
|
||||
SPARK_API_KEY="" SPARK_MODEL_LIST="" SPARK_PROXY_URL="" SPARK_SEARCH_MODE="" \
|
||||
# Stepfun
|
||||
STEPFUN_API_KEY="" STEPFUN_MODEL_LIST="" \
|
||||
# Taichu
|
||||
TAICHU_API_KEY="" TAICHU_MODEL_LIST="" \
|
||||
# TogetherAI
|
||||
TOGETHERAI_API_KEY="" TOGETHERAI_MODEL_LIST="" \
|
||||
# Upstage
|
||||
UPSTAGE_API_KEY="" UPSTAGE_MODEL_LIST="" \
|
||||
# v0 (Vercel)
|
||||
V0_API_KEY="" V0_MODEL_LIST="" \
|
||||
# vLLM
|
||||
VLLM_API_KEY="" VLLM_MODEL_LIST="" VLLM_PROXY_URL="" \
|
||||
# Wenxin
|
||||
WENXIN_API_KEY="" WENXIN_MODEL_LIST="" \
|
||||
# xAI
|
||||
XAI_API_KEY="" XAI_MODEL_LIST="" XAI_PROXY_URL="" \
|
||||
# Xinference
|
||||
XINFERENCE_API_KEY="" XINFERENCE_MODEL_LIST="" XINFERENCE_PROXY_URL="" \
|
||||
# 01.AI
|
||||
ZEROONE_API_KEY="" ZEROONE_MODEL_LIST="" \
|
||||
# Zhipu
|
||||
ZHIPU_API_KEY="" ZHIPU_MODEL_LIST="" \
|
||||
# Tencent Cloud
|
||||
TENCENT_CLOUD_API_KEY="" TENCENT_CLOUD_MODEL_LIST="" \
|
||||
# Infini-AI
|
||||
INFINIAI_API_KEY="" INFINIAI_MODEL_LIST="" \
|
||||
# 302.AI
|
||||
AI302_API_KEY="" AI302_MODEL_LIST="" \
|
||||
# FAL
|
||||
ENABLED_FAL="" FAL_API_KEY="" FAL_MODEL_LIST="" \
|
||||
# BFL
|
||||
BFL_API_KEY="" BFL_MODEL_LIST="" \
|
||||
# Vercel AI Gateway
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST=""
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3210/tcp
|
||||
|
||||
ENTRYPOINT ["/bin/node"]
|
||||
|
||||
CMD ["/app/startServer.js"]
|
||||
@@ -1,3 +1,10 @@
|
||||
> \[!NOTE]
|
||||
>
|
||||
> **Version Information**
|
||||
>
|
||||
> - **v1.x** (Stable): Available on the [`main`](https://github.com/lobehub/lobe-chat/tree/main) branch
|
||||
> - **v2.x** (In Development): Currently being actively developed on the [`next`](https://github.com/lobehub/lobe-chat/tree/next) branch 🔥
|
||||
|
||||
<div align="center"><a name="readme-top"></a>
|
||||
|
||||
[![][image-banner]][vercel-link]
|
||||
@@ -239,55 +246,11 @@ We have implemented support for the following model service providers:
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
- **[OpenAI](https://lobechat.com/discover/provider/openai)**: OpenAI is a global leader in artificial intelligence research, with models like the GPT series pushing the frontiers of natural language processing. OpenAI is committed to transforming multiple industries through innovative and efficient AI solutions. Their products demonstrate significant performance and cost-effectiveness, widely used in research, business, and innovative applications.
|
||||
- **[Ollama](https://lobechat.com/discover/provider/ollama)**: Ollama provides models that cover a wide range of fields, including code generation, mathematical operations, multilingual processing, and conversational interaction, catering to diverse enterprise-level and localized deployment needs.
|
||||
- **[Anthropic](https://lobechat.com/discover/provider/anthropic)**: Anthropic is a company focused on AI research and development, offering a range of advanced language models such as Claude 3.5 Sonnet, Claude 3 Sonnet, Claude 3 Opus, and Claude 3 Haiku. These models achieve an ideal balance between intelligence, speed, and cost, suitable for various applications from enterprise workloads to rapid-response scenarios. Claude 3.5 Sonnet, as their latest model, has excelled in multiple evaluations while maintaining a high cost-performance ratio.
|
||||
- **[Bedrock](https://lobechat.com/discover/provider/bedrock)**: Bedrock is a service provided by Amazon AWS, focusing on delivering advanced AI language and visual models for enterprises. Its model family includes Anthropic's Claude series, Meta's Llama 3.1 series, and more, offering a range of options from lightweight to high-performance, supporting tasks such as text generation, conversation, and image processing for businesses of varying scales and needs.
|
||||
- **[Google](https://lobechat.com/discover/provider/google)**: Google's Gemini series represents its most advanced, versatile AI models, developed by Google DeepMind, designed for multimodal capabilities, supporting seamless understanding and processing of text, code, images, audio, and video. Suitable for various environments from data centers to mobile devices, it significantly enhances the efficiency and applicability of AI models.
|
||||
- **[DeepSeek](https://lobechat.com/discover/provider/deepseek)**: DeepSeek is a company focused on AI technology research and application, with its latest model DeepSeek-V2.5 integrating general dialogue and code processing capabilities, achieving significant improvements in human preference alignment, writing tasks, and instruction following.
|
||||
- **[Moonshot](https://lobechat.com/discover/provider/moonshot)**: Moonshot is an open-source platform launched by Beijing Dark Side Technology Co., Ltd., providing various natural language processing models with a wide range of applications, including but not limited to content creation, academic research, intelligent recommendations, and medical diagnosis, supporting long text processing and complex generation tasks.
|
||||
- **[OpenRouter](https://lobechat.com/discover/provider/openrouter)**: OpenRouter is a service platform providing access to various cutting-edge large model interfaces, supporting OpenAI, Anthropic, LLaMA, and more, suitable for diverse development and application needs. Users can flexibly choose the optimal model and pricing based on their requirements, enhancing the AI experience.
|
||||
- **[HuggingFace](https://lobechat.com/discover/provider/huggingface)**: The HuggingFace Inference API provides a fast and free way for you to explore thousands of models for various tasks. Whether you are prototyping for a new application or experimenting with the capabilities of machine learning, this API gives you instant access to high-performance models across multiple domains.
|
||||
- **[Cloudflare Workers AI](https://lobechat.com/discover/provider/cloudflare)**: Run serverless GPU-powered machine learning models on Cloudflare's global network.
|
||||
|
||||
<details><summary><kbd>See more providers (+32)</kbd></summary>
|
||||
|
||||
- **[GitHub](https://lobechat.com/discover/provider/github)**: With GitHub Models, developers can become AI engineers and leverage the industry's leading AI models.
|
||||
- **[Novita](https://lobechat.com/discover/provider/novita)**: Novita AI is a platform providing a variety of large language models and AI image generation API services, flexible, reliable, and cost-effective. It supports the latest open-source models like Llama3 and Mistral, offering a comprehensive, user-friendly, and auto-scaling API solution for generative AI application development, suitable for the rapid growth of AI startups.
|
||||
- **[PPIO](https://lobechat.com/discover/provider/ppio)**: PPIO supports stable and cost-efficient open-source LLM APIs, such as DeepSeek, Llama, Qwen etc.
|
||||
- **[302.AI](https://lobechat.com/discover/provider/ai302)**: 302.AI is an on-demand AI application platform offering the most comprehensive AI APIs and online AI applications available on the market.
|
||||
- **[Together AI](https://lobechat.com/discover/provider/togetherai)**: Together AI is dedicated to achieving leading performance through innovative AI models, offering extensive customization capabilities, including rapid scaling support and intuitive deployment processes to meet various enterprise needs.
|
||||
- **[Fireworks AI](https://lobechat.com/discover/provider/fireworksai)**: Fireworks AI is a leading provider of advanced language model services, focusing on functional calling and multimodal processing. Its latest model, Firefunction V2, is based on Llama-3, optimized for function calling, conversation, and instruction following. The visual language model FireLLaVA-13B supports mixed input of images and text. Other notable models include the Llama series and Mixtral series, providing efficient multilingual instruction following and generation support.
|
||||
- **[Groq](https://lobechat.com/discover/provider/groq)**: Groq's LPU inference engine has excelled in the latest independent large language model (LLM) benchmarks, redefining the standards for AI solutions with its remarkable speed and efficiency. Groq represents instant inference speed, demonstrating strong performance in cloud-based deployments.
|
||||
- **[Perplexity](https://lobechat.com/discover/provider/perplexity)**: Perplexity is a leading provider of conversational generation models, offering various advanced Llama 3.1 models that support both online and offline applications, particularly suited for complex natural language processing tasks.
|
||||
- **[Mistral](https://lobechat.com/discover/provider/mistral)**: Mistral provides advanced general, specialized, and research models widely used in complex reasoning, multilingual tasks, and code generation. Through functional calling interfaces, users can integrate custom functionalities for specific applications.
|
||||
- **[ModelScope](https://lobechat.com/discover/provider/modelscope)**: ModelScope is a model-as-a-service platform launched by Alibaba Cloud, offering a wide range of AI models and inference services.
|
||||
- **[Ai21Labs](https://lobechat.com/discover/provider/ai21)**: AI21 Labs builds foundational models and AI systems for enterprises, accelerating the application of generative AI in production.
|
||||
- **[Upstage](https://lobechat.com/discover/provider/upstage)**: Upstage focuses on developing AI models for various business needs, including Solar LLM and document AI, aiming to achieve artificial general intelligence (AGI) for work. It allows for the creation of simple conversational agents through Chat API and supports functional calling, translation, embedding, and domain-specific applications.
|
||||
- **[xAI (Grok)](https://lobechat.com/discover/provider/xai)**: xAI is a company dedicated to building artificial intelligence to accelerate human scientific discovery. Our mission is to advance our collective understanding of the universe.
|
||||
- **[Aliyun Bailian](https://lobechat.com/discover/provider/qwen)**: Tongyi Qianwen is a large-scale language model independently developed by Alibaba Cloud, featuring strong natural language understanding and generation capabilities. It can answer various questions, create written content, express opinions, and write code, playing a role in multiple fields.
|
||||
- **[Wenxin](https://lobechat.com/discover/provider/wenxin)**: An enterprise-level one-stop platform for large model and AI-native application development and services, providing the most comprehensive and user-friendly toolchain for the entire process of generative artificial intelligence model development and application development.
|
||||
- **[Hunyuan](https://lobechat.com/discover/provider/hunyuan)**: A large language model developed by Tencent, equipped with powerful Chinese creative capabilities, logical reasoning abilities in complex contexts, and reliable task execution skills.
|
||||
- **[ZhiPu](https://lobechat.com/discover/provider/zhipu)**: Zhipu AI offers an open platform for multimodal and language models, supporting a wide range of AI application scenarios, including text processing, image understanding, and programming assistance.
|
||||
- **[SiliconCloud](https://lobechat.com/discover/provider/siliconcloud)**: SiliconFlow is dedicated to accelerating AGI for the benefit of humanity, enhancing large-scale AI efficiency through an easy-to-use and cost-effective GenAI stack.
|
||||
- **[01.AI](https://lobechat.com/discover/provider/zeroone)**: 01.AI focuses on AI 2.0 era technologies, vigorously promoting the innovation and application of 'human + artificial intelligence', using powerful models and advanced AI technologies to enhance human productivity and achieve technological empowerment.
|
||||
- **[Spark](https://lobechat.com/discover/provider/spark)**: iFlytek's Spark model provides powerful AI capabilities across multiple domains and languages, utilizing advanced natural language processing technology to build innovative applications suitable for smart hardware, smart healthcare, smart finance, and other vertical scenarios.
|
||||
- **[SenseNova](https://lobechat.com/discover/provider/sensenova)**: SenseNova, backed by SenseTime's robust infrastructure, offers efficient and user-friendly full-stack large model services.
|
||||
- **[Stepfun](https://lobechat.com/discover/provider/stepfun)**: StepFun's large model possesses industry-leading multimodal and complex reasoning capabilities, supporting ultra-long text understanding and powerful autonomous scheduling search engine functions.
|
||||
- **[Baichuan](https://lobechat.com/discover/provider/baichuan)**: Baichuan Intelligence is a company focused on the research and development of large AI models, with its models excelling in domestic knowledge encyclopedias, long text processing, and generative creation tasks in Chinese, surpassing mainstream foreign models. Baichuan Intelligence also possesses industry-leading multimodal capabilities, performing excellently in multiple authoritative evaluations. Its models include Baichuan 4, Baichuan 3 Turbo, and Baichuan 3 Turbo 128k, each optimized for different application scenarios, providing cost-effective solutions.
|
||||
- **[Minimax](https://lobechat.com/discover/provider/minimax)**: MiniMax is a general artificial intelligence technology company established in 2021, dedicated to co-creating intelligence with users. MiniMax has independently developed general large models of different modalities, including trillion-parameter MoE text models, voice models, and image models, and has launched applications such as Conch AI.
|
||||
- **[InternLM](https://lobechat.com/discover/provider/internlm)**: An open-source organization dedicated to the research and development of large model toolchains. It provides an efficient and user-friendly open-source platform for all AI developers, making cutting-edge large models and algorithm technologies easily accessible.
|
||||
- **[Higress](https://lobechat.com/discover/provider/higress)**: Higress is a cloud-native API gateway that was developed internally at Alibaba to address the issues of Tengine reload affecting long-lived connections and the insufficient load balancing capabilities for gRPC/Dubbo.
|
||||
- **[Gitee AI](https://lobechat.com/discover/provider/giteeai)**: Gitee AI's Serverless API provides AI developers with an out of the box large model inference API service.
|
||||
- **[Taichu](https://lobechat.com/discover/provider/taichu)**: The Institute of Automation, Chinese Academy of Sciences, and Wuhan Artificial Intelligence Research Institute have launched a new generation of multimodal large models, supporting comprehensive question-answering tasks such as multi-turn Q\&A, text creation, image generation, 3D understanding, and signal analysis, with stronger cognitive, understanding, and creative abilities, providing a new interactive experience.
|
||||
- **[360 AI](https://lobechat.com/discover/provider/ai360)**: 360 AI is an AI model and service platform launched by 360 Company, offering various advanced natural language processing models, including 360GPT2 Pro, 360GPT Pro, 360GPT Turbo, and 360GPT Turbo Responsibility 8K. These models combine large-scale parameters and multimodal capabilities, widely applied in text generation, semantic understanding, dialogue systems, and code generation. With flexible pricing strategies, 360 AI meets diverse user needs, supports developer integration, and promotes the innovation and development of intelligent applications.
|
||||
- **[Search1API](https://lobechat.com/discover/provider/search1api)**: Search1API provides access to the DeepSeek series of models that can connect to the internet as needed, including standard and fast versions, supporting a variety of model sizes.
|
||||
- **[InfiniAI](https://lobechat.com/discover/provider/infiniai)**: Provides high-performance, easy-to-use, and secure large model services for application developers, covering the entire process from large model development to service deployment.
|
||||
- **[Qiniu](https://lobechat.com/discover/provider/qiniu)**: Qiniu, as a long-established cloud service provider, delivers cost-effective and reliable AI inference services for both real-time and batch processing, with a simple and user-friendly experience.
|
||||
<details><summary><kbd>See more providers (+-10)</kbd></summary>
|
||||
|
||||
</details>
|
||||
|
||||
> 📊 Total providers: [<kbd>**42**</kbd>](https://lobechat.com/discover/providers)
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
@@ -382,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-09-27**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [Web](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | Smart web search that reads and analyzes pages to deliver comprehensive answers from Google results.<br/>`web` `search` |
|
||||
| [Bing_websearch](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | Search for information from the internet base BingApi<br/>`bingsearch` |
|
||||
| [Google CSE](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | Searches Google through their official CSE API.<br/>`web` `search` |
|
||||
| Recent Submits | Description |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [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` |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-09-27**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [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` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
@@ -902,7 +865,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/lobehub/lobe-chat?color=369eff&labelColor=black&logo=github&style=flat-square
|
||||
[github-releasedate-link]: https://github.com/lobehub/lobe-chat/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobe-chat?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/network/stargazers
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobe-chat?color=ffcb47&labelColor=black&style=flat-square
|
||||
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
|
||||
[github-trending-url]: https://trendshift.io/repositories/2256
|
||||
|
||||
+16
-53
@@ -1,3 +1,10 @@
|
||||
> \[!NOTE]
|
||||
>
|
||||
> **版本信息**
|
||||
>
|
||||
> - **v1.x** (稳定版):位于 [`main`](https://github.com/lobehub/lobe-chat/tree/main) 分支
|
||||
> - **v2.x** (开发中):正在 [`next`](https://github.com/lobehub/lobe-chat/tree/next) 分支火热开发中 🔥
|
||||
|
||||
<div align="center"><a name="readme-top"></a>
|
||||
|
||||
[![][image-banner]][vercel-link]
|
||||
@@ -239,55 +246,11 @@ LobeChat 支持文件上传与知识库功能,你可以上传文件、图片
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
- **[OpenAI](https://lobechat.com/discover/provider/openai)**: OpenAI 是全球领先的人工智能研究机构,其开发的模型如 GPT 系列推动了自然语言处理的前沿。OpenAI 致力于通过创新和高效的 AI 解决方案改变多个行业。他们的产品具有显著的性能和经济性,广泛用于研究、商业和创新应用。
|
||||
- **[Ollama](https://lobechat.com/discover/provider/ollama)**: Ollama 提供的模型广泛涵盖代码生成、数学运算、多语种处理和对话互动等领域,支持企业级和本地化部署的多样化需求。
|
||||
- **[Anthropic](https://lobechat.com/discover/provider/anthropic)**: Anthropic 是一家专注于人工智能研究和开发的公司,提供了一系列先进的语言模型,如 Claude 3.5 Sonnet、Claude 3 Sonnet、Claude 3 Opus 和 Claude 3 Haiku。这些模型在智能、速度和成本之间取得了理想的平衡,适用于从企业级工作负载到快速响应的各种应用场景。Claude 3.5 Sonnet 作为其最新模型,在多项评估中表现优异,同时保持了较高的性价比。
|
||||
- **[Bedrock](https://lobechat.com/discover/provider/bedrock)**: Bedrock 是亚马逊 AWS 提供的一项服务,专注于为企业提供先进的 AI 语言模型和视觉模型。其模型家族包括 Anthropic 的 Claude 系列、Meta 的 Llama 3.1 系列等,涵盖从轻量级到高性能的多种选择,支持文本生成、对话、图像处理等多种任务,适用于不同规模和需求的企业应用。
|
||||
- **[Google](https://lobechat.com/discover/provider/google)**: Google 的 Gemini 系列是其最先进、通用的 AI 模型,由 Google DeepMind 打造,专为多模态设计,支持文本、代码、图像、音频和视频的无缝理解与处理。适用于从数据中心到移动设备的多种环境,极大提升了 AI 模型的效率与应用广泛性。
|
||||
- **[DeepSeek](https://lobechat.com/discover/provider/deepseek)**: DeepSeek 是一家专注于人工智能技术研究和应用的公司,其最新模型 DeepSeek-V3 多项评测成绩超越 Qwen2.5-72B 和 Llama-3.1-405B 等开源模型,性能对齐领军闭源模型 GPT-4o 与 Claude-3.5-Sonnet。
|
||||
- **[Moonshot](https://lobechat.com/discover/provider/moonshot)**: Moonshot 是由北京月之暗面科技有限公司推出的开源平台,提供多种自然语言处理模型,应用领域广泛,包括但不限于内容创作、学术研究、智能推荐、医疗诊断等,支持长文本处理和复杂生成任务。
|
||||
- **[OpenRouter](https://lobechat.com/discover/provider/openrouter)**: OpenRouter 是一个提供多种前沿大模型接口的服务平台,支持 OpenAI、Anthropic、LLaMA 及更多,适合多样化的开发和应用需求。用户可根据自身需求灵活选择最优的模型和价格,助力 AI 体验的提升。
|
||||
- **[HuggingFace](https://lobechat.com/discover/provider/huggingface)**: HuggingFace Inference API 提供了一种快速且免费的方式,让您可以探索成千上万种模型,适用于各种任务。无论您是在为新应用程序进行原型设计,还是在尝试机器学习的功能,这个 API 都能让您即时访问多个领域的高性能模型。
|
||||
- **[Cloudflare Workers AI](https://lobechat.com/discover/provider/cloudflare)**: 在 Cloudflare 的全球网络上运行由无服务器 GPU 驱动的机器学习模型。
|
||||
|
||||
<details><summary><kbd>See more providers (+32)</kbd></summary>
|
||||
|
||||
- **[GitHub](https://lobechat.com/discover/provider/github)**: 通过 GitHub 模型,开发人员可以成为 AI 工程师,并使用行业领先的 AI 模型进行构建。
|
||||
- **[Novita](https://lobechat.com/discover/provider/novita)**: Novita AI 是一个提供多种大语言模型与 AI 图像生成的 API 服务的平台,灵活、可靠且具有成本效益。它支持 Llama3、Mistral 等最新的开源模型,并为生成式 AI 应用开发提供了全面、用户友好且自动扩展的 API 解决方案,适合 AI 初创公司的快速发展。
|
||||
- **[PPIO](https://lobechat.com/discover/provider/ppio)**: PPIO 派欧云提供稳定、高性价比的开源模型 API 服务,支持 DeepSeek 全系列、Llama、Qwen 等行业领先大模型。
|
||||
- **[302.AI](https://lobechat.com/discover/provider/ai302)**: 302.AI 是一个按需付费的 AI 应用平台,提供市面上最全的 AI API 和 AI 在线应用
|
||||
- **[Together AI](https://lobechat.com/discover/provider/togetherai)**: Together AI 致力于通过创新的 AI 模型实现领先的性能,提供广泛的自定义能力,包括快速扩展支持和直观的部署流程,满足企业的各种需求。
|
||||
- **[Fireworks AI](https://lobechat.com/discover/provider/fireworksai)**: Fireworks AI 是一家领先的高级语言模型服务商,专注于功能调用和多模态处理。其最新模型 Firefunction V2 基于 Llama-3,优化用于函数调用、对话及指令跟随。视觉语言模型 FireLLaVA-13B 支持图像和文本混合输入。其他 notable 模型包括 Llama 系列和 Mixtral 系列,提供高效的多语言指令跟随与生成支持。
|
||||
- **[Groq](https://lobechat.com/discover/provider/groq)**: Groq 的 LPU 推理引擎在最新的独立大语言模型(LLM)基准测试中表现卓越,以其惊人的速度和效率重新定义了 AI 解决方案的标准。Groq 是一种即时推理速度的代表,在基于云的部署中展现了良好的性能。
|
||||
- **[Perplexity](https://lobechat.com/discover/provider/perplexity)**: Perplexity 是一家领先的对话生成模型提供商,提供多种先进的 Llama 3.1 模型,支持在线和离线应用,特别适用于复杂的自然语言处理任务。
|
||||
- **[Mistral](https://lobechat.com/discover/provider/mistral)**: Mistral 提供先进的通用、专业和研究型模型,广泛应用于复杂推理、多语言任务、代码生成等领域,通过功能调用接口,用户可以集成自定义功能,实现特定应用。
|
||||
- **[ModelScope](https://lobechat.com/discover/provider/modelscope)**: ModelScope 是阿里云推出的模型即服务平台,提供丰富的 AI 模型和推理服务。
|
||||
- **[Ai21Labs](https://lobechat.com/discover/provider/ai21)**: AI21 Labs 为企业构建基础模型和人工智能系统,加速生成性人工智能在生产中的应用。
|
||||
- **[Upstage](https://lobechat.com/discover/provider/upstage)**: Upstage 专注于为各种商业需求开发 AI 模型,包括 Solar LLM 和文档 AI,旨在实现工作的人造通用智能(AGI)。通过 Chat API 创建简单的对话代理,并支持功能调用、翻译、嵌入以及特定领域应用。
|
||||
- **[xAI (Grok)](https://lobechat.com/discover/provider/xai)**: xAI 是一家致力于构建人工智能以加速人类科学发现的公司。我们的使命是推动我们对宇宙的共同理解。
|
||||
- **[Aliyun Bailian](https://lobechat.com/discover/provider/qwen)**: 通义千问是阿里云自主研发的超大规模语言模型,具有强大的自然语言理解和生成能力。它可以回答各种问题、创作文字内容、表达观点看法、撰写代码等,在多个领域发挥作用。
|
||||
- **[Wenxin](https://lobechat.com/discover/provider/wenxin)**: 企业级一站式大模型与 AI 原生应用开发及服务平台,提供最全面易用的生成式人工智能模型开发、应用开发全流程工具链
|
||||
- **[Hunyuan](https://lobechat.com/discover/provider/hunyuan)**: 由腾讯研发的大语言模型,具备强大的中文创作能力,复杂语境下的逻辑推理能力,以及可靠的任务执行能力
|
||||
- **[ZhiPu](https://lobechat.com/discover/provider/zhipu)**: 智谱 AI 提供多模态与语言模型的开放平台,支持广泛的 AI 应用场景,包括文本处理、图像理解与编程辅助等。
|
||||
- **[SiliconCloud](https://lobechat.com/discover/provider/siliconcloud)**: SiliconCloud,基于优秀开源基础模型的高性价比 GenAI 云服务
|
||||
- **[01.AI](https://lobechat.com/discover/provider/zeroone)**: 零一万物致力于推动以人为本的 AI 2.0 技术革命,旨在通过大语言模型创造巨大的经济和社会价值,并开创新的 AI 生态与商业模式。
|
||||
- **[Spark](https://lobechat.com/discover/provider/spark)**: 科大讯飞星火大模型提供多领域、多语言的强大 AI 能力,利用先进的自然语言处理技术,构建适用于智能硬件、智慧医疗、智慧金融等多种垂直场景的创新应用。
|
||||
- **[SenseNova](https://lobechat.com/discover/provider/sensenova)**: 商汤日日新,依托商汤大装置的强大的基础支撑,提供高效易用的全栈大模型服务。
|
||||
- **[Stepfun](https://lobechat.com/discover/provider/stepfun)**: 阶级星辰大模型具备行业领先的多模态及复杂推理能力,支持超长文本理解和强大的自主调度搜索引擎功能。
|
||||
- **[Baichuan](https://lobechat.com/discover/provider/baichuan)**: 百川智能是一家专注于人工智能大模型研发的公司,其模型在国内知识百科、长文本处理和生成创作等中文任务上表现卓越,超越了国外主流模型。百川智能还具备行业领先的多模态能力,在多项权威评测中表现优异。其模型包括 Baichuan 4、Baichuan 3 Turbo 和 Baichuan 3 Turbo 128k 等,分别针对不同应用场景进行优化,提供高性价比的解决方案。
|
||||
- **[Minimax](https://lobechat.com/discover/provider/minimax)**: MiniMax 是 2021 年成立的通用人工智能科技公司,致力于与用户共创智能。MiniMax 自主研发了不同模态的通用大模型,其中包括万亿参数的 MoE 文本大模型、语音大模型以及图像大模型。并推出了海螺 AI 等应用。
|
||||
- **[InternLM](https://lobechat.com/discover/provider/internlm)**: 致力于大模型研究与开发工具链的开源组织。为所有 AI 开发者提供高效、易用的开源平台,让最前沿的大模型与算法技术触手可及
|
||||
- **[Higress](https://lobechat.com/discover/provider/higress)**: Higress 是一款云原生 API 网关,在阿里内部为解决 Tengine reload 对长连接业务有损,以及 gRPC/Dubbo 负载均衡能力不足而诞生。
|
||||
- **[Gitee AI](https://lobechat.com/discover/provider/giteeai)**: Gitee AI 的 Serverless API 为 AI 开发者提供开箱即用的大模型推理 API 服务。
|
||||
- **[Taichu](https://lobechat.com/discover/provider/taichu)**: 中科院自动化研究所和武汉人工智能研究院推出新一代多模态大模型,支持多轮问答、文本创作、图像生成、3D 理解、信号分析等全面问答任务,拥有更强的认知、理解、创作能力,带来全新互动体验。
|
||||
- **[360 AI](https://lobechat.com/discover/provider/ai360)**: 360 AI 是 360 公司推出的 AI 模型和服务平台,提供多种先进的自然语言处理模型,包括 360GPT2 Pro、360GPT Pro、360GPT Turbo 和 360GPT Turbo Responsibility 8K。这些模型结合了大规模参数和多模态能力,广泛应用于文本生成、语义理解、对话系统与代码生成等领域。通过灵活的定价策略,360 AI 满足多样化用户需求,支持开发者集成,推动智能化应用的革新和发展。
|
||||
- **[Search1API](https://lobechat.com/discover/provider/search1api)**: Search1API 提供可根据需要自行联网的 DeepSeek 系列模型的访问,包括标准版和快速版本,支持多种参数规模的模型选择。
|
||||
- **[InfiniAI](https://lobechat.com/discover/provider/infiniai)**: 为应用开发者提供高性能、易上手、安全可靠的大模型服务,覆盖从大模型开发到大模型服务化部署的全流程。
|
||||
- **[Qiniu](https://lobechat.com/discover/provider/qiniu)**: 七牛作为老牌云服务厂商,提供高性价比稳定的实时、批量 AI 推理服务,简单易用。
|
||||
<details><summary><kbd>See more providers (+-10)</kbd></summary>
|
||||
|
||||
</details>
|
||||
|
||||
> 📊 Total providers: [<kbd>**42**</kbd>](https://lobechat.com/discover/providers)
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
@@ -375,12 +338,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-09-27**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
|
||||
| [必应网页搜索](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | 通过 BingApi 搜索互联网上的信息<br/>`bingsearch` |
|
||||
| [谷歌自定义搜索引擎](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | 通过他们的官方自定义搜索引擎 API 搜索谷歌。<br/>`网络` `搜索` |
|
||||
| 最近新增 | 描述 |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [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` `优惠券` |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-09-27**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
@@ -923,7 +886,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/lobehub/lobe-chat?color=369eff&labelColor=black&logo=github&style=flat-square
|
||||
[github-releasedate-link]: https://github.com/lobehub/lobe-chat/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobe-chat?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/network/stargazers
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobe-chat?color=ffcb47&labelColor=black&style=flat-square
|
||||
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
|
||||
[github-trending-url]: https://trendshift.io/repositories/2256
|
||||
|
||||
+20
-19
@@ -32,46 +32,47 @@
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"get-port-please": "^3.1.2",
|
||||
"get-port-please": "^3.2.0",
|
||||
"pdfjs-dist": "4.10.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.20.3",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@typescript/native-preview": "7.0.0-dev.20250711.1",
|
||||
"consola": "^3.1.0",
|
||||
"consola": "^3.4.2",
|
||||
"cookie": "^1.0.2",
|
||||
"electron": "^38.0.0",
|
||||
"electron": "^38.7.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^5.3.3",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-vite": "^3.0.0",
|
||||
"execa": "^9.5.2",
|
||||
"electron-vite": "^3.1.0",
|
||||
"execa": "^9.6.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"just-diff": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"resolve": "^1.22.8",
|
||||
"semver": "^7.5.4",
|
||||
"set-cookie-parser": "^2.7.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3",
|
||||
"undici": "^7.9.0",
|
||||
"vite": "^6.3.5",
|
||||
"resolve": "^1.22.11",
|
||||
"semver": "^7.7.3",
|
||||
"set-cookie-parser": "^2.7.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"vite": "^6.4.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { DataSyncConfig, MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import querystring from 'node:querystring';
|
||||
@@ -14,39 +14,38 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
|
||||
* Implements OAuth authorization flow using intermediate page + polling mechanism
|
||||
*/
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* 远程服务器配置控制器
|
||||
* Remote server configuration controller
|
||||
*/
|
||||
private get remoteServerConfigCtr() {
|
||||
return this.app.getController(RemoteServerConfigCtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前的 PKCE 参数
|
||||
* Current PKCE parameters
|
||||
*/
|
||||
private codeVerifier: string | null = null;
|
||||
private authRequestState: string | null = null;
|
||||
|
||||
/**
|
||||
* 轮询相关参数
|
||||
* Polling related parameters
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
private pollingInterval: NodeJS.Timeout | null = null;
|
||||
private cachedRemoteUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* 自动刷新定时器
|
||||
* Auto-refresh timer
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
private autoRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* 构造 redirect_uri,确保授权和令牌交换时使用相同的 URI
|
||||
* @param remoteUrl 远程服务器 URL
|
||||
* @param includeHandoffId 是否包含 handoff ID(仅在授权时需要)
|
||||
* Construct redirect_uri, ensuring the same URI is used for authorization and token exchange
|
||||
* @param remoteUrl Remote server URL
|
||||
*/
|
||||
private constructRedirectUri(remoteUrl: string): string {
|
||||
const callbackUrl = new URL('/oidc/callback/desktop', remoteUrl);
|
||||
@@ -59,9 +58,12 @@ export default class AuthCtr extends ControllerModule {
|
||||
*/
|
||||
@ipcClientEvent('requestAuthorization')
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
// Clear any old authorization state
|
||||
this.clearAuthorizationState();
|
||||
|
||||
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
|
||||
|
||||
// 缓存远程服务器 URL 用于后续轮询
|
||||
// Cache remote server URL for subsequent polling
|
||||
this.cachedRemoteUrl = remoteUrl;
|
||||
|
||||
logger.info(
|
||||
@@ -114,6 +116,31 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Market OAuth authorization (desktop)
|
||||
*/
|
||||
@ipcClientEvent('requestMarketAuthorization')
|
||||
async requestMarketAuthorization(params: MarketAuthorizationParams) {
|
||||
const { authUrl } = params;
|
||||
|
||||
if (!authUrl) {
|
||||
const errorMessage = 'Market authorization URL is required';
|
||||
logger.error(errorMessage);
|
||||
return { error: errorMessage, success: false };
|
||||
}
|
||||
|
||||
logger.info(`Requesting market authorization via: ${authUrl}`);
|
||||
try {
|
||||
await shell.openExternal(authUrl);
|
||||
logger.debug('Opening market authorization URL in default browser');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Market authorization request failed:', error);
|
||||
return { error: message, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询机制获取凭证
|
||||
*/
|
||||
@@ -133,7 +160,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
// Check if polling has timed out
|
||||
if (Date.now() - startTime > maxPollTime) {
|
||||
logger.warn('Credential polling timed out');
|
||||
this.stopPolling();
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationFailed('Authorization timed out');
|
||||
return;
|
||||
}
|
||||
@@ -167,14 +194,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during credential polling:', error);
|
||||
this.stopPolling();
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
|
||||
}
|
||||
}, pollInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
* Stop polling
|
||||
*/
|
||||
private stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
@@ -184,18 +211,30 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动刷新定时器
|
||||
* Clear authorization state
|
||||
* Called before starting a new authorization flow or after authorization failure/timeout
|
||||
*/
|
||||
private clearAuthorizationState() {
|
||||
logger.debug('Clearing authorization state');
|
||||
this.stopPolling();
|
||||
this.codeVerifier = null;
|
||||
this.authRequestState = null;
|
||||
this.cachedRemoteUrl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh timer
|
||||
*/
|
||||
private startAutoRefresh() {
|
||||
// 先停止现有的定时器
|
||||
// Stop existing timer first
|
||||
this.stopAutoRefresh();
|
||||
|
||||
const checkInterval = 2 * 60 * 1000; // 每 2 分钟检查一次
|
||||
const checkInterval = 2 * 60 * 1000; // Check every 2 minutes
|
||||
logger.debug('Starting auto-refresh timer');
|
||||
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// 检查 token 是否即将过期 (提前 5 分钟刷新)
|
||||
// Check if token is expiring soon (refresh 5 minutes in advance)
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
@@ -208,7 +247,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.broadcastTokenRefreshed();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed: ${result.error}`);
|
||||
// 如果自动刷新失败,停止定时器并清除 token
|
||||
// If auto-refresh fails, stop timer and clear token
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
@@ -222,7 +261,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动刷新定时器
|
||||
* Stop auto-refresh timer
|
||||
*/
|
||||
private stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer) {
|
||||
@@ -233,8 +272,8 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询获取凭证
|
||||
* 直接发送 HTTP 请求到远程服务器
|
||||
* Poll for credentials
|
||||
* Sends HTTP request directly to remote server
|
||||
*/
|
||||
private async pollForCredentials(): Promise<{ code: string; state: string } | null> {
|
||||
if (!this.authRequestState || !this.cachedRemoteUrl) {
|
||||
@@ -242,17 +281,17 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用缓存的远程服务器 URL
|
||||
// Use cached remote server URL
|
||||
const remoteUrl = this.cachedRemoteUrl;
|
||||
|
||||
// 构造请求 URL
|
||||
// Construct request URL
|
||||
const url = new URL('/oidc/handoff', remoteUrl);
|
||||
url.searchParams.set('id', this.authRequestState);
|
||||
url.searchParams.set('client', 'desktop');
|
||||
|
||||
logger.debug(`Polling for credentials: ${url.toString()}`);
|
||||
|
||||
// 直接发送 HTTP 请求
|
||||
// Send HTTP request directly
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -260,9 +299,9 @@ export default class AuthCtr extends ControllerModule {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// 检查响应状态
|
||||
// Check response status
|
||||
if (response.status === 404) {
|
||||
// 凭证还未准备好,这是正常情况
|
||||
// Credentials not ready yet, this is normal
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -270,7 +309,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 解析响应数据
|
||||
// Parse response data
|
||||
const data = (await response.json()) as {
|
||||
data: {
|
||||
id: string;
|
||||
@@ -511,7 +550,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动后初始化
|
||||
* Initialize after app is ready
|
||||
*/
|
||||
afterAppReady() {
|
||||
logger.debug('AuthCtr initialized, checking for existing tokens');
|
||||
@@ -519,7 +558,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* Clean up all timers
|
||||
*/
|
||||
cleanup() {
|
||||
logger.debug('Cleaning up AuthCtr timers');
|
||||
@@ -528,14 +567,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动刷新功能
|
||||
* 在应用启动时检查是否有有效的 token,如果有就启动自动刷新定时器
|
||||
* Initialize auto-refresh functionality
|
||||
* Checks for valid token at app startup and starts auto-refresh timer if token exists
|
||||
*/
|
||||
private async initializeAutoRefresh() {
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// 检查是否配置了远程服务器且处于活动状态
|
||||
// Check if remote server is configured and active
|
||||
if (!config.active || !config.remoteServerUrl) {
|
||||
logger.debug(
|
||||
'Remote server not active or configured, skipping auto-refresh initialization',
|
||||
@@ -543,36 +582,36 @@ export default class AuthCtr extends ControllerModule {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有有效的访问令牌
|
||||
// Check if valid access token exists
|
||||
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!accessToken) {
|
||||
logger.debug('No access token found, skipping auto-refresh initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有过期时间信息
|
||||
// Check if token expiration time exists
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
if (!expiresAt) {
|
||||
logger.debug('No token expiration time found, skipping auto-refresh initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 token 是否已经过期
|
||||
// Check if token has already expired
|
||||
const currentTime = Date.now();
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
|
||||
// 尝试刷新 token
|
||||
// Attempt to refresh token
|
||||
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (refreshResult.success) {
|
||||
logger.info('Token refresh successful during initialization');
|
||||
this.broadcastTokenRefreshed();
|
||||
// 重新启动自动刷新定时器
|
||||
// Restart auto-refresh timer
|
||||
this.startAutoRefresh();
|
||||
return;
|
||||
} else {
|
||||
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
|
||||
// 只有在刷新失败时才清除 token 并要求重新授权
|
||||
// Clear token and require re-authorization only on refresh failure
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
@@ -580,7 +619,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新定时器
|
||||
// Start auto-refresh timer
|
||||
logger.info(
|
||||
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
|
||||
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { extractSubPath, findMatchingRoute } from '~common/routes';
|
||||
|
||||
import { AppBrowsersIdentifiers, BrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
BrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
} from '@/appBrowsers';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, shortcut } from './index';
|
||||
@@ -14,11 +18,16 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@ipcClientEvent('openSettingsWindow')
|
||||
async openSettingsWindow(tab?: string) {
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings window', tab);
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions: OpenSettingsWindowOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
? { tab: typeof options === 'string' ? options : undefined }
|
||||
: options;
|
||||
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings window', normalizedOptions);
|
||||
|
||||
try {
|
||||
await this.app.browserManager.showSettingsWindowWithTab(tab);
|
||||
await this.app.browserManager.showSettingsWindowWithTab(normalizedOptions);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -68,15 +77,37 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
|
||||
try {
|
||||
if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) {
|
||||
const subPath = extractSubPath(path, matchedRoute.pathPrefix);
|
||||
const extractedSubPath = extractSubPath(path, matchedRoute.pathPrefix);
|
||||
const sanitizedSubPath =
|
||||
extractedSubPath && !extractedSubPath.startsWith('?') ? extractedSubPath : undefined;
|
||||
let searchParams: Record<string, string> | undefined;
|
||||
try {
|
||||
const url = new URL(params.url);
|
||||
const entries = Array.from(url.searchParams.entries());
|
||||
if (entries.length > 0) {
|
||||
searchParams = entries.reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[BrowserWindowsCtr] Failed to parse URL for settings route interception:',
|
||||
params.url,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
await this.app.browserManager.showSettingsWindowWithTab(subPath);
|
||||
await this.app.browserManager.showSettingsWindowWithTab({
|
||||
searchParams,
|
||||
tab: sanitizedSubPath,
|
||||
});
|
||||
|
||||
return {
|
||||
intercepted: true,
|
||||
path,
|
||||
source,
|
||||
subPath,
|
||||
subPath: sanitizedSubPath,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
};
|
||||
} else {
|
||||
@@ -105,8 +136,8 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
*/
|
||||
@ipcClientEvent('createMultiInstanceWindow')
|
||||
async createMultiInstanceWindow(params: {
|
||||
templateId: WindowTemplateIdentifiers;
|
||||
path: string;
|
||||
templateId: WindowTemplateIdentifiers;
|
||||
uniqueId?: string;
|
||||
}) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import {
|
||||
EditLocalFileParams,
|
||||
EditLocalFileResult,
|
||||
GlobFilesParams,
|
||||
GlobFilesResult,
|
||||
GrepContentParams,
|
||||
GrepContentResult,
|
||||
ListLocalFileParams,
|
||||
LocalMoveFilesResultItem,
|
||||
LocalReadFileParams,
|
||||
@@ -13,10 +19,10 @@ import {
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
|
||||
import { shell } from 'electron';
|
||||
import * as fs from 'node:fs';
|
||||
import { rename as renamePromise } from 'node:fs/promises';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats, constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
@@ -25,40 +31,15 @@ import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
// 创建日志记录器
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:LocalFileCtr');
|
||||
|
||||
const statPromise = promisify(fs.stat);
|
||||
const readdirPromise = promisify(fs.readdir);
|
||||
const renamePromiseFs = promisify(fs.rename);
|
||||
const accessPromise = promisify(fs.access);
|
||||
const writeFilePromise = promisify(fs.writeFile);
|
||||
|
||||
export default class LocalFileCtr extends ControllerModule {
|
||||
private get searchService() {
|
||||
return this.app.getService(FileSearchService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', { keywords: params.keywords });
|
||||
|
||||
const options: Omit<SearchOptions, 'keywords'> = {
|
||||
limit: 30,
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await this.searchService.search(params.keywords, options);
|
||||
logger.debug('File search completed', { count: results.length });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// ==================== File Operation ====================
|
||||
|
||||
@ipcClientEvent('openLocalFile')
|
||||
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
|
||||
@@ -102,7 +83,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
const results: LocalReadFileResult[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
// 初始化结果对象
|
||||
// Initialize result object
|
||||
logger.debug('Reading single file:', { filePath });
|
||||
const result = await this.readFile({ path: filePath });
|
||||
results.push(result);
|
||||
@@ -158,7 +139,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
|
||||
try {
|
||||
const stats = await statPromise(filePath);
|
||||
const stats = await stat(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
logger.warn('Attempted to read directory content:', { filePath });
|
||||
result.content = 'This is a directory and cannot be read as plain text.';
|
||||
@@ -197,7 +178,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
const results: FileResult[] = [];
|
||||
try {
|
||||
const entries = await readdirPromise(dirPath);
|
||||
const entries = await readdir(dirPath);
|
||||
logger.debug('Directory entries retrieved successfully:', {
|
||||
dirPath,
|
||||
entriesCount: entries.length,
|
||||
@@ -212,7 +193,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
const fullPath = path.join(dirPath, entry);
|
||||
try {
|
||||
const stats = await statPromise(fullPath);
|
||||
const stats = await stat(fullPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
results.push({
|
||||
createdTime: stats.birthtime,
|
||||
@@ -260,7 +241,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 逐个处理移动请求
|
||||
// Process each move request
|
||||
for (const item of items) {
|
||||
const { oldPath: sourcePath, newPath } = item;
|
||||
const logPrefix = `[Moving file ${sourcePath} -> ${newPath}]`;
|
||||
@@ -272,7 +253,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
success: false,
|
||||
};
|
||||
|
||||
// 基本验证
|
||||
// Basic validation
|
||||
if (!sourcePath || !newPath) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: source or target path is empty`);
|
||||
resultItem.error = 'Both oldPath and newPath are required for each item.';
|
||||
@@ -281,9 +262,9 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查源是否存在
|
||||
// Check if source exists
|
||||
try {
|
||||
await accessPromise(sourcePath, fs.constants.F_OK);
|
||||
await access(sourcePath, constants.F_OK);
|
||||
logger.debug(`${logPrefix} Source file exists`);
|
||||
} catch (accessError: any) {
|
||||
if (accessError.code === 'ENOENT') {
|
||||
@@ -297,28 +278,28 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查目标路径是否与源路径相同
|
||||
// Check if target path is the same as source path
|
||||
if (path.normalize(sourcePath) === path.normalize(newPath)) {
|
||||
logger.info(`${logPrefix} Source and target paths are identical, skipping move`);
|
||||
resultItem.success = true;
|
||||
resultItem.newPath = newPath; // 即使未移动,也报告目标路径
|
||||
resultItem.newPath = newPath; // Report target path even if not moved
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// LBYL: 确保目标目录存在
|
||||
// LBYL: Ensure target directory exists
|
||||
const targetDir = path.dirname(newPath);
|
||||
makeSureDirExist(targetDir);
|
||||
logger.debug(`${logPrefix} Ensured target directory exists: ${targetDir}`);
|
||||
|
||||
// 执行移动 (rename)
|
||||
await renamePromiseFs(sourcePath, newPath);
|
||||
// Execute move (rename)
|
||||
await rename(sourcePath, newPath);
|
||||
resultItem.success = true;
|
||||
resultItem.newPath = newPath;
|
||||
logger.info(`${logPrefix} Move successful`);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Move failed:`, error);
|
||||
// 使用与 handleMoveFile 类似的错误处理逻辑
|
||||
// Use similar error handling logic as handleMoveFile
|
||||
let errorMessage = (error as Error).message;
|
||||
if ((error as any).code === 'ENOENT')
|
||||
errorMessage = `Source path not found: ${sourcePath}.`;
|
||||
@@ -334,7 +315,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`;
|
||||
else if ((error as any).code === 'EEXIST')
|
||||
errorMessage = `An item already exists at the target path: ${newPath}.`;
|
||||
// 保留来自访问检查或目录检查的更具体错误
|
||||
// Keep more specific errors from access or directory checks
|
||||
else if (
|
||||
!errorMessage.startsWith('Source path not found') &&
|
||||
!errorMessage.startsWith('Permission denied accessing source path') &&
|
||||
@@ -411,9 +392,9 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
// Perform the rename operation using fs.promises.rename directly
|
||||
// Perform the rename operation using rename directly
|
||||
try {
|
||||
await renamePromise(currentPath, newPath);
|
||||
await rename(currentPath, newPath);
|
||||
logger.info(`${logPrefix} Rename successful: ${currentPath} -> ${newPath}`);
|
||||
// Optionally return the newPath if frontend needs it
|
||||
// return { success: true, newPath: newPath };
|
||||
@@ -444,7 +425,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
const logPrefix = `[Writing file ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
|
||||
|
||||
// 验证参数
|
||||
// Validate parameters
|
||||
if (!filePath) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: path is empty`);
|
||||
return { error: 'Path cannot be empty', success: false };
|
||||
@@ -456,14 +437,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保目标目录存在
|
||||
// Ensure target directory exists (use async to avoid blocking main thread)
|
||||
const dirname = path.dirname(filePath);
|
||||
logger.debug(`${logPrefix} Creating directory: ${dirname}`);
|
||||
fs.mkdirSync(dirname, { recursive: true });
|
||||
await mkdir(dirname, { recursive: true });
|
||||
|
||||
// 写入文件内容
|
||||
// Write file content
|
||||
logger.debug(`${logPrefix} Starting to write content to file`);
|
||||
await writeFilePromise(filePath, content, 'utf8');
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
logger.info(`${logPrefix} File written successfully`, {
|
||||
path: filePath,
|
||||
size: content.length,
|
||||
@@ -478,4 +459,270 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Search & Find ====================
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
keywords: params.keywords,
|
||||
});
|
||||
|
||||
// Build search options from params, mapping directory to onlyIn
|
||||
const options: SearchOptions = {
|
||||
contentContains: params.contentContains,
|
||||
createdAfter: params.createdAfter ? new Date(params.createdAfter) : undefined,
|
||||
createdBefore: params.createdBefore ? new Date(params.createdBefore) : undefined,
|
||||
detailed: params.detailed,
|
||||
exclude: params.exclude,
|
||||
fileTypes: params.fileTypes,
|
||||
keywords: params.keywords,
|
||||
limit: params.limit || 30,
|
||||
liveUpdate: params.liveUpdate,
|
||||
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
|
||||
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
|
||||
onlyIn: params.directory, // Map directory param to onlyIn option
|
||||
sortBy: params.sortBy,
|
||||
sortDirection: params.sortDirection,
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await this.searchService.search(options.keywords, options);
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('grepContent')
|
||||
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting content search`, { output_mode, searchPath });
|
||||
|
||||
try {
|
||||
const regex = new RegExp(
|
||||
pattern,
|
||||
`g${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`,
|
||||
);
|
||||
|
||||
// Determine files to search
|
||||
let filesToSearch: string[] = [];
|
||||
const stats = await stat(searchPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
filesToSearch = [searchPath];
|
||||
} else {
|
||||
// Use glob pattern if provided, otherwise search all files
|
||||
const globPattern = params.glob || '**/*';
|
||||
filesToSearch = await fg(globPattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
// Filter by type if provided
|
||||
if (params.type) {
|
||||
const ext = `.${params.type}`;
|
||||
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
|
||||
|
||||
const matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (const filePath of filesToSearch) {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) continue;
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
if (regex.test(content)) {
|
||||
matches.push(filePath);
|
||||
totalMatches++;
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
const matchedLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const contextBefore = params['-B'] || params['-C'] || 0;
|
||||
const contextAfter = params['-A'] || params['-C'] || 0;
|
||||
|
||||
const startLine = Math.max(0, i - contextBefore);
|
||||
const endLine = Math.min(lines.length - 1, i + contextAfter);
|
||||
|
||||
for (let j = startLine; j <= endLine; j++) {
|
||||
const lineNum = params['-n'] ? `${j + 1}:` : '';
|
||||
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
|
||||
}
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
matches.push(...matchedLines);
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const fileMatches = (content.match(regex) || []).length;
|
||||
if (fileMatches > 0) {
|
||||
matches.push(`${filePath}:${fileMatches}`);
|
||||
totalMatches += fileMatches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('globLocalFiles')
|
||||
async handleGlobFiles({
|
||||
path: searchPath = process.cwd(),
|
||||
pattern,
|
||||
}: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const logPrefix = `[globFiles: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting glob search`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== File Editing ====================
|
||||
|
||||
@ipcClientEvent('editLocalFile')
|
||||
async handleEditFile({
|
||||
file_path: filePath,
|
||||
new_string,
|
||||
old_string,
|
||||
replace_all = false,
|
||||
}: EditLocalFileParams): Promise<EditLocalFileResult> {
|
||||
const logPrefix = `[editFile: ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting file edit`, { replace_all });
|
||||
|
||||
try {
|
||||
// Read file content
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
// Check if old_string exists
|
||||
if (!content.includes(old_string)) {
|
||||
logger.error(`${logPrefix} Old string not found in file`);
|
||||
return {
|
||||
error: 'The specified old_string was not found in the file',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform replacement
|
||||
let newContent: string;
|
||||
let replacements: number;
|
||||
|
||||
if (replace_all) {
|
||||
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const matches = content.match(regex);
|
||||
replacements = matches ? matches.length : 0;
|
||||
newContent = content.replaceAll(old_string, new_string);
|
||||
} else {
|
||||
// Replace only first occurrence
|
||||
const index = content.indexOf(old_string);
|
||||
if (index === -1) {
|
||||
return {
|
||||
error: 'Old string not found',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
newContent =
|
||||
content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
|
||||
logger.info(`${logPrefix} File edited successfully`, { replacements });
|
||||
return {
|
||||
replacements,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Edit failed:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
export default class MenuController extends ControllerModule {
|
||||
/**
|
||||
* 刷新菜单
|
||||
* Refresh menu
|
||||
*/
|
||||
@ipcClientEvent('refreshAppMenu')
|
||||
refreshAppMenu() {
|
||||
// 注意:可能需要根据具体情况决定是否允许渲染进程刷新所有菜单
|
||||
// Note: May need to decide whether to allow renderer process to refresh all menus based on specific circumstances
|
||||
return this.app.menuManager.refreshMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
* Show context menu
|
||||
*/
|
||||
@ipcClientEvent('showContextMenu')
|
||||
showContextMenu(params: { data?: any; type: string }) {
|
||||
@@ -19,11 +19,11 @@ export default class MenuController extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置开发菜单可见性
|
||||
* Set development menu visibility
|
||||
*/
|
||||
@ipcClientEvent('setDevMenuVisibility')
|
||||
setDevMenuVisibility(visible: boolean) {
|
||||
// 调用 MenuManager 的方法来重建应用菜单
|
||||
// Call MenuManager method to rebuild application menu
|
||||
return this.app.menuManager.rebuildAppMenu({ showDevItems: visible });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,31 +13,31 @@ const logger = createLogger('controllers:NotificationCtr');
|
||||
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
/**
|
||||
* 在应用准备就绪后设置桌面通知
|
||||
* Set up desktop notifications after the application is ready
|
||||
*/
|
||||
afterAppReady() {
|
||||
this.setupNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置桌面通知权限和配置
|
||||
* Set up desktop notification permissions and configuration
|
||||
*/
|
||||
private setupNotifications() {
|
||||
logger.debug('Setting up desktop notifications');
|
||||
|
||||
try {
|
||||
// 检查通知支持
|
||||
// Check notification support
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('Desktop notifications are not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
// 在 macOS 上,我们可能需要显式请求通知权限
|
||||
// On macOS, we may need to explicitly request notification permissions
|
||||
if (macOS()) {
|
||||
logger.debug('macOS detected, notification permissions should be handled by system');
|
||||
}
|
||||
|
||||
// 在 Windows 上设置应用用户模型 ID
|
||||
// Set app user model ID on Windows
|
||||
if (windows()) {
|
||||
app.setAppUserModelId('com.lobehub.chat');
|
||||
logger.debug('Set Windows App User Model ID for notifications');
|
||||
@@ -49,34 +49,34 @@ export default class NotificationCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 显示系统桌面通知(仅当窗口隐藏时)
|
||||
* Show system desktop notification (only when window is hidden)
|
||||
*/
|
||||
@ipcClientEvent('showDesktopNotification')
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
logger.debug('收到桌面通知请求:', params);
|
||||
logger.debug('Received desktop notification request:', params);
|
||||
|
||||
try {
|
||||
// 检查通知支持
|
||||
// Check notification support
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('系统不支持桌面通知');
|
||||
logger.warn('System does not support desktop notifications');
|
||||
return { error: 'Desktop notifications not supported', success: false };
|
||||
}
|
||||
|
||||
// 检查窗口是否隐藏
|
||||
// Check if window is hidden
|
||||
const isWindowHidden = this.isMainWindowHidden();
|
||||
|
||||
if (!isWindowHidden) {
|
||||
logger.debug('主窗口可见,跳过桌面通知');
|
||||
logger.debug('Main window is visible, skipping desktop notification');
|
||||
return { reason: 'Window is visible', skipped: true, success: true };
|
||||
}
|
||||
|
||||
logger.info('窗口已隐藏,显示桌面通知:', params.title);
|
||||
logger.info('Window is hidden, showing desktop notification:', params.title);
|
||||
|
||||
const notification = new Notification({
|
||||
body: params.body,
|
||||
// 添加更多配置以确保通知能正常显示
|
||||
// Add more configuration to ensure notifications display properly
|
||||
hasReply: false,
|
||||
silent: params.silent || false,
|
||||
timeoutType: 'default',
|
||||
@@ -84,38 +84,38 @@ export default class NotificationCtr extends ControllerModule {
|
||||
urgency: 'normal',
|
||||
});
|
||||
|
||||
// 添加更多事件监听来调试
|
||||
// Add more event listeners for debugging
|
||||
notification.on('show', () => {
|
||||
logger.info('通知已显示');
|
||||
logger.info('Notification shown');
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
logger.debug('用户点击通知,显示主窗口');
|
||||
logger.debug('User clicked notification, showing main window');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
logger.debug('通知已关闭');
|
||||
logger.debug('Notification closed');
|
||||
});
|
||||
|
||||
notification.on('failed', (error) => {
|
||||
logger.error('通知显示失败:', error);
|
||||
logger.error('Notification display failed:', error);
|
||||
});
|
||||
|
||||
// 使用 Promise 来确保通知显示
|
||||
// Use Promise to ensure notification is shown
|
||||
return new Promise((resolve) => {
|
||||
notification.show();
|
||||
|
||||
// 给通知一些时间来显示,然后检查结果
|
||||
// Give the notification some time to display, then check the result
|
||||
setTimeout(() => {
|
||||
logger.info('通知显示调用完成');
|
||||
logger.info('Notification display call completed');
|
||||
resolve({ success: true });
|
||||
}, 100);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('显示桌面通知失败:', error);
|
||||
logger.error('Failed to show desktop notification:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false,
|
||||
@@ -124,7 +124,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查主窗口是否隐藏
|
||||
* Check if the main window is hidden
|
||||
*/
|
||||
@ipcClientEvent('isMainWindowHidden')
|
||||
isMainWindowHidden(): boolean {
|
||||
@@ -132,23 +132,23 @@ export default class NotificationCtr extends ControllerModule {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const browserWindow = mainWindow.browserWindow;
|
||||
|
||||
// 如果窗口被销毁,认为是隐藏的
|
||||
// If window is destroyed, consider it hidden
|
||||
if (browserWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查窗口是否可见和聚焦
|
||||
// Check if window is visible and focused
|
||||
const isVisible = browserWindow.isVisible();
|
||||
const isFocused = browserWindow.isFocused();
|
||||
const isMinimized = browserWindow.isMinimized();
|
||||
|
||||
logger.debug('窗口状态检查:', { isFocused, isMinimized, isVisible });
|
||||
logger.debug('Window state check:', { isFocused, isMinimized, isVisible });
|
||||
|
||||
// 窗口隐藏的条件:不可见或最小化或失去焦点
|
||||
// Window is hidden if: not visible, minimized, or not focused
|
||||
return !isVisible || isMinimized || !isFocused;
|
||||
} catch (error) {
|
||||
logger.error('检查窗口状态失败:', error);
|
||||
return true; // 发生错误时认为窗口隐藏,确保通知能显示
|
||||
logger.error('Failed to check window state:', error);
|
||||
return true; // Consider window hidden on error to ensure notifications can be shown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +246,8 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
* 使用存储的刷新令牌获取新的访问令牌
|
||||
* Refresh access token
|
||||
* Use stored refresh token to obtain a new access token
|
||||
* Handles concurrent requests by returning the existing refresh promise if one is in progress.
|
||||
*/
|
||||
async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
|
||||
@@ -271,27 +271,27 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
*/
|
||||
private async performTokenRefresh(): Promise<{ error?: string; success: boolean }> {
|
||||
try {
|
||||
// 获取配置信息
|
||||
// Get configuration information
|
||||
const config = await this.getRemoteServerConfig();
|
||||
|
||||
if (!config.remoteServerUrl || !config.active) {
|
||||
logger.warn('Remote server not active or configured, skipping refresh.');
|
||||
return { error: '远程服务器未激活或未配置', success: false };
|
||||
return { error: 'Remote server is not active or configured', success: false };
|
||||
}
|
||||
|
||||
// 获取刷新令牌
|
||||
// Get refresh token
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
logger.error('No refresh token available for refresh operation.');
|
||||
return { error: '没有可用的刷新令牌', success: false };
|
||||
return { error: 'No refresh token available', success: false };
|
||||
}
|
||||
|
||||
// 构造刷新请求
|
||||
// Construct refresh request
|
||||
const remoteUrl = await this.getRemoteServerUrl(config);
|
||||
|
||||
const tokenUrl = new URL('/oidc/token', remoteUrl);
|
||||
|
||||
// 构造请求体
|
||||
// Construct request body
|
||||
const body = querystring.stringify({
|
||||
client_id: 'lobehub-desktop',
|
||||
grant_type: 'refresh_token',
|
||||
@@ -300,7 +300,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
|
||||
logger.debug(`Sending token refresh request to ${tokenUrl.toString()}`);
|
||||
|
||||
// 发送请求
|
||||
// Send request
|
||||
const response = await fetch(tokenUrl.toString(), {
|
||||
body,
|
||||
headers: {
|
||||
@@ -310,25 +310,25 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 尝试解析错误响应
|
||||
// Try to parse error response
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = `刷新令牌失败: ${response.status} ${response.statusText} ${
|
||||
const errorMessage = `Token refresh failed: ${response.status} ${response.statusText} ${
|
||||
errorData.error_description || errorData.error || ''
|
||||
}`.trim();
|
||||
logger.error(errorMessage, errorData);
|
||||
return { error: errorMessage, success: false };
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
// Parse response
|
||||
const data = await response.json();
|
||||
|
||||
// 检查响应中是否包含必要令牌
|
||||
// Check if response contains necessary tokens
|
||||
if (!data.access_token || !data.refresh_token) {
|
||||
logger.error('Refresh response missing access_token or refresh_token', data);
|
||||
return { error: '刷新响应中缺少令牌', success: false };
|
||||
return { error: 'Missing tokens in refresh response', success: false };
|
||||
}
|
||||
|
||||
// 保存新令牌
|
||||
// Save new tokens
|
||||
logger.info('Token refresh successful, saving new tokens.');
|
||||
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
|
||||
|
||||
@@ -336,7 +336,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Exception during token refresh operation:', errorMessage, error);
|
||||
return { error: `刷新令牌时发生异常: ${errorMessage}`, success: false };
|
||||
return { error: `Exception occurred during token refresh: ${errorMessage}`, success: false };
|
||||
} finally {
|
||||
// Ensure the promise reference is cleared once the operation completes
|
||||
logger.debug('Clearing the refresh promise reference.');
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
GetCommandOutputParams,
|
||||
GetCommandOutputResult,
|
||||
KillCommandParams,
|
||||
KillCommandResult,
|
||||
RunCommandParams,
|
||||
RunCommandResult,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ShellCommandCtr');
|
||||
|
||||
interface ShellProcess {
|
||||
lastReadStderr: number;
|
||||
lastReadStdout: number;
|
||||
process: ChildProcess;
|
||||
stderr: string[];
|
||||
stdout: string[];
|
||||
}
|
||||
|
||||
export default class ShellCommandCtr extends ControllerModule {
|
||||
// Shell process management
|
||||
private shellProcesses = new Map<string, ShellProcess>();
|
||||
|
||||
@ipcClientEvent('runCommand')
|
||||
async handleRunCommand({
|
||||
command,
|
||||
description,
|
||||
run_in_background,
|
||||
timeout = 120_000,
|
||||
}: RunCommandParams): Promise<RunCommandResult> {
|
||||
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
|
||||
logger.debug(`${logPrefix} Starting command execution`, {
|
||||
background: run_in_background,
|
||||
timeout,
|
||||
});
|
||||
|
||||
// Validate timeout
|
||||
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
|
||||
|
||||
// Cross-platform shell selection
|
||||
const shellConfig =
|
||||
process.platform === 'win32'
|
||||
? { args: ['/c', command], cmd: 'cmd.exe' }
|
||||
: { args: ['-c', command], cmd: '/bin/sh' };
|
||||
|
||||
try {
|
||||
if (run_in_background) {
|
||||
// Background execution
|
||||
const shellId = randomUUID();
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
const shellProcess: ShellProcess = {
|
||||
lastReadStderr: 0,
|
||||
lastReadStdout: 0,
|
||||
process: childProcess,
|
||||
stderr: [],
|
||||
stdout: [],
|
||||
};
|
||||
|
||||
// Capture output
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
shellProcess.stdout.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
shellProcess.stderr.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
logger.debug(`${logPrefix} Background process exited`, { code, shellId });
|
||||
});
|
||||
|
||||
this.shellProcesses.set(shellId, shellProcess);
|
||||
|
||||
logger.info(`${logPrefix} Started background execution`, { shellId });
|
||||
return {
|
||||
shell_id: shellId,
|
||||
success: true,
|
||||
};
|
||||
} else {
|
||||
// Synchronous execution with timeout
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
killed = true;
|
||||
childProcess.kill();
|
||||
resolve({
|
||||
error: `Command timed out after ${effectiveTimeout}ms`,
|
||||
stderr,
|
||||
stdout,
|
||||
success: false,
|
||||
});
|
||||
}, effectiveTimeout);
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
if (!killed) {
|
||||
clearTimeout(timeoutHandle);
|
||||
const success = code === 0;
|
||||
logger.info(`${logPrefix} Command completed`, { code, success });
|
||||
resolve({
|
||||
exit_code: code || 0,
|
||||
output: stdout + stderr,
|
||||
stderr,
|
||||
stdout,
|
||||
success,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
logger.error(`${logPrefix} Command failed:`, error);
|
||||
resolve({
|
||||
error: error.message,
|
||||
stderr,
|
||||
stdout,
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to execute command:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('getCommandOutput')
|
||||
async handleGetCommandOutput({
|
||||
filter,
|
||||
shell_id,
|
||||
}: GetCommandOutputParams): Promise<GetCommandOutputResult> {
|
||||
const logPrefix = `[getCommandOutput: ${shell_id}]`;
|
||||
logger.debug(`${logPrefix} Retrieving output`);
|
||||
|
||||
const shellProcess = this.shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
logger.error(`${logPrefix} Shell process not found`);
|
||||
return {
|
||||
error: `Shell ID ${shell_id} not found`,
|
||||
output: '',
|
||||
running: false,
|
||||
stderr: '',
|
||||
stdout: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
|
||||
|
||||
// Get new output since last read
|
||||
const newStdout = stdout.slice(lastReadStdout).join('');
|
||||
const newStderr = stderr.slice(lastReadStderr).join('');
|
||||
let output = newStdout + newStderr;
|
||||
|
||||
// Apply filter if provided
|
||||
if (filter) {
|
||||
try {
|
||||
const regex = new RegExp(filter, 'gm');
|
||||
const lines = output.split('\n');
|
||||
output = lines.filter((line) => regex.test(line)).join('\n');
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Invalid filter regex:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last read positions separately
|
||||
shellProcess.lastReadStdout = stdout.length;
|
||||
shellProcess.lastReadStderr = stderr.length;
|
||||
|
||||
const running = childProcess.exitCode === null;
|
||||
|
||||
logger.debug(`${logPrefix} Output retrieved`, {
|
||||
outputLength: output.length,
|
||||
running,
|
||||
});
|
||||
|
||||
return {
|
||||
output,
|
||||
running,
|
||||
stderr: newStderr,
|
||||
stdout: newStdout,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@ipcClientEvent('killCommand')
|
||||
async handleKillCommand({ shell_id }: KillCommandParams): Promise<KillCommandResult> {
|
||||
const logPrefix = `[killCommand: ${shell_id}]`;
|
||||
logger.debug(`${logPrefix} Attempting to kill shell`);
|
||||
|
||||
const shellProcess = this.shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
logger.error(`${logPrefix} Shell process not found`);
|
||||
return {
|
||||
error: `Shell ID ${shell_id} not found`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
shellProcess.process.kill();
|
||||
this.shellProcesses.delete(shell_id);
|
||||
logger.info(`${logPrefix} Shell killed successfully`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to kill shell:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ControllerModule, ipcClientEvent } from '.';
|
||||
|
||||
export default class ShortcutController extends ControllerModule {
|
||||
/**
|
||||
* 获取所有快捷键配置
|
||||
* Get all shortcut configurations
|
||||
*/
|
||||
@ipcClientEvent('getShortcutsConfig')
|
||||
getShortcutsConfig() {
|
||||
@@ -12,7 +12,7 @@ export default class ShortcutController extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个快捷键配置
|
||||
* Update a single shortcut configuration
|
||||
*/
|
||||
@ipcClientEvent('updateShortcutConfig')
|
||||
updateShortcutConfig({
|
||||
|
||||
@@ -77,6 +77,7 @@ export default class SystemController extends ControllerModule {
|
||||
|
||||
// 更新i18n实例的语言
|
||||
await this.app.i18n.changeLanguage(locale === 'auto' ? app.getLocale() : locale);
|
||||
this.app.browserManager.broadcastToAllWindows('localeChanged', { locale });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -8,24 +8,24 @@ import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
// 创建日志记录器
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:TrayMenuCtr');
|
||||
|
||||
export default class TrayMenuCtr extends ControllerModule {
|
||||
async toggleMainWindow() {
|
||||
logger.debug('通过快捷键切换主窗口可见性');
|
||||
logger.debug('Toggle main window visibility via shortcut');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示托盘气泡通知
|
||||
* @param options 气泡选项
|
||||
* @returns 操作结果
|
||||
* Show tray balloon notification
|
||||
* @param options Balloon options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('showTrayNotification')
|
||||
async showNotification(options: ShowTrayNotificationParams) {
|
||||
logger.debug('显示托盘气泡通知');
|
||||
logger.debug('Show tray balloon notification');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const mainTray = this.app.trayManager.getMainTray();
|
||||
@@ -42,19 +42,19 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
return {
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘图标
|
||||
* @param options 图标选项
|
||||
* @returns 操作结果
|
||||
* Update tray icon
|
||||
* @param options Icon options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('updateTrayIcon')
|
||||
async updateTrayIcon(options: UpdateTrayIconParams) {
|
||||
logger.debug('更新托盘图标');
|
||||
logger.debug('Update tray icon');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const mainTray = this.app.trayManager.getMainTray();
|
||||
@@ -64,7 +64,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
mainTray.updateIcon(options.iconPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('更新托盘图标失败:', error);
|
||||
logger.error('Failed to update tray icon:', error);
|
||||
return {
|
||||
error: String(error),
|
||||
success: false,
|
||||
@@ -74,19 +74,19 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
return {
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘提示文本
|
||||
* @param options 提示文本选项
|
||||
* @returns 操作结果
|
||||
* Update tray tooltip text
|
||||
* @param options Tooltip text options
|
||||
* @returns Operation result
|
||||
*/
|
||||
@ipcClientEvent('updateTrayTooltip')
|
||||
async updateTrayTooltip(options: UpdateTrayTooltipParams) {
|
||||
logger.debug('更新托盘提示文本');
|
||||
logger.debug('Update tray tooltip text');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const mainTray = this.app.trayManager.getMainTray();
|
||||
@@ -98,7 +98,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
return {
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const logger = createLogger('controllers:UpdaterCtr');
|
||||
|
||||
export default class UpdaterCtr extends ControllerModule {
|
||||
/**
|
||||
* 检查更新
|
||||
* Check for updates
|
||||
*/
|
||||
@ipcClientEvent('checkUpdate')
|
||||
async checkForUpdates() {
|
||||
@@ -15,7 +15,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载更新
|
||||
* Download update
|
||||
*/
|
||||
@ipcClientEvent('downloadUpdate')
|
||||
async downloadUpdate() {
|
||||
@@ -24,7 +24,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭应用并安装更新
|
||||
* Quit application and install update
|
||||
*/
|
||||
@ipcClientEvent('installNow')
|
||||
quitAndInstallUpdate() {
|
||||
@@ -33,7 +33,7 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下次启动时安装更新
|
||||
* Install update on next startup
|
||||
*/
|
||||
@ipcClientEvent('installLater')
|
||||
installLater() {
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import AuthCtr from '../AuthCtr';
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
||||
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
linux: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock OFFICIAL_CLOUD_SERVER
|
||||
vi.mock('@/const/env', () => ({
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobehub-cloud.com',
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
// Mock crypto
|
||||
let randomBytesCounter = 0;
|
||||
vi.mock('node:crypto', () => ({
|
||||
default: {
|
||||
randomBytes: vi.fn((size: number) => {
|
||||
randomBytesCounter++;
|
||||
return {
|
||||
toString: vi.fn(() => `mock-random-${randomBytesCounter}`),
|
||||
};
|
||||
}),
|
||||
subtle: {
|
||||
digest: vi.fn(() => Promise.resolve(new ArrayBuffer(32))),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Create mock App and RemoteServerConfigCtr
|
||||
const mockRemoteServerConfigCtr = {
|
||||
clearTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }),
|
||||
getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => {
|
||||
if (config?.storageMode === 'selfHost') {
|
||||
return config.remoteServerUrl || 'https://mock-server.com';
|
||||
}
|
||||
return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER
|
||||
}),
|
||||
getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000),
|
||||
isTokenExpiringSoon: vi.fn().mockReturnValue(false),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||
setRemoteServerConfig: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as RemoteServerConfigCtr;
|
||||
|
||||
const mockApp = {
|
||||
getController: vi.fn((ControllerClass) => {
|
||||
if (ControllerClass === RemoteServerConfigCtr) {
|
||||
return mockRemoteServerConfigCtr;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as unknown as App;
|
||||
|
||||
describe('AuthCtr', () => {
|
||||
let authCtr: AuthCtr;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
let mockWindow: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
randomBytesCounter = 0; // Reset counter for each test
|
||||
|
||||
// Reset shell.openExternal to default successful behavior
|
||||
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
|
||||
|
||||
// Create fresh instance for each test
|
||||
authCtr = new AuthCtr(mockApp);
|
||||
|
||||
// Mock global fetch
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Mock BrowserWindow with send spy
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([mockWindow]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up authCtr intervals (using real timers, not fake timers)
|
||||
authCtr.cleanup();
|
||||
// Clean up any fake timers if used
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
// Use real timers for all tests since setInterval with async doesn't work well with fake timers
|
||||
|
||||
describe('requestAuthorization', () => {
|
||||
it('should generate PKCE parameters and open authorization URL', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
const result = await authCtr.requestAuthorization(config);
|
||||
|
||||
// Verify success response
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
// Verify shell.openExternal was called with correct URL
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://lobehub-cloud.com/oidc/auth'),
|
||||
);
|
||||
|
||||
// Verify URL contains required parameters
|
||||
const authUrl = vi.mocked(shell.openExternal).mock.calls[0][0];
|
||||
expect(authUrl).toContain('client_id=lobehub-desktop');
|
||||
expect(authUrl).toContain('response_type=code');
|
||||
expect(authUrl).toContain('code_challenge_method=S256');
|
||||
expect(authUrl).toContain('scope=profile%20email%20offline_access');
|
||||
});
|
||||
|
||||
it('should start polling after authorization request', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
const result = await authCtr.requestAuthorization(config);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Wait a bit for polling to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
// Verify fetch was called for polling
|
||||
const pollingCalls = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
);
|
||||
expect(pollingCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use self-hosted server URL when storageMode is selfHost', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'selfHost',
|
||||
remoteServerUrl: 'https://my-custom-server.com',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Verify shell.openExternal was called with custom URL
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://my-custom-server.com/oidc/auth'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authorization request error gracefully', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
vi.mocked(shell.openExternal).mockRejectedValue(new Error('Failed to open browser'));
|
||||
|
||||
const result = await authCtr.requestAuthorization(config);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Failed to open browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('polling mechanism', () => {
|
||||
it('should poll every 3 seconds', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for first poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 3100));
|
||||
|
||||
const firstCallCount = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(firstCallCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Wait for second poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const secondCallCount = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(secondCallCount).toBeGreaterThanOrEqual(2);
|
||||
}, 10000);
|
||||
|
||||
it('should stop polling when credentials are received', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
let pollCount = 0;
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Return success on third poll
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
pollCount++;
|
||||
if (pollCount >= 3) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'mock-random-2', // Second randomBytes call is for state
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Token exchange endpoint
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
const pollCountBefore = pollCount;
|
||||
|
||||
// Wait more time and verify no more polling
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
expect(pollCount).toBe(pollCountBefore);
|
||||
}, 15000);
|
||||
|
||||
it('should broadcast authorizationSuccessful when credentials are exchanged', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'mock-random-2', // Second randomBytes call is for state
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling to complete and token exchange
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify authorizationSuccessful was broadcast
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationSuccessful');
|
||||
}, 6000);
|
||||
|
||||
it('should validate state parameter and reject mismatched state', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'wrong-state', // Mismatched state
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling and state validation
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify authorizationFailed was broadcast with state error
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationFailed', {
|
||||
error: 'Invalid state parameter',
|
||||
});
|
||||
}, 6000);
|
||||
});
|
||||
|
||||
describe('token refresh', () => {
|
||||
it('should start auto-refresh after successful authorization', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'mock-random-2', // Second randomBytes call is for state
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling and token exchange
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify saveTokens was called
|
||||
expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalledWith(
|
||||
'new-access-token',
|
||||
'new-refresh-token',
|
||||
3600,
|
||||
);
|
||||
|
||||
// Verify remote server was set to active
|
||||
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
});
|
||||
}, 6000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario: Authorization Timeout and Retry', () => {
|
||||
// All scenario tests use real timers
|
||||
|
||||
it('Step 1: User requests authorization but does not complete it within 5 minutes', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
// Mock: User never completes authorization, so polling always returns 404
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
// User clicks "Connect to Cloud" button
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for some polling to happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
const handoffCallsBeforeTimeout = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(handoffCallsBeforeTimeout).toBeGreaterThan(0);
|
||||
|
||||
// Verify polling is active by checking calls increased
|
||||
const callsBefore = handoffCallsBeforeTimeout;
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
const callsAfter = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||
}, 15000); // Increase test timeout
|
||||
|
||||
it('Step 2: User clicks retry button after previous attempt', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
// First attempt
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
// Reset mock to track retry
|
||||
mockFetch.mockClear();
|
||||
|
||||
// User clicks retry button - should start fresh authorization
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Verify: New polling started
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
const handoffCalls = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
);
|
||||
expect(handoffCalls.length).toBeGreaterThan(0);
|
||||
}, 10000);
|
||||
|
||||
it('Step 3: Retry generates new state parameter (not reusing old state)', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
const capturedStates: string[] = [];
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
const stateParam = urlObj.searchParams.get('id');
|
||||
if (stateParam && !capturedStates.includes(stateParam)) {
|
||||
capturedStates.push(stateParam);
|
||||
}
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
// First authorization attempt
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
const firstState = capturedStates[0];
|
||||
|
||||
// Clear for second attempt tracking
|
||||
const firstAttemptStates = [...capturedStates];
|
||||
capturedStates.length = 0;
|
||||
|
||||
// Retry - should generate NEW state
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
const secondState = capturedStates[0];
|
||||
|
||||
// CRITICAL: States must be different
|
||||
expect(firstState).toBeDefined();
|
||||
expect(secondState).toBeDefined();
|
||||
expect(secondState).not.toBe(firstState);
|
||||
expect(firstAttemptStates).not.toContain(secondState);
|
||||
}, 10000);
|
||||
|
||||
it('Step 4: User completes authorization on retry successfully', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
// First attempt - incomplete
|
||||
mockFetch.mockResolvedValue({ status: 404, ok: false });
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
// Second attempt - user completes it this time
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Handoff returns credentials immediately
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'authorization-code',
|
||||
state: 'mock-random-4', // Matches second request's state (3rd and 4th randomBytes calls)
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
|
||||
// Token exchange succeeds
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait longer for polling and token exchange
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify: Success message shown
|
||||
const successCall = mockWindow.webContents.send.mock.calls.find(
|
||||
(call: any[]) => call[0] === 'authorizationSuccessful',
|
||||
);
|
||||
expect(successCall).toBeDefined();
|
||||
|
||||
// Verify: Tokens saved
|
||||
expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalled();
|
||||
}, 12000);
|
||||
|
||||
it('Edge case: Rapid retry clicks should not create multiple polling intervals', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({ status: 404, ok: false });
|
||||
|
||||
// User rapidly clicks retry multiple times
|
||||
await authCtr.requestAuthorization(config);
|
||||
await authCtr.requestAuthorization(config);
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for some polling to happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 9000));
|
||||
|
||||
// Count handoff requests
|
||||
const handoffCalls = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
);
|
||||
|
||||
// Should have ~3 calls (one per 3-second interval), not ~9 (3 intervals running)
|
||||
// Allow some tolerance for timing
|
||||
expect(handoffCalls.length).toBeLessThanOrEqual(5);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('BrowserWindowsCtr', () => {
|
||||
it('should show the settings window with the specified tab', async () => {
|
||||
const tab = 'appearance';
|
||||
const result = await browserWindowsCtr.openSettingsWindow(tab);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(tab);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({ tab });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -120,11 +120,11 @@ describe('BrowserWindowsCtr', () => {
|
||||
it('should show settings window if matched route target is settings', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/settings?active=common',
|
||||
url: 'app://host/settings?active=common',
|
||||
path: '/settings/provider',
|
||||
url: 'app://host/settings/provider?active=provider&provider=ollama',
|
||||
};
|
||||
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
|
||||
const subPath = 'common';
|
||||
const subPath = 'provider';
|
||||
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
|
||||
(extractSubPath as Mock).mockReturnValue(subPath);
|
||||
|
||||
@@ -132,7 +132,10 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(params.path);
|
||||
expect(extractSubPath).toHaveBeenCalledWith(params.path, matchedRoute.pathPrefix);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(subPath);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
|
||||
searchParams: { active: 'provider', provider: 'ollama' },
|
||||
tab: subPath,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
intercepted: true,
|
||||
path: params.path,
|
||||
@@ -170,11 +173,11 @@ describe('BrowserWindowsCtr', () => {
|
||||
it('should return error if processing route interception fails for settings', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/settings?active=general',
|
||||
path: '/settings',
|
||||
url: 'app://host/settings?active=general',
|
||||
};
|
||||
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
|
||||
const subPath = 'general';
|
||||
const subPath = undefined;
|
||||
const errorMessage = 'Processing error for settings';
|
||||
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
|
||||
(extractSubPath as Mock).mockReturnValue(subPath);
|
||||
@@ -182,6 +185,10 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
const result = await browserWindowsCtr.interceptRoute(params);
|
||||
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
|
||||
searchParams: { active: 'general' },
|
||||
tab: subPath,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
error: errorMessage,
|
||||
intercepted: false,
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock file-loaders
|
||||
vi.mock('@lobechat/file-loaders', () => ({
|
||||
SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'],
|
||||
loadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fast-glob
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises and node:fs
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
Stats: class Stats {},
|
||||
constants: {
|
||||
F_OK: 0,
|
||||
},
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock FileSearchService
|
||||
const mockSearchService = {
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock makeSureDirExist
|
||||
vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockSearchService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('LocalFileCtr', () => {
|
||||
let localFileCtr: LocalFileCtr;
|
||||
let mockShell: any;
|
||||
let mockFg: any;
|
||||
let mockLoadFile: any;
|
||||
let mockFsPromises: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocks
|
||||
mockShell = (await import('electron')).shell;
|
||||
mockFg = (await import('fast-glob')).default;
|
||||
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
|
||||
mockFsPromises = await import('node:fs/promises');
|
||||
|
||||
localFileCtr = new LocalFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFile', () => {
|
||||
it('should open file successfully', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/file.txt');
|
||||
});
|
||||
|
||||
it('should return error when opening file fails', async () => {
|
||||
const error = new Error('Failed to open');
|
||||
vi.mocked(mockShell.openPath).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFolder', () => {
|
||||
it('should open directory when isDirectory is true', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '/test/folder',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should open parent directory when isDirectory is false', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '/test/folder/file.txt',
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should return error when opening folder fails', async () => {
|
||||
const error = new Error('Failed to open folder');
|
||||
vi.mocked(mockShell.openPath).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '/test/folder',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open folder' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFile', () => {
|
||||
it('should read file successfully with default location', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.fileType).toBe('txt');
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
expect(result.content).toBe(mockFileContent);
|
||||
});
|
||||
|
||||
it('should read file with custom location range', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt', loc: [1, 3] });
|
||||
|
||||
expect(result.content).toBe('line2\nline3');
|
||||
expect(result.lineCount).toBe(2);
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/missing.txt' });
|
||||
|
||||
expect(result.content).toContain('Error accessing or processing file');
|
||||
expect(result.lineCount).toBe(0);
|
||||
expect(result.charCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFiles', () => {
|
||||
it('should read multiple files successfully', async () => {
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: 'file content',
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFiles({
|
||||
paths: ['/test/file1.txt', '/test/file2.txt'],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockLoadFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
it('should write file successfully', async () => {
|
||||
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '/test/file.txt',
|
||||
content: 'test content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error when path is empty', async () => {
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '',
|
||||
content: 'test content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Path cannot be empty' });
|
||||
});
|
||||
|
||||
it('should return error when content is undefined', async () => {
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '/test/file.txt',
|
||||
content: undefined as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Content cannot be empty' });
|
||||
});
|
||||
|
||||
it('should handle write error', async () => {
|
||||
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(mockFsPromises.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '/test/file.txt',
|
||||
content: 'test content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to write file: Write failed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRenameFile', () => {
|
||||
it('should rename file successfully', async () => {
|
||||
vi.mocked(mockFsPromises.rename).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, newPath: '/test/new.txt' });
|
||||
expect(mockFsPromises.rename).toHaveBeenCalledWith('/test/old.txt', '/test/new.txt');
|
||||
});
|
||||
|
||||
it('should skip rename when paths are identical', async () => {
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/file.txt',
|
||||
newName: 'file.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, newPath: '/test/file.txt' });
|
||||
expect(mockFsPromises.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid new name with path separators', async () => {
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: '../new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid new name');
|
||||
});
|
||||
|
||||
it('should reject invalid new name with special characters', async () => {
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new:file.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid new name');
|
||||
});
|
||||
|
||||
it('should handle file not found error', async () => {
|
||||
const error: any = new Error('File not found');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('File or directory not found');
|
||||
});
|
||||
|
||||
it('should handle file already exists error', async () => {
|
||||
const error: any = new Error('File exists');
|
||||
error.code = 'EEXIST';
|
||||
vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLocalFilesSearch', () => {
|
||||
it('should search files successfully', async () => {
|
||||
const mockResults = [
|
||||
{
|
||||
name: 'test.txt',
|
||||
path: '/test/test.txt',
|
||||
isDirectory: false,
|
||||
size: 100,
|
||||
type: 'txt',
|
||||
},
|
||||
];
|
||||
mockSearchService.search.mockResolvedValue(mockResults);
|
||||
|
||||
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
||||
|
||||
expect(result).toEqual(mockResults);
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('test', {
|
||||
keywords: 'test',
|
||||
limit: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
||||
|
||||
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockFiles = [
|
||||
{ path: '/test/file1.txt', stats: { mtime: new Date('2024-01-02') } },
|
||||
{ path: '/test/file2.txt', stats: { mtime: new Date('2024-01-01') } },
|
||||
];
|
||||
vi.mocked(mockFg).mockResolvedValue(mockFiles);
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.files).toEqual(['/test/file1.txt', '/test/file2.txt']);
|
||||
expect(result.total_files).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle glob error', async () => {
|
||||
vi.mocked(mockFg).mockRejectedValue(new Error('Glob failed'));
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
files: [],
|
||||
total_files: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import ShellCommandCtr from '../ShellCommandCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock crypto
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'test-uuid-123'),
|
||||
}));
|
||||
|
||||
const mockApp = {} as unknown as App;
|
||||
|
||||
describe('ShellCommandCtr', () => {
|
||||
let shellCommandCtr: ShellCommandCtr;
|
||||
let mockSpawn: any;
|
||||
let mockChildProcess: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocks
|
||||
const childProcessModule = await import('node:child_process');
|
||||
mockSpawn = vi.mocked(childProcessModule.spawn);
|
||||
|
||||
// Create mock child process
|
||||
mockChildProcess = {
|
||||
stdout: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
mockSpawn.mockReturnValue(mockChildProcess);
|
||||
|
||||
shellCommandCtr = new ShellCommandCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('handleRunCommand', () => {
|
||||
describe('synchronous mode', () => {
|
||||
it('should execute command successfully', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
let stdoutCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
// Simulate successful exit
|
||||
setTimeout(() => exitCallback(0), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stdoutCallback = callback;
|
||||
// Simulate output
|
||||
setTimeout(() => stdoutCallback(Buffer.from('test output\n')), 5);
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'echo "test"',
|
||||
description: 'test command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toBe('test output\n');
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle command timeout', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'sleep 10',
|
||||
description: 'long running command',
|
||||
timeout: 100,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
expect(mockChildProcess.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle command execution error', async () => {
|
||||
let errorCallback: (error: Error) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'error') {
|
||||
errorCallback = callback;
|
||||
setTimeout(() => errorCallback(new Error('Command not found')), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'invalid-command',
|
||||
description: 'invalid command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Command not found');
|
||||
});
|
||||
|
||||
it('should handle non-zero exit code', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(1), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'exit 1',
|
||||
description: 'failing command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exit_code).toBe(1);
|
||||
});
|
||||
|
||||
it('should capture stderr output', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
let stderrCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(1), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stderrCallback = callback;
|
||||
setTimeout(() => stderrCallback(Buffer.from('error message\n')), 5);
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'command-with-error',
|
||||
description: 'command with stderr',
|
||||
});
|
||||
|
||||
expect(result.stderr).toBe('error message\n');
|
||||
});
|
||||
|
||||
it('should enforce timeout limits', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
// Test minimum timeout
|
||||
const minResult = await shellCommandCtr.handleRunCommand({
|
||||
command: 'sleep 5',
|
||||
timeout: 500, // Below 1000ms minimum
|
||||
});
|
||||
|
||||
expect(minResult.success).toBe(false);
|
||||
expect(minResult.error).toContain('1000ms'); // Should use 1000ms minimum
|
||||
});
|
||||
});
|
||||
|
||||
describe('background mode', () => {
|
||||
it('should start command in background', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'long-running-task',
|
||||
description: 'background task',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.shell_id).toBe('test-uuid-123');
|
||||
});
|
||||
|
||||
it('should use correct shell on Windows', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'dir',
|
||||
description: 'windows command',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('cmd.exe', ['/c', 'dir'], expect.any(Object));
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('should use correct shell on Unix', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'ls',
|
||||
description: 'unix command',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('/bin/sh', ['-c', 'ls'], expect.any(Object));
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGetCommandOutput', () => {
|
||||
beforeEach(async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
// Simulate some output
|
||||
setTimeout(() => callback(Buffer.from('line 1\n')), 5);
|
||||
setTimeout(() => callback(Buffer.from('line 2\n')), 10);
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
setTimeout(() => callback(Buffer.from('error line\n')), 7);
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
|
||||
// Start a background process first
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'test-command',
|
||||
run_in_background: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should retrieve command output', async () => {
|
||||
// Wait for output to be captured
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('line 1');
|
||||
expect(result.stderr).toContain('error line');
|
||||
});
|
||||
|
||||
it('should return error for non-existent shell_id', async () => {
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'non-existent-id',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should filter output with regex', async () => {
|
||||
// Wait for output to be captured
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
filter: 'line 1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('line 1');
|
||||
expect(result.output).not.toContain('line 2');
|
||||
});
|
||||
|
||||
it('should only return new output since last read', async () => {
|
||||
// Wait for initial output
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
// First read
|
||||
const firstResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(firstResult.stdout).toContain('line 1');
|
||||
|
||||
// Second read should return empty (no new output)
|
||||
const secondResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(secondResult.stdout).toBe('');
|
||||
expect(secondResult.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('should handle invalid regex filter gracefully', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
filter: '[invalid(regex',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should return unfiltered output when filter is invalid
|
||||
});
|
||||
|
||||
it('should report running status correctly', async () => {
|
||||
mockChildProcess.exitCode = null;
|
||||
|
||||
const runningResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(runningResult.running).toBe(true);
|
||||
|
||||
// Simulate process exit
|
||||
mockChildProcess.exitCode = 0;
|
||||
|
||||
const exitedResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(exitedResult.running).toBe(false);
|
||||
});
|
||||
|
||||
it('should track stdout and stderr offsets separately when streaming output', async () => {
|
||||
// Create a new background process with manual control over stdout/stderr
|
||||
let stdoutCallback: (data: Buffer) => void;
|
||||
let stderrCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stdoutCallback = callback;
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stderrCallback = callback;
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
|
||||
// Start a new background process
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'test-interleaved',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Simulate stderr output first
|
||||
stderrCallback(Buffer.from('error 1\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// First read - should get stderr
|
||||
const firstRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(firstRead.stderr).toBe('error 1\n');
|
||||
expect(firstRead.stdout).toBe('');
|
||||
|
||||
// Simulate stdout output after stderr
|
||||
stdoutCallback(Buffer.from('output 1\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// Second read - should get stdout without losing data
|
||||
const secondRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(secondRead.stdout).toBe('output 1\n');
|
||||
expect(secondRead.stderr).toBe('');
|
||||
|
||||
// Simulate more stderr
|
||||
stderrCallback(Buffer.from('error 2\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// Third read - should get new stderr
|
||||
const thirdRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(thirdRead.stderr).toBe('error 2\n');
|
||||
expect(thirdRead.stdout).toBe('');
|
||||
|
||||
// Simulate more stdout
|
||||
stdoutCallback(Buffer.from('output 2\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// Fourth read - should get new stdout
|
||||
const fourthRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(fourthRead.stdout).toBe('output 2\n');
|
||||
expect(fourthRead.stderr).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleKillCommand', () => {
|
||||
beforeEach(async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
// Start a background process
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'test-command',
|
||||
run_in_background: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should kill command successfully', async () => {
|
||||
const result = await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockChildProcess.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent shell_id', async () => {
|
||||
const result = await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'non-existent-id',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should remove process from map after killing', async () => {
|
||||
await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
// Try to get output from killed process
|
||||
const outputResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(outputResult.success).toBe(false);
|
||||
expect(outputResult.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle kill error gracefully', async () => {
|
||||
mockChildProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
const result = await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Kill failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ describe('TrayMenuCtr', () => {
|
||||
expect(mockGetMainTray).not.toHaveBeenCalled();
|
||||
expect(mockDisplayBalloon).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('TrayMenuCtr', () => {
|
||||
expect(mockGetMainTray).toHaveBeenCalled();
|
||||
expect(mockDisplayBalloon).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
success: false
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe('TrayMenuCtr', () => {
|
||||
const result = await trayMenuCtr.updateTrayIcon(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,7 @@ describe('TrayMenuCtr', () => {
|
||||
const result = await trayMenuCtr.updateTrayTooltip(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -248,7 +248,7 @@ describe('TrayMenuCtr', () => {
|
||||
|
||||
expect(mockUpdateTooltip).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,13 +19,13 @@ const ipcDecorator =
|
||||
};
|
||||
|
||||
/**
|
||||
* controller 用的 ipc client event 装饰器
|
||||
* IPC client event decorator for controllers
|
||||
*/
|
||||
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
|
||||
ipcDecorator(method, 'client');
|
||||
|
||||
/**
|
||||
* controller 用的 ipc server event 装饰器
|
||||
* IPC server event decorator for controllers
|
||||
*/
|
||||
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
|
||||
ipcDecorator(method, 'server');
|
||||
@@ -56,8 +56,8 @@ const protocolDecorator =
|
||||
|
||||
/**
|
||||
* Protocol handler decorator
|
||||
* @param urlType 协议URL类型 (如: 'plugin')
|
||||
* @param action 操作类型 (如: 'install')
|
||||
* @param urlType Protocol URL type (e.g., 'plugin')
|
||||
* @param action Action type (e.g., 'install')
|
||||
*/
|
||||
export const createProtocolHandler = (urlType: string) => (action: string) =>
|
||||
protocolDecorator(urlType, action);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { Session, app, ipcMain, protocol } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { buildDir, nextStandaloneDir } from '@/const/dir';
|
||||
import { buildDir, LOCAL_DATABASE_DIR, nextStandaloneDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { IServiceModule } from '@/services';
|
||||
@@ -129,6 +130,9 @@ 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');
|
||||
@@ -371,6 +375,27 @@ export class App {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up stale database lock file from previous crashes or abnormal exits
|
||||
*/
|
||||
private cleanupDatabaseLock = async () => {
|
||||
try {
|
||||
const dbPath = join(this.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
const lockPath = `${dbPath}.lock`;
|
||||
|
||||
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');
|
||||
}
|
||||
} 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({
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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';
|
||||
|
||||
// Mock electron modules
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: vi.fn(() => '/mock/app/path'),
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn(() => '/mock/user/path'),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
on: vi.fn(),
|
||||
commandLine: {
|
||||
appendSwitch: vi.fn(),
|
||||
},
|
||||
dock: {
|
||||
setIcon: vi.fn(),
|
||||
},
|
||||
exit: vi.fn(),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
},
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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(),
|
||||
extractSubPath: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('fix-path', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
nextStandaloneDir: '/mock/standalone',
|
||||
LOCAL_DATABASE_DIR: 'lobehub-local-db',
|
||||
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',
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/electron-server-ipc', () => ({
|
||||
ElectronIPCServer: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock all infrastructure managers
|
||||
vi.mock('../infrastructure/I18nManager', () => ({
|
||||
I18nManager: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/StoreManager', () => ({
|
||||
StoreManager: vi.fn().mockImplementation(() => ({
|
||||
get: vi.fn((key) => {
|
||||
if (key === 'storagePath') return '/mock/storage/path';
|
||||
return undefined;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/StaticFileServerManager', () => ({
|
||||
StaticFileServerManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/UpdaterManager', () => ({
|
||||
UpdaterManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/ProtocolManager', () => ({
|
||||
ProtocolManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
processPendingUrls: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../browser/BrowserManager', () => ({
|
||||
BrowserManager: vi.fn().mockImplementation(() => ({
|
||||
initializeBrowsers: vi.fn(),
|
||||
getIdentifierByWebContents: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/MenuManager', () => ({
|
||||
MenuManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/ShortcutManager', () => ({
|
||||
ShortcutManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/TrayManager', () => ({
|
||||
TrayManager: vi.fn().mockImplementation(() => ({
|
||||
initializeTrays: vi.fn(),
|
||||
destroyAll: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
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', () => {
|
||||
let appInstance: App;
|
||||
let mockLockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock glob imports to return empty arrays
|
||||
(import.meta as any).glob = vi.fn(() => ({}));
|
||||
|
||||
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bootstrap - database lock cleanup', () => {
|
||||
it('should remove stale lock file if it exists during bootstrap', async () => {
|
||||
// Setup: simulate existing lock file
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockResolvedValue(undefined);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap which should trigger cleanup
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: lock file was removed
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should not attempt to remove lock file if it does not exist', async () => {
|
||||
// Setup: no lock file exists
|
||||
vi.mocked(pathExistsSync).mockReturnValue(false);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: remove was NOT called since file doesn't exist
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue bootstrap even if lock cleanup fails', async () => {
|
||||
// Setup: simulate lock file exists but cleanup fails
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Bootstrap should not throw even if cleanup fails
|
||||
await expect(appInstance.bootstrap()).resolves.not.toThrow();
|
||||
|
||||
// Verify: cleanup was attempted
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should clean up lock file before starting IPC server', async () => {
|
||||
// Setup
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(remove).mockImplementation(async () => {
|
||||
callOrder.push('remove');
|
||||
});
|
||||
|
||||
// Mock IPC server start to track call order
|
||||
const { ElectronIPCServer } = await import('@lobechat/electron-server-ipc');
|
||||
const mockStart = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('ipcServer.start');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(ElectronIPCServer).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
start: mockStart,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Create app instance and bootstrap
|
||||
appInstance = new App();
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: cleanup happens before IPC server starts
|
||||
expect(callOrder).toEqual(['remove', 'ipcServer.start']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appStoragePath', () => {
|
||||
it('should return storage path from store manager', () => {
|
||||
appInstance = new App();
|
||||
|
||||
const storagePath = appInstance.appStoragePath;
|
||||
|
||||
expect(storagePath).toBe('/mock/storage/path');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -358,6 +358,9 @@ export default class Browser {
|
||||
session: browserWindow.webContents.session,
|
||||
});
|
||||
|
||||
// Setup CORS bypass for local file server
|
||||
this.setupCORSBypass(browserWindow);
|
||||
|
||||
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
|
||||
this.loadPlaceholder().then(() => {
|
||||
this.loadUrl(path).catch((e) => {
|
||||
@@ -491,4 +494,37 @@ export default class Browser {
|
||||
logger.debug(`[${this.identifier}] Manually reapplying visual effects via Browser.`);
|
||||
this.applyVisualEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup CORS bypass for local file server (127.0.0.1:*)
|
||||
* This is needed for Electron to access files from the local static file server
|
||||
*/
|
||||
private setupCORSBypass(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] Setting up CORS bypass for local file server`);
|
||||
|
||||
const session = browserWindow.webContents.session;
|
||||
|
||||
// Intercept response headers to add CORS headers
|
||||
session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const url = details.url;
|
||||
|
||||
// Only modify headers for local file server requests (127.0.0.1)
|
||||
if (url.includes('127.0.0.1') || url.includes('lobe-desktop-file')) {
|
||||
const responseHeaders = details.responseHeaders || {};
|
||||
|
||||
// Add CORS headers
|
||||
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
|
||||
responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, PUT, DELETE, OPTIONS'];
|
||||
responseHeaders['Access-Control-Allow-Headers'] = ['*'];
|
||||
|
||||
callback({
|
||||
responseHeaders,
|
||||
});
|
||||
} else {
|
||||
callback({ responseHeaders: details.responseHeaders });
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`[${this.identifier}] CORS bypass setup completed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
MainBroadcastEventKey,
|
||||
MainBroadcastParams,
|
||||
OpenSettingsWindowOptions,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { WebContents } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { AppBrowsersIdentifiers, appBrowsers, WindowTemplate, WindowTemplateIdentifiers, windowTemplates } from '../../appBrowsers';
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
appBrowsers,
|
||||
windowTemplates,
|
||||
} from '../../appBrowsers';
|
||||
import type { App } from '../App';
|
||||
import type { BrowserWindowOpts } from './Browser';
|
||||
import Browser from './Browser';
|
||||
@@ -63,14 +72,35 @@ export class BrowserManager {
|
||||
* Display the settings window and navigate to a specific tab
|
||||
* @param tab Settings window sub-path tab
|
||||
*/
|
||||
async showSettingsWindowWithTab(tab?: string) {
|
||||
logger.debug(`Showing settings window with tab: ${tab || 'default'}`);
|
||||
// common is the main path for settings route
|
||||
if (tab && tab !== 'common') {
|
||||
const browser = await this.redirectToPage('settings', tab);
|
||||
async showSettingsWindowWithTab(options?: OpenSettingsWindowOptions) {
|
||||
const tab = options?.tab;
|
||||
const searchParams = options?.searchParams;
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (tab && tab !== 'common' && !query.has('active')) {
|
||||
query.set('active', tab);
|
||||
}
|
||||
|
||||
const queryString = query.toString();
|
||||
const activeTab = query.get('active') ?? tab;
|
||||
|
||||
logger.debug(
|
||||
`Showing settings window with navigation: active=${activeTab || 'default'}, query=${
|
||||
queryString || 'none'
|
||||
}`,
|
||||
);
|
||||
|
||||
if (queryString) {
|
||||
const browser = await this.redirectToPage('settings', undefined, queryString);
|
||||
|
||||
// make provider page more large
|
||||
if (tab.startsWith('provider/')) {
|
||||
if (activeTab?.startsWith('provider')) {
|
||||
logger.debug('Resizing window for provider settings');
|
||||
browser.setWindowSize({ height: 1000, width: 1400 });
|
||||
browser.moveToCenter();
|
||||
@@ -87,7 +117,7 @@ export class BrowserManager {
|
||||
* @param identifier Window identifier
|
||||
* @param subPath Sub-path, such as 'agent', 'about', etc.
|
||||
*/
|
||||
async redirectToPage(identifier: string, subPath?: string) {
|
||||
async redirectToPage(identifier: string, subPath?: string, search?: string) {
|
||||
try {
|
||||
// Ensure window is retrieved or created
|
||||
const browser = this.retrieveByIdentifier(identifier);
|
||||
@@ -105,11 +135,14 @@ export class BrowserManager {
|
||||
|
||||
// Build complete URL path
|
||||
const fullPath = subPath ? `${baseRoute}/${subPath}` : baseRoute;
|
||||
const normalizedSearch =
|
||||
search && search.length > 0 ? (search.startsWith('?') ? search : `?${search}`) : '';
|
||||
const fullUrl = `${fullPath}${normalizedSearch}`;
|
||||
|
||||
logger.debug(`Redirecting to: ${fullPath}`);
|
||||
logger.debug(`Redirecting to: ${fullUrl}`);
|
||||
|
||||
// Load URL and show window
|
||||
await browser.loadUrl(fullPath);
|
||||
await browser.loadUrl(fullUrl);
|
||||
browser.show();
|
||||
|
||||
return browser;
|
||||
@@ -143,14 +176,20 @@ export class BrowserManager {
|
||||
* @param uniqueId Optional unique identifier, will be generated if not provided
|
||||
* @returns The window identifier and Browser instance
|
||||
*/
|
||||
createMultiInstanceWindow(templateId: WindowTemplateIdentifiers, path: string, uniqueId?: string) {
|
||||
createMultiInstanceWindow(
|
||||
templateId: WindowTemplateIdentifiers,
|
||||
path: string,
|
||||
uniqueId?: string,
|
||||
) {
|
||||
const template = windowTemplates[templateId];
|
||||
if (!template) {
|
||||
throw new Error(`Window template ${templateId} not found`);
|
||||
}
|
||||
|
||||
// Generate unique identifier
|
||||
const windowId = uniqueId || `${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const windowId =
|
||||
uniqueId ||
|
||||
`${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
// Create browser options from template
|
||||
const browserOpts: BrowserWindowOpts = {
|
||||
@@ -164,8 +203,8 @@ export class BrowserManager {
|
||||
const browser = this.retrieveOrInitialize(browserOpts);
|
||||
|
||||
return {
|
||||
identifier: windowId,
|
||||
browser: browser,
|
||||
identifier: windowId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +215,7 @@ export class BrowserManager {
|
||||
*/
|
||||
getWindowsByTemplate(templateId: string): string[] {
|
||||
const prefix = `${templateId}_`;
|
||||
return Array.from(this.browsers.keys()).filter(id => id.startsWith(prefix));
|
||||
return Array.from(this.browsers.keys()).filter((id) => id.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +224,7 @@ export class BrowserManager {
|
||||
*/
|
||||
closeWindowsByTemplate(templateId: string): void {
|
||||
const windowIds = this.getWindowsByTemplate(templateId);
|
||||
windowIds.forEach(id => {
|
||||
windowIds.forEach((id) => {
|
||||
const browser = this.browsers.get(id);
|
||||
if (browser) {
|
||||
browser.close();
|
||||
@@ -235,8 +274,7 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
browser.browserWindow.on('show', () => {
|
||||
if (browser.webContents)
|
||||
this.webContentsMap.set(browser.webContents, browser.identifier);
|
||||
if (browser.webContents) this.webContentsMap.set(browser.webContents, browser.identifier);
|
||||
});
|
||||
|
||||
return browser;
|
||||
|
||||
@@ -50,7 +50,9 @@ export class ProtocolManager {
|
||||
|
||||
// Check if already registered
|
||||
const isCurrentlyRegistered = app.isDefaultProtocolClient(this.protocolScheme);
|
||||
logger.debug(`🔗 [Protocol] Is currently default protocol client: ${isCurrentlyRegistered}`);
|
||||
logger.debug(
|
||||
`🔗 [Protocol] ${this.protocolScheme}:// is currently registered: ${isCurrentlyRegistered}`,
|
||||
);
|
||||
|
||||
// Register as default protocol client
|
||||
let registrationResult: boolean;
|
||||
@@ -71,7 +73,9 @@ export class ProtocolManager {
|
||||
registrationResult = app.setAsDefaultProtocolClient(this.protocolScheme);
|
||||
}
|
||||
|
||||
logger.debug(`🔗 [Protocol] Registration result: ${registrationResult}`);
|
||||
logger.debug(
|
||||
`🔗 [Protocol] Registration result for ${this.protocolScheme}://: ${registrationResult}`,
|
||||
);
|
||||
|
||||
if (!registrationResult) {
|
||||
logger.error(
|
||||
@@ -83,7 +87,9 @@ export class ProtocolManager {
|
||||
|
||||
// Verify registration
|
||||
const isRegisteredAfter = app.isDefaultProtocolClient(this.protocolScheme);
|
||||
logger.debug(`🔗 [Protocol] Final registration status: ${isRegisteredAfter}`);
|
||||
logger.debug(
|
||||
`🔗 [Protocol] Final registration status for ${this.protocolScheme}://: ${isRegisteredAfter}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +129,6 @@ export class ProtocolManager {
|
||||
*/
|
||||
private getProtocolUrlFromArgs(args: string[]): string | null {
|
||||
const protocolPrefix = `${this.protocolScheme}://`;
|
||||
|
||||
logger.debug(`🔗 [Protocol] Searching for protocol URLs in args: ${JSON.stringify(args)}`);
|
||||
logger.debug(`🔗 [Protocol] Looking for prefix: ${protocolPrefix}`);
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@ import type { App } from '../App';
|
||||
|
||||
const logger = createLogger('core:StaticFileServerManager');
|
||||
|
||||
const getAllowedOrigin = (rawOrigin?: string) => {
|
||||
if (!rawOrigin) return '*';
|
||||
|
||||
try {
|
||||
const url = new URL(rawOrigin);
|
||||
const normalizedOrigin = `${url.protocol}//${url.host}`;
|
||||
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' ? normalizedOrigin : '*';
|
||||
} catch {
|
||||
const normalizedOrigin = rawOrigin.replace(/\/$/, '');
|
||||
return normalizedOrigin.includes('localhost') || normalizedOrigin.includes('127.0.0.1')
|
||||
? normalizedOrigin
|
||||
: '*';
|
||||
}
|
||||
};
|
||||
|
||||
export class StaticFileServerManager {
|
||||
private app: App;
|
||||
private fileService: FileService;
|
||||
@@ -126,16 +141,38 @@ export class StaticFileServerManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取请求的 Origin 并设置 CORS
|
||||
const origin = req.headers.origin || req.headers.referer;
|
||||
const allowedOrigin = getAllowedOrigin(origin);
|
||||
|
||||
// 处理 CORS 预检请求
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Max-Age': '86400',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, `http://127.0.0.1:${this.serverPort}`);
|
||||
logger.debug(`Processing HTTP file request: ${req.url}`);
|
||||
logger.debug(`Request method: ${req.method}`);
|
||||
logger.debug(`Request headers: ${JSON.stringify(req.headers)}`);
|
||||
|
||||
// 提取文件路径:从 /desktop-file/path/to/file.png 中提取相对路径
|
||||
let filePath = decodeURIComponent(url.pathname.slice(1)); // 移除开头的 /
|
||||
logger.debug(`Initial file path after decode: ${filePath}`);
|
||||
|
||||
// 如果路径以 desktop-file/ 开头,则移除该前缀
|
||||
const prefixWithoutSlash = LOCAL_STORAGE_URL_PREFIX.slice(1) + '/'; // 移除开头的 / 并添加结尾的 /
|
||||
logger.debug(`Prefix to remove: ${prefixWithoutSlash}`);
|
||||
|
||||
if (filePath.startsWith(prefixWithoutSlash)) {
|
||||
filePath = filePath.slice(prefixWithoutSlash.length);
|
||||
logger.debug(`File path after removing prefix: ${filePath}`);
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
@@ -148,7 +185,12 @@ export class StaticFileServerManager {
|
||||
}
|
||||
|
||||
// 使用 FileService 获取文件
|
||||
const fileResult = await this.fileService.getFile(`desktop://${filePath}`);
|
||||
const desktopPath = `desktop://${filePath}`;
|
||||
logger.debug(`Attempting to get file: ${desktopPath}`);
|
||||
const fileResult = await this.fileService.getFile(desktopPath);
|
||||
logger.debug(
|
||||
`File retrieved successfully, mime type: ${fileResult.mimeType}, size: ${fileResult.content.byteLength} bytes`,
|
||||
);
|
||||
|
||||
// 再次检查响应状态
|
||||
if (res.destroyed || res.headersSent) {
|
||||
@@ -158,11 +200,8 @@ export class StaticFileServerManager {
|
||||
|
||||
// 设置响应头
|
||||
res.writeHead(200, {
|
||||
// 缓存一年
|
||||
'Access-Control-Allow-Origin': 'http://localhost:*',
|
||||
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
// 允许 localhost 的任意端口
|
||||
'Content-Length': Buffer.byteLength(fileResult.content),
|
||||
'Content-Type': fileResult.mimeType,
|
||||
});
|
||||
@@ -173,16 +212,27 @@ export class StaticFileServerManager {
|
||||
logger.debug(`HTTP file served successfully: desktop://${filePath}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error serving HTTP file: ${error}`);
|
||||
logger.error(`Error stack: ${error.stack}`);
|
||||
|
||||
// 检查响应是否仍然可写
|
||||
if (!res.destroyed && !res.headersSent) {
|
||||
try {
|
||||
// 获取请求的 Origin 并设置 CORS(错误响应也需要!)
|
||||
const origin = req.headers.origin || req.headers.referer;
|
||||
const allowedOrigin = getAllowedOrigin(origin);
|
||||
|
||||
// 判断是否是文件未找到错误
|
||||
if (error.name === 'FileNotFoundError') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.writeHead(404, {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
res.end('File Not Found');
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.writeHead(500, {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
} catch (writeError) {
|
||||
|
||||
@@ -141,8 +141,29 @@ export class UpdaterManager {
|
||||
// Mark application for exit
|
||||
this.app.isQuiting = true;
|
||||
|
||||
// Delay installation by 1 second to ensure window is closed
|
||||
autoUpdater.quitAndInstall();
|
||||
// Close all windows first to ensure clean exit
|
||||
logger.info('Closing all windows before update installation...');
|
||||
const { BrowserWindow, app } = require('electron');
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
allWindows.forEach((window) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Release single instance lock before quitting
|
||||
// This ensures the new instance can acquire the lock
|
||||
logger.info('Releasing single instance lock...');
|
||||
app.releaseSingleInstanceLock();
|
||||
|
||||
// Small delay to ensure windows are closed and lock is released
|
||||
setTimeout(() => {
|
||||
// quitAndInstall parameters:
|
||||
// - isSilent: true (don't show installation UI)
|
||||
// - isForceRunAfter: true (force start app after installation)
|
||||
logger.info('Calling autoUpdater.quitAndInstall...');
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MacOSSearchServiceImpl } from '../impl/macOS';
|
||||
|
||||
/**
|
||||
* macOS File Search Integration Tests
|
||||
*
|
||||
* These tests run against the real macOS Spotlight service
|
||||
* using files in the current repository.
|
||||
*
|
||||
* Run with: bunx vitest run 'macOS.integration.test'
|
||||
*/
|
||||
|
||||
// Get repository root path (assumes test runs from apps/desktop)
|
||||
const repoRoot = path.resolve(__dirname, '../../../../..');
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('MacOSSearchServiceImpl Integration', () => {
|
||||
const searchService = new MacOSSearchServiceImpl();
|
||||
|
||||
describe('checkSearchServiceStatus', () => {
|
||||
it('should verify Spotlight is available on macOS', async () => {
|
||||
const isAvailable = await searchService.checkSearchServiceStatus();
|
||||
|
||||
expect(isAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search for known repository files', () => {
|
||||
it('should find package.json in repo root', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'package.json',
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find at least one package.json
|
||||
const packageJson = results.find((r) => r.name === 'package.json');
|
||||
expect(packageJson).toBeDefined();
|
||||
expect(packageJson!.type).toBe('json');
|
||||
expect(packageJson!.path).toContain(repoRoot);
|
||||
});
|
||||
|
||||
it('should find README files', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'README',
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Should contain markdown files
|
||||
const mdFile = results.find((r) => r.type === 'md');
|
||||
expect(mdFile).toBeDefined();
|
||||
expect(mdFile!.name).toMatch(/README/i);
|
||||
});
|
||||
|
||||
it('should find TypeScript files', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'macOS',
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find the macOS.ts implementation file
|
||||
const macOSFile = results.find((r) => r.name.includes('macOS') && r.type === 'ts');
|
||||
expect(macOSFile).toBeDefined();
|
||||
expect(macOSFile!.contentType).toBe('code');
|
||||
});
|
||||
|
||||
it('should find files in apps/desktop directory', async () => {
|
||||
const desktopPath = path.join(repoRoot, 'apps/desktop');
|
||||
|
||||
const results = await searchService.search({
|
||||
keywords: 'src',
|
||||
limit: 20,
|
||||
onlyIn: desktopPath,
|
||||
});
|
||||
|
||||
// Spotlight indexing may not be complete for this directory
|
||||
// so we make the test lenient
|
||||
if (results.length > 0) {
|
||||
// All results should be within apps/desktop
|
||||
results.forEach((result) => {
|
||||
expect(result.path).toContain('apps/desktop');
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'⚠️ No results found in apps/desktop - Spotlight indexing may not be complete',
|
||||
);
|
||||
}
|
||||
|
||||
// At minimum, verify the search completed without error
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should find test files', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'test.ts',
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find test files
|
||||
const testFile = results.find((r) => r.name.endsWith('.test.ts'));
|
||||
expect(testFile).toBeDefined();
|
||||
expect(testFile!.path).toContain('__tests__');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search with filters', () => {
|
||||
it('should respect limit parameter', async () => {
|
||||
const limit = 3;
|
||||
const results = await searchService.search({
|
||||
keywords: 'src',
|
||||
limit,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeLessThanOrEqual(limit);
|
||||
});
|
||||
|
||||
it('should search in specific subdirectory only', async () => {
|
||||
const srcPath = path.join(repoRoot, 'apps/desktop/src');
|
||||
|
||||
const results = await searchService.search({
|
||||
keywords: 'index',
|
||||
limit: 10,
|
||||
onlyIn: srcPath,
|
||||
});
|
||||
|
||||
// All results should be within the specified directory
|
||||
results.forEach((result) => {
|
||||
expect(result.path).toContain('apps/desktop/src');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent keywords', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'xyzabc123unlikely-keyword-that-does-not-exist-12345',
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file type detection', () => {
|
||||
it('should correctly identify TypeScript files', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'LocalFileCtr',
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
const tsFile = results.find((r) => r.name === 'LocalFileCtr.ts');
|
||||
if (tsFile) {
|
||||
expect(tsFile.type).toBe('ts');
|
||||
expect(tsFile.contentType).toBe('code');
|
||||
expect(tsFile.isDirectory).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly identify JSON files', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'tsconfig',
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
const jsonFile = results.find((r) => r.name.includes('tsconfig') && r.type === 'json');
|
||||
if (jsonFile) {
|
||||
expect(jsonFile.type).toBe('json');
|
||||
expect(jsonFile.contentType).toBe('code');
|
||||
expect(jsonFile.size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly identify directories', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: '__tests__',
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
const testDir = results.find((r) => r.name === '__tests__' && r.isDirectory);
|
||||
if (testDir) {
|
||||
expect(testDir.isDirectory).toBe(true);
|
||||
expect(testDir.type).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly identify markdown files', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'CLAUDE.md',
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
const mdFile = results.find((r) => r.name === 'CLAUDE.md');
|
||||
if (mdFile) {
|
||||
expect(mdFile.type).toBe('md');
|
||||
expect(mdFile.contentType).toBe('text');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('file metadata', () => {
|
||||
it('should return valid file metadata', async () => {
|
||||
const results = await searchService.search({
|
||||
keywords: 'package.json',
|
||||
limit: 1,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
const file = results[0];
|
||||
|
||||
// Verify all metadata fields are present
|
||||
expect(file.path).toBeTruthy();
|
||||
expect(file.name).toBeTruthy();
|
||||
expect(typeof file.isDirectory).toBe('boolean');
|
||||
expect(typeof file.size).toBe('number');
|
||||
expect(file.size).toBeGreaterThanOrEqual(0);
|
||||
expect(file.type).toBeDefined();
|
||||
expect(file.contentType).toBeDefined();
|
||||
expect(file.modifiedTime).toBeInstanceOf(Date);
|
||||
expect(file.createdTime).toBeInstanceOf(Date);
|
||||
expect(file.lastAccessTime).toBeInstanceOf(Date);
|
||||
|
||||
// Dates should be valid
|
||||
expect(file.modifiedTime.getTime()).toBeGreaterThan(0);
|
||||
expect(file.createdTime.getTime()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle files with different extensions', async () => {
|
||||
const testCases = [
|
||||
{ keyword: '.ts', expectedType: 'ts', expectedContentType: 'code' },
|
||||
{ keyword: '.json', expectedType: 'json', expectedContentType: 'code' },
|
||||
{ keyword: '.txt', expectedType: 'txt', expectedContentType: 'text' },
|
||||
];
|
||||
|
||||
for (const { keyword, expectedType, expectedContentType } of testCases) {
|
||||
const results = await searchService.search({
|
||||
keywords: keyword,
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
const file = results.find((r) => r.type === expectedType);
|
||||
if (file) {
|
||||
expect(file.type).toBe(expectedType);
|
||||
expect(file.contentType).toBe(expectedContentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('search accuracy after fix', () => {
|
||||
it('should use fuzzy matching instead of exact phrase', async () => {
|
||||
// Test the fix: keywords should do fuzzy matching, not exact phrase
|
||||
// Before fix: "local file" would only match exact phrase "local file"
|
||||
// After fix: "local file" should match "LocalFileCtr" (contains "local" and "file")
|
||||
|
||||
const results = await searchService.search({
|
||||
keywords: 'LocalFile',
|
||||
limit: 10,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find LocalFileCtr.ts or similar files
|
||||
const found = results.some(
|
||||
(r) => r.name.includes('LocalFile') || r.name.includes('localFile'),
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle paths with spaces correctly', async () => {
|
||||
// Test the fix: command args should be properly split
|
||||
// This test verifies spawn receives correct arguments array
|
||||
|
||||
const pathWithSpaces = repoRoot; // May contain spaces in CI or certain setups
|
||||
const results = await searchService.search({
|
||||
keywords: 'test',
|
||||
limit: 5,
|
||||
onlyIn: pathWithSpaces,
|
||||
});
|
||||
|
||||
// Should not throw error even if path contains spaces
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should search case-insensitively', async () => {
|
||||
// The "cd" flag in kMDItemFSName makes it case-insensitive
|
||||
|
||||
const lowerResults = await searchService.search({
|
||||
keywords: 'readme',
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
const upperResults = await searchService.search({
|
||||
keywords: 'README',
|
||||
limit: 5,
|
||||
onlyIn: repoRoot,
|
||||
});
|
||||
|
||||
// Both searches should find similar files
|
||||
expect(lowerResults.length).toBeGreaterThan(0);
|
||||
expect(upperResults.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle non-existent directory gracefully', async () => {
|
||||
const nonExistentPath = path.join(repoRoot, 'this-directory-does-not-exist-12345');
|
||||
|
||||
const results = await searchService.search({
|
||||
keywords: 'test',
|
||||
limit: 5,
|
||||
onlyIn: nonExistentPath,
|
||||
});
|
||||
|
||||
// Should return empty array instead of throwing
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSearchIndex', () => {
|
||||
it.skip('should handle index update request', async () => {
|
||||
// Index update requires elevated permissions, may fail in restricted environments
|
||||
const result = await searchService.updateSearchIndex(repoRoot);
|
||||
|
||||
// Should return boolean (true if succeeded, false if failed)
|
||||
expect(typeof result).toBe('boolean');
|
||||
}, 15000); // Index update can take time
|
||||
});
|
||||
});
|
||||
|
||||
// Skip message for non-macOS platforms
|
||||
if (process.platform !== 'darwin') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('⏭️ Skipping macOS integration tests on', process.platform, '(only runs on darwin)');
|
||||
}
|
||||
@@ -23,12 +23,11 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Build the command first, regardless of execution method
|
||||
const command = this.buildSearchCommand(options);
|
||||
logger.debug(`Executing command: ${command}`);
|
||||
const { cmd, args, commandString } = this.buildSearchCommand(options);
|
||||
logger.debug(`Executing command: ${commandString}`);
|
||||
|
||||
// Use spawn for both live and non-live updates to handle large outputs
|
||||
return new Promise((resolve, reject) => {
|
||||
const [cmd, ...args] = command.split(' ');
|
||||
const childProcess = spawn(cmd, args);
|
||||
|
||||
let results: string[] = []; // Store raw file paths
|
||||
@@ -137,31 +136,39 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
/**
|
||||
* Build mdfind command string
|
||||
* @param options Search options
|
||||
* @returns Complete command string
|
||||
* @returns Command components (cmd, args array, and command string for logging)
|
||||
*/
|
||||
private buildSearchCommand(options: SearchOptions): string {
|
||||
// Basic command
|
||||
let command = 'mdfind';
|
||||
|
||||
// Add options
|
||||
const mdFindOptions: string[] = [];
|
||||
private buildSearchCommand(options: SearchOptions): {
|
||||
args: string[];
|
||||
cmd: string;
|
||||
commandString: string;
|
||||
} {
|
||||
// Command and arguments array
|
||||
const cmd = 'mdfind';
|
||||
const args: string[] = [];
|
||||
|
||||
// macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing
|
||||
|
||||
// Search in specific directory
|
||||
if (options.onlyIn) {
|
||||
mdFindOptions.push(`-onlyin "${options.onlyIn}"`);
|
||||
args.push('-onlyin', options.onlyIn);
|
||||
}
|
||||
|
||||
// Live update
|
||||
if (options.liveUpdate) {
|
||||
mdFindOptions.push('-live');
|
||||
args.push('-live');
|
||||
}
|
||||
|
||||
// Detailed metadata
|
||||
if (options.detailed) {
|
||||
mdFindOptions.push(
|
||||
'-attr kMDItemDisplayName kMDItemContentType kMDItemKind kMDItemFSSize kMDItemFSCreationDate kMDItemFSContentChangeDate',
|
||||
args.push(
|
||||
'-attr',
|
||||
'kMDItemDisplayName',
|
||||
'kMDItemContentType',
|
||||
'kMDItemKind',
|
||||
'kMDItemFSSize',
|
||||
'kMDItemFSCreationDate',
|
||||
'kMDItemFSContentChangeDate',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,9 +178,10 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
// Basic query
|
||||
if (options.keywords) {
|
||||
// If the query string doesn't use Spotlight query syntax (doesn't contain kMDItem properties),
|
||||
// treat it as plain text search
|
||||
// treat it as a flexible name search rather than exact phrase match
|
||||
if (!options.keywords.includes('kMDItem')) {
|
||||
queryExpression = `"${options.keywords.replaceAll('"', '\\"')}"`;
|
||||
// Use kMDItemFSName for filename matching with wildcards for better flexibility
|
||||
queryExpression = `kMDItemFSName == "*${options.keywords.replaceAll('"', '\\"')}*"cd`;
|
||||
} else {
|
||||
queryExpression = options.keywords;
|
||||
}
|
||||
@@ -244,15 +252,15 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Combine complete command
|
||||
if (mdFindOptions.length > 0) {
|
||||
command += ' ' + mdFindOptions.join(' ');
|
||||
// Add query expression to args
|
||||
if (queryExpression) {
|
||||
args.push(queryExpression);
|
||||
}
|
||||
|
||||
// Finally add query expression
|
||||
command += ` ${queryExpression}`;
|
||||
// Build command string for logging
|
||||
const commandString = `${cmd} ${args.map((arg) => (arg.includes(' ') || arg.includes('*') ? `"${arg}"` : arg)).join(' ')}`;
|
||||
|
||||
return command;
|
||||
return { args, cmd, commandString };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProxyDispatcherManager } from '../dispatcher';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock undici
|
||||
vi.mock('undici', () => ({
|
||||
Agent: vi.fn(),
|
||||
ProxyAgent: vi.fn(),
|
||||
getGlobalDispatcher: vi.fn(),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch-socks
|
||||
vi.mock('fetch-socks', () => ({
|
||||
socksDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ProxyUrlBuilder
|
||||
vi.mock('../urlBuilder', () => ({
|
||||
ProxyUrlBuilder: {
|
||||
build: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ProxyDispatcherManager', () => {
|
||||
let mockDispatcher: any;
|
||||
let mockAgent: any;
|
||||
let mockProxyAgent: any;
|
||||
let mockGetGlobalDispatcher: any;
|
||||
let mockSetGlobalDispatcher: any;
|
||||
let mockSocksDispatcher: any;
|
||||
let mockProxyUrlBuilder: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocked modules
|
||||
const undici = await import('undici');
|
||||
const fetchSocks = await import('fetch-socks');
|
||||
const urlBuilder = await import('../urlBuilder');
|
||||
|
||||
mockAgent = vi.mocked(undici.Agent);
|
||||
mockProxyAgent = vi.mocked(undici.ProxyAgent);
|
||||
mockGetGlobalDispatcher = vi.mocked(undici.getGlobalDispatcher);
|
||||
mockSetGlobalDispatcher = vi.mocked(undici.setGlobalDispatcher);
|
||||
mockSocksDispatcher = vi.mocked(fetchSocks.socksDispatcher);
|
||||
mockProxyUrlBuilder = vi.mocked(urlBuilder.ProxyUrlBuilder.build);
|
||||
|
||||
// Setup mock dispatcher with destroy method
|
||||
mockDispatcher = {
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockGetGlobalDispatcher.mockReturnValue(mockDispatcher);
|
||||
mockAgent.mockReturnValue({ destroy: vi.fn().mockResolvedValue(undefined) });
|
||||
mockProxyAgent.mockReturnValue({ destroy: vi.fn().mockResolvedValue(undefined) });
|
||||
mockSocksDispatcher.mockReturnValue({ destroy: vi.fn().mockResolvedValue(undefined) });
|
||||
|
||||
// Setup ProxyUrlBuilder mock to return properly formatted URLs
|
||||
mockProxyUrlBuilder.mockImplementation((config: NetworkProxySettings) => {
|
||||
if (config.proxyRequireAuth && config.proxyUsername && config.proxyPassword) {
|
||||
return `${config.proxyType}://${config.proxyUsername}:${config.proxyPassword}@${config.proxyServer}:${config.proxyPort}`;
|
||||
}
|
||||
return `${config.proxyType}://${config.proxyServer}:${config.proxyPort}`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProxyAgent', () => {
|
||||
describe('HTTP/HTTPS proxy', () => {
|
||||
it('should create ProxyAgent for http proxy', () => {
|
||||
const proxyUrl = 'http://proxy.example.com:8080';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('http', proxyUrl);
|
||||
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith({ uri: proxyUrl });
|
||||
});
|
||||
|
||||
it('should create ProxyAgent for https proxy', () => {
|
||||
const proxyUrl = 'https://proxy.example.com:8080';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('https', proxyUrl);
|
||||
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith({ uri: proxyUrl });
|
||||
});
|
||||
|
||||
it('should create ProxyAgent with authentication', () => {
|
||||
const proxyUrl = 'http://user:pass@proxy.example.com:8080';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('http', proxyUrl);
|
||||
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith({ uri: proxyUrl });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SOCKS5 proxy', () => {
|
||||
it('should create socksDispatcher for socks5 proxy without auth', () => {
|
||||
const proxyUrl = 'socks5://proxy.example.com:1080';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('socks5', proxyUrl);
|
||||
|
||||
expect(mockSocksDispatcher).toHaveBeenCalledWith([
|
||||
{
|
||||
host: 'proxy.example.com',
|
||||
port: 1080,
|
||||
type: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create socksDispatcher for socks5 proxy with auth', () => {
|
||||
const proxyUrl = 'socks5://user:pass@proxy.example.com:1080';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('socks5', proxyUrl);
|
||||
|
||||
expect(mockSocksDispatcher).toHaveBeenCalledWith([
|
||||
{
|
||||
host: 'proxy.example.com',
|
||||
port: 1080,
|
||||
type: 5,
|
||||
userId: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create socksDispatcher with IPv4 address', () => {
|
||||
const proxyUrl = 'socks5://192.168.1.1:1080';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('socks5', proxyUrl);
|
||||
|
||||
expect(mockSocksDispatcher).toHaveBeenCalledWith([
|
||||
{
|
||||
host: '192.168.1.1',
|
||||
port: 1080,
|
||||
type: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create socksDispatcher with different port', () => {
|
||||
const proxyUrl = 'socks5://proxy.example.com:9050';
|
||||
|
||||
ProxyDispatcherManager.createProxyAgent('socks5', proxyUrl);
|
||||
|
||||
expect(mockSocksDispatcher).toHaveBeenCalledWith([
|
||||
{
|
||||
host: 'proxy.example.com',
|
||||
port: 9050,
|
||||
type: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error when ProxyAgent creation fails', () => {
|
||||
mockProxyAgent.mockImplementationOnce(() => {
|
||||
throw new Error('ProxyAgent creation failed');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
ProxyDispatcherManager.createProxyAgent('http', 'http://invalid');
|
||||
}).toThrow('Failed to create proxy agent: ProxyAgent creation failed');
|
||||
});
|
||||
|
||||
it('should throw error when socksDispatcher creation fails', () => {
|
||||
mockSocksDispatcher.mockImplementationOnce(() => {
|
||||
throw new Error('SOCKS dispatcher creation failed');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
ProxyDispatcherManager.createProxyAgent('socks5', 'socks5://invalid');
|
||||
}).toThrow('Failed to create proxy agent: SOCKS dispatcher creation failed');
|
||||
});
|
||||
|
||||
it('should throw error with unknown error type', () => {
|
||||
mockProxyAgent.mockImplementationOnce(() => {
|
||||
throw 'String error';
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
ProxyDispatcherManager.createProxyAgent('http', 'http://invalid');
|
||||
}).toThrow('Failed to create proxy agent: Unknown error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyProxySettings', () => {
|
||||
const validConfig: NetworkProxySettings = {
|
||||
enableProxy: true,
|
||||
proxyType: 'http',
|
||||
proxyServer: 'proxy.example.com',
|
||||
proxyPort: '8080',
|
||||
proxyRequireAuth: false,
|
||||
proxyBypass: 'localhost,127.0.0.1,::1',
|
||||
};
|
||||
|
||||
describe('disable proxy', () => {
|
||||
it('should reset to direct connection when proxy is disabled', async () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
enableProxy: false,
|
||||
};
|
||||
|
||||
await ProxyDispatcherManager.applyProxySettings(config);
|
||||
|
||||
expect(mockDispatcher.destroy).toHaveBeenCalled();
|
||||
expect(mockAgent).toHaveBeenCalled();
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle dispatcher destruction failure gracefully', async () => {
|
||||
mockDispatcher.destroy.mockRejectedValueOnce(new Error('Destroy failed'));
|
||||
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
enableProxy: false,
|
||||
};
|
||||
|
||||
// Should not throw even if destroy fails
|
||||
await expect(ProxyDispatcherManager.applyProxySettings(config)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle dispatcher without destroy method', async () => {
|
||||
mockGetGlobalDispatcher.mockReturnValueOnce({});
|
||||
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
enableProxy: false,
|
||||
};
|
||||
|
||||
await expect(ProxyDispatcherManager.applyProxySettings(config)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable proxy', () => {
|
||||
it('should apply http proxy settings', async () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'http',
|
||||
};
|
||||
|
||||
await ProxyDispatcherManager.applyProxySettings(config);
|
||||
|
||||
expect(mockDispatcher.destroy).toHaveBeenCalled();
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith({
|
||||
uri: 'http://proxy.example.com:8080',
|
||||
});
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply https proxy settings', async () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'https',
|
||||
};
|
||||
|
||||
await ProxyDispatcherManager.applyProxySettings(config);
|
||||
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith({
|
||||
uri: 'https://proxy.example.com:8080',
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply socks5 proxy settings', async () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'socks5',
|
||||
};
|
||||
|
||||
await ProxyDispatcherManager.applyProxySettings(config);
|
||||
|
||||
expect(mockSocksDispatcher).toHaveBeenCalled();
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply proxy with authentication', async () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
await ProxyDispatcherManager.applyProxySettings(config);
|
||||
|
||||
expect(mockProxyAgent).toHaveBeenCalledWith({
|
||||
uri: 'http://testuser:testpass@proxy.example.com:8080',
|
||||
});
|
||||
});
|
||||
|
||||
it('should destroy old dispatcher before applying new proxy', async () => {
|
||||
const destroySpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetGlobalDispatcher.mockReturnValue({ destroy: destroySpy });
|
||||
|
||||
await ProxyDispatcherManager.applyProxySettings(validConfig);
|
||||
|
||||
expect(destroySpy).toHaveBeenCalled();
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent proxy changes', () => {
|
||||
it('should queue concurrent proxy setting changes', async () => {
|
||||
const config1: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '8080',
|
||||
};
|
||||
|
||||
const config2: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '8081',
|
||||
};
|
||||
|
||||
// Start both operations concurrently
|
||||
const promise1 = ProxyDispatcherManager.applyProxySettings(config1);
|
||||
const promise2 = ProxyDispatcherManager.applyProxySettings(config2);
|
||||
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
// Both operations should complete successfully
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process queued operations sequentially', async () => {
|
||||
const operations: Promise<void>[] = [];
|
||||
|
||||
// Queue multiple operations
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: `${8080 + i}`,
|
||||
};
|
||||
operations.push(ProxyDispatcherManager.applyProxySettings(config));
|
||||
}
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
// All operations should complete
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should handle errors in queued operations', async () => {
|
||||
mockProxyAgent.mockReturnValueOnce({ destroy: vi.fn() }).mockImplementationOnce(() => {
|
||||
throw new Error('Agent creation failed');
|
||||
});
|
||||
|
||||
const config1: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '8080',
|
||||
};
|
||||
|
||||
const config2: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '8081',
|
||||
};
|
||||
|
||||
const promise1 = ProxyDispatcherManager.applyProxySettings(config1);
|
||||
const promise2 = ProxyDispatcherManager.applyProxySettings(config2);
|
||||
|
||||
await expect(promise1).resolves.not.toThrow();
|
||||
await expect(promise2).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate error when agent creation fails', async () => {
|
||||
mockProxyAgent.mockImplementationOnce(() => {
|
||||
throw new Error('Agent creation failed');
|
||||
});
|
||||
|
||||
await expect(ProxyDispatcherManager.applyProxySettings(validConfig)).rejects.toThrow(
|
||||
'Failed to create proxy agent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle null dispatcher gracefully', async () => {
|
||||
mockGetGlobalDispatcher.mockReturnValueOnce(null);
|
||||
|
||||
await expect(ProxyDispatcherManager.applyProxySettings(validConfig)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle undefined dispatcher gracefully', async () => {
|
||||
mockGetGlobalDispatcher.mockReturnValueOnce(undefined);
|
||||
|
||||
await expect(ProxyDispatcherManager.applyProxySettings(validConfig)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProxyConnectionTester } from '../tester';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock undici
|
||||
vi.mock('undici', () => ({
|
||||
fetch: vi.fn(),
|
||||
getGlobalDispatcher: vi.fn(),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ProxyConfigValidator
|
||||
vi.mock('../validator', () => ({
|
||||
ProxyConfigValidator: {
|
||||
validate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ProxyUrlBuilder
|
||||
vi.mock('../urlBuilder', () => ({
|
||||
ProxyUrlBuilder: {
|
||||
build: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ProxyDispatcherManager
|
||||
vi.mock('../dispatcher', () => ({
|
||||
ProxyDispatcherManager: {
|
||||
createProxyAgent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ProxyConnectionTester', () => {
|
||||
let mockAgent: any;
|
||||
let mockOriginalDispatcher: any;
|
||||
let mockFetch: any;
|
||||
let mockGetGlobalDispatcher: any;
|
||||
let mockSetGlobalDispatcher: any;
|
||||
let mockProxyDispatcherManager: any;
|
||||
let mockProxyConfigValidator: any;
|
||||
let mockProxyUrlBuilder: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocked modules
|
||||
const undici = await import('undici');
|
||||
const dispatcher = await import('../dispatcher');
|
||||
const validator = await import('../validator');
|
||||
const urlBuilder = await import('../urlBuilder');
|
||||
|
||||
mockFetch = vi.mocked(undici.fetch);
|
||||
mockGetGlobalDispatcher = vi.mocked(undici.getGlobalDispatcher);
|
||||
mockSetGlobalDispatcher = vi.mocked(undici.setGlobalDispatcher);
|
||||
mockProxyDispatcherManager = vi.mocked(dispatcher.ProxyDispatcherManager);
|
||||
mockProxyConfigValidator = vi.mocked(validator.ProxyConfigValidator);
|
||||
mockProxyUrlBuilder = vi.mocked(urlBuilder.ProxyUrlBuilder.build);
|
||||
|
||||
// Setup mock agent
|
||||
mockAgent = {
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockOriginalDispatcher = {
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockGetGlobalDispatcher.mockReturnValue(mockOriginalDispatcher);
|
||||
mockProxyDispatcherManager.createProxyAgent.mockReturnValue(mockAgent);
|
||||
mockProxyConfigValidator.validate.mockReturnValue({ isValid: true, errors: [] });
|
||||
mockProxyUrlBuilder.mockImplementation((config: NetworkProxySettings) => {
|
||||
return `${config.proxyType}://${config.proxyServer}:${config.proxyPort}`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
describe('successful connection', () => {
|
||||
it('should return success for successful HTTP request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
expect(result.message).toBeUndefined();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://www.google.com',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': 'LobeChat-Desktop/1.0.0',
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return success with custom URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const customUrl = 'https://api.example.com';
|
||||
const result = await ProxyConnectionTester.testConnection(customUrl);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(customUrl, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should return success with custom timeout', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection('https://www.google.com', 5000);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should include response time in result', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.responseTime).toBeDefined();
|
||||
expect(typeof result.responseTime).toBe('number');
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection failures', () => {
|
||||
it('should return failure for HTTP error status', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('HTTP 404');
|
||||
expect(result.message).toContain('Not Found');
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return failure for HTTP 500 error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('should return failure for network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Network error');
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return failure for timeout', async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
return new Promise((_, reject) => {
|
||||
const error = new Error('Request aborted');
|
||||
error.name = 'AbortError';
|
||||
setTimeout(() => reject(error), 50);
|
||||
});
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection('https://www.google.com', 100);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return failure for connection refused', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('should handle unknown error type', async () => {
|
||||
mockFetch.mockRejectedValueOnce('String error');
|
||||
|
||||
const result = await ProxyConnectionTester.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('testProxyConfig', () => {
|
||||
const validConfig: NetworkProxySettings = {
|
||||
enableProxy: true,
|
||||
proxyType: 'http',
|
||||
proxyServer: 'proxy.example.com',
|
||||
proxyPort: '8080',
|
||||
proxyRequireAuth: false,
|
||||
proxyBypass: 'localhost,127.0.0.1,::1',
|
||||
};
|
||||
|
||||
describe('config validation', () => {
|
||||
it('should return failure for invalid config', async () => {
|
||||
mockProxyConfigValidator.validate.mockReturnValueOnce({
|
||||
isValid: false,
|
||||
errors: ['Proxy server is required', 'Invalid port'],
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Invalid proxy configuration');
|
||||
expect(result.message).toContain('Proxy server is required');
|
||||
expect(result.message).toContain('Invalid port');
|
||||
});
|
||||
|
||||
it('should validate config before testing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(mockProxyConfigValidator.validate).toHaveBeenCalledWith(validConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled proxy', () => {
|
||||
it('should test direct connection when proxy is disabled', async () => {
|
||||
const disabledConfig: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
enableProxy: false,
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(disabledConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom test URL for disabled proxy', async () => {
|
||||
const disabledConfig: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
enableProxy: false,
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const customUrl = 'https://api.example.com';
|
||||
await ProxyConnectionTester.testProxyConfig(disabledConfig, customUrl);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(customUrl, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled proxy', () => {
|
||||
it('should test proxy connection successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should create temporary proxy agent for testing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(mockProxyDispatcherManager.createProxyAgent).toHaveBeenCalledWith(
|
||||
'http',
|
||||
'http://proxy.example.com:8080',
|
||||
);
|
||||
});
|
||||
|
||||
it('should restore original dispatcher after test', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalledWith(mockOriginalDispatcher);
|
||||
});
|
||||
|
||||
it('should destroy temporary agent after test', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(mockAgent.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore dispatcher even if test fails', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(mockSetGlobalDispatcher).toHaveBeenCalledWith(mockOriginalDispatcher);
|
||||
});
|
||||
|
||||
it('should destroy agent even if test fails', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(mockAgent.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle agent destroy failure gracefully', async () => {
|
||||
mockAgent.destroy.mockRejectedValueOnce(new Error('Destroy failed'));
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should test with custom URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const customUrl = 'https://httpbin.org/ip';
|
||||
await ProxyConnectionTester.testProxyConfig(validConfig, customUrl);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
customUrl,
|
||||
expect.objectContaining({
|
||||
dispatcher: mockAgent,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should test socks5 proxy', async () => {
|
||||
const socks5Config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'socks5',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(socks5Config);
|
||||
|
||||
expect(mockProxyDispatcherManager.createProxyAgent).toHaveBeenCalledWith(
|
||||
'socks5',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should test proxy with authentication', async () => {
|
||||
const authConfig: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user',
|
||||
proxyPassword: 'pass',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await ProxyConnectionTester.testProxyConfig(authConfig);
|
||||
|
||||
expect(mockProxyUrlBuilder).toHaveBeenCalledWith(authConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return failure when agent creation fails', async () => {
|
||||
mockProxyDispatcherManager.createProxyAgent.mockImplementationOnce(() => {
|
||||
throw new Error('Agent creation failed');
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Proxy test failed');
|
||||
expect(result.message).toContain('Agent creation failed');
|
||||
});
|
||||
|
||||
it('should return failure when fetch fails', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection timeout'));
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Connection timeout');
|
||||
});
|
||||
|
||||
it('should return failure for HTTP error response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 407,
|
||||
statusText: 'Proxy Authentication Required',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('HTTP 407');
|
||||
});
|
||||
|
||||
it('should handle timeout correctly', async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout')), 50);
|
||||
});
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle unknown error type', async () => {
|
||||
mockProxyDispatcherManager.createProxyAgent.mockImplementationOnce(() => {
|
||||
throw 'String error';
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown error');
|
||||
});
|
||||
|
||||
it('should handle null agent', async () => {
|
||||
mockProxyDispatcherManager.createProxyAgent.mockReturnValueOnce(null);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
// Should handle gracefully
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle agent without destroy method', async () => {
|
||||
mockProxyDispatcherManager.createProxyAgent.mockReturnValueOnce({});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const result = await ProxyConnectionTester.testProxyConfig(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ProxyUrlBuilder } from '../urlBuilder';
|
||||
|
||||
describe('ProxyUrlBuilder', () => {
|
||||
const baseConfig: NetworkProxySettings = {
|
||||
enableProxy: true,
|
||||
proxyType: 'http',
|
||||
proxyServer: 'proxy.example.com',
|
||||
proxyPort: '8080',
|
||||
proxyRequireAuth: false,
|
||||
proxyBypass: 'localhost,127.0.0.1,::1',
|
||||
};
|
||||
|
||||
describe('build', () => {
|
||||
describe('without authentication', () => {
|
||||
it('should build URL with http proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyType: 'http',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should build URL with https proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyType: 'https',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('https://proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should build URL with socks5 proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyType: 'socks5',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('socks5://proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should build URL with IPv4 address', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyServer: '192.168.1.1',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://192.168.1.1:8080');
|
||||
});
|
||||
|
||||
it('should build URL with localhost', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyServer: 'localhost',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://localhost:8080');
|
||||
});
|
||||
|
||||
it('should build URL with different port', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyPort: '3128',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:3128');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with authentication', () => {
|
||||
it('should build URL with username and password', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://testuser:testpass@proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should build URL with encoded username containing @ symbol', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user@domain.com',
|
||||
proxyPassword: 'password',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://user%40domain.com:password@proxy.example.com:8080');
|
||||
// Verify encoding
|
||||
expect(url).toContain('user%40domain.com');
|
||||
});
|
||||
|
||||
it('should build URL with encoded password containing colon', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user',
|
||||
proxyPassword: 'pass:word',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://user:pass%3Aword@proxy.example.com:8080');
|
||||
// Verify encoding
|
||||
expect(url).toContain('pass%3Aword');
|
||||
});
|
||||
|
||||
it('should build URL with encoded special characters in username', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user name',
|
||||
proxyPassword: 'password',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://user%20name:password@proxy.example.com:8080');
|
||||
// Verify encoding
|
||||
expect(url).toContain('user%20name');
|
||||
});
|
||||
|
||||
it('should build URL with encoded special characters in password', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user',
|
||||
proxyPassword: 'p@ss w0rd!',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
// Verify encoding of special characters
|
||||
expect(url).toContain(encodeURIComponent('p@ss w0rd!'));
|
||||
expect(url).toContain('user:');
|
||||
expect(url).toContain('@proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should build URL with encoded slash in credentials', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'domain/user',
|
||||
proxyPassword: 'pass/word',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://domain%2Fuser:pass%2Fword@proxy.example.com:8080');
|
||||
// Verify encoding
|
||||
expect(url).toContain('domain%2Fuser');
|
||||
expect(url).toContain('pass%2Fword');
|
||||
});
|
||||
|
||||
it('should build URL with encoded hash in credentials', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user#123',
|
||||
proxyPassword: 'pass#word',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://user%23123:pass%23word@proxy.example.com:8080');
|
||||
// Verify encoding
|
||||
expect(url).toContain('user%23123');
|
||||
expect(url).toContain('pass%23word');
|
||||
});
|
||||
|
||||
it('should build URL with encoded question mark in credentials', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user?name',
|
||||
proxyPassword: 'pass?word',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://user%3Fname:pass%3Fword@proxy.example.com:8080');
|
||||
// Verify encoding
|
||||
expect(url).toContain('user%3Fname');
|
||||
expect(url).toContain('pass%3Fword');
|
||||
});
|
||||
|
||||
it('should build URL with https proxy type and auth', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyType: 'https',
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('https://testuser:testpass@proxy.example.com:8080');
|
||||
});
|
||||
|
||||
it('should build URL with socks5 proxy type and auth', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyType: 'socks5',
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'sockuser',
|
||||
proxyPassword: 'sockpass',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('socks5://sockuser:sockpass@proxy.example.com:8080');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should not include auth when proxyRequireAuth is false but credentials are provided', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: false,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:8080');
|
||||
expect(url).not.toContain('testuser');
|
||||
expect(url).not.toContain('testpass');
|
||||
});
|
||||
|
||||
it('should not include auth when username is empty', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: '',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:8080');
|
||||
expect(url).not.toContain('@');
|
||||
});
|
||||
|
||||
it('should not include auth when password is empty', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: '',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:8080');
|
||||
expect(url).not.toContain('@');
|
||||
});
|
||||
|
||||
it('should not include auth when username is undefined', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: undefined,
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:8080');
|
||||
expect(url).not.toContain('@');
|
||||
});
|
||||
|
||||
it('should not include auth when password is undefined', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: undefined,
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:8080');
|
||||
expect(url).not.toContain('@');
|
||||
});
|
||||
|
||||
it('should handle minimum port number', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyPort: '1',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:1');
|
||||
});
|
||||
|
||||
it('should handle maximum port number', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyPort: '65535',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
expect(url).toBe('http://proxy.example.com:65535');
|
||||
});
|
||||
|
||||
it('should handle complex URL encoding scenario', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...baseConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'user@example.com:admin',
|
||||
proxyPassword: 'p@ss:w0rd#123',
|
||||
};
|
||||
|
||||
const url = ProxyUrlBuilder.build(config);
|
||||
|
||||
// Verify all special characters are encoded
|
||||
const expectedUsername = encodeURIComponent('user@example.com:admin');
|
||||
const expectedPassword = encodeURIComponent('p@ss:w0rd#123');
|
||||
|
||||
expect(url).toBe(`http://${expectedUsername}:${expectedPassword}@proxy.example.com:8080`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ProxyConfigValidator } from '../validator';
|
||||
|
||||
describe('ProxyConfigValidator', () => {
|
||||
const validConfig: NetworkProxySettings = {
|
||||
enableProxy: true,
|
||||
proxyType: 'http',
|
||||
proxyServer: 'proxy.example.com',
|
||||
proxyPort: '8080',
|
||||
proxyRequireAuth: false,
|
||||
proxyBypass: 'localhost,127.0.0.1,::1',
|
||||
};
|
||||
|
||||
describe('validate', () => {
|
||||
describe('disabled proxy', () => {
|
||||
it('should validate successfully when proxy is disabled', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
enableProxy: false,
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip validation for disabled proxy even with invalid fields', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
enableProxy: false,
|
||||
proxyType: 'invalid' as any,
|
||||
proxyServer: '',
|
||||
proxyPort: 'invalid',
|
||||
proxyRequireAuth: false,
|
||||
proxyBypass: 'localhost',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy type validation', () => {
|
||||
it('should accept http proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'http',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept https proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'https',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept socks5 proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'socks5',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject unsupported proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'socks4' as any,
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toContain('Unsupported proxy type');
|
||||
expect(result.errors[0]).toContain('socks4');
|
||||
});
|
||||
|
||||
it('should reject invalid proxy type', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyType: 'ftp' as any,
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors[0]).toContain('Supported types: http, https, socks5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy server validation', () => {
|
||||
it('should accept valid domain name', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: 'proxy.example.com',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept valid IPv4 address', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: '192.168.1.1',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept localhost', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: 'localhost',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept subdomain', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: 'proxy.subdomain.example.com',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject empty proxy server', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: '',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy server is required when proxy is enabled');
|
||||
});
|
||||
|
||||
it('should reject whitespace-only proxy server', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: ' ',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy server is required when proxy is enabled');
|
||||
});
|
||||
|
||||
it('should reject invalid domain format', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: 'invalid..domain',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid proxy server format');
|
||||
});
|
||||
|
||||
it('should reject domain starting with hyphen', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: '-proxy.com',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid proxy server format');
|
||||
});
|
||||
|
||||
it('should reject domain with invalid characters', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: 'proxy@example.com',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid proxy server format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy port validation', () => {
|
||||
it('should accept valid port 1', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '1',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept valid port 65535', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '65535',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept common proxy port 8080', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '8080',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject empty proxy port', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy port is required when proxy is enabled');
|
||||
});
|
||||
|
||||
it('should reject whitespace-only proxy port', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: ' ',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy port is required when proxy is enabled');
|
||||
});
|
||||
|
||||
it('should reject port 0', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '0',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy port must be a valid number between 1 and 65535');
|
||||
});
|
||||
|
||||
it('should reject port above 65535', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '65536',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy port must be a valid number between 1 and 65535');
|
||||
});
|
||||
|
||||
it('should reject negative port', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: '-1',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy port must be a valid number between 1 and 65535');
|
||||
});
|
||||
|
||||
it('should reject non-numeric port', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyPort: 'abc',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Proxy port must be a valid number between 1 and 65535');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication validation', () => {
|
||||
it('should validate successfully with auth disabled', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: false,
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate successfully with auth enabled and credentials provided', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject when auth is enabled but username is missing', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: '',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Proxy username is required when authentication is enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when auth is enabled but username is whitespace', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: ' ',
|
||||
proxyPassword: 'testpass',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Proxy username is required when authentication is enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when auth is enabled but password is missing', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: '',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Proxy password is required when authentication is enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when auth is enabled but password is whitespace', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: 'testuser',
|
||||
proxyPassword: ' ',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Proxy password is required when authentication is enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when auth is enabled but both username and password are missing', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: '',
|
||||
proxyPassword: '',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors).toContain(
|
||||
'Proxy username is required when authentication is enabled',
|
||||
);
|
||||
expect(result.errors).toContain(
|
||||
'Proxy password is required when authentication is enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow missing credentials when auth is disabled', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyRequireAuth: false,
|
||||
proxyUsername: undefined,
|
||||
proxyPassword: undefined,
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple validation errors', () => {
|
||||
it('should collect all validation errors', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
enableProxy: true,
|
||||
proxyType: 'invalid' as any,
|
||||
proxyServer: '',
|
||||
proxyPort: 'abc',
|
||||
proxyRequireAuth: true,
|
||||
proxyUsername: '',
|
||||
proxyPassword: '',
|
||||
proxyBypass: 'localhost',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should collect errors for invalid server and port', () => {
|
||||
const config: NetworkProxySettings = {
|
||||
...validConfig,
|
||||
proxyServer: 'invalid..domain',
|
||||
proxyPort: '99999',
|
||||
};
|
||||
|
||||
const result = ProxyConfigValidator.validate(config);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors).toContain('Invalid proxy server format');
|
||||
expect(result.errors).toContain('Proxy port must be a valid number between 1 and 65535');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// copy from https://github.com/kirill-konshin/next-electron-rsc
|
||||
import { serialize as serializeCookie } from 'cookie';
|
||||
import { type Protocol, type Session, protocol } from 'electron';
|
||||
import { type Protocol, type Session } from 'electron';
|
||||
import type { NextConfig } from 'next';
|
||||
import type NextNodeServer from 'next/dist/server/next-server';
|
||||
import assert from 'node:assert';
|
||||
@@ -202,6 +202,11 @@ export function createHandler({
|
||||
|
||||
if (!isDev) {
|
||||
logger.info('Initializing Next.js app for production');
|
||||
|
||||
// https://github.com/lobehub/lobe-chat/pull/9851
|
||||
// @ts-expect-error
|
||||
// noinspection JSConstantReassignment
|
||||
process.env.NODE_ENV = 'production';
|
||||
const next = require(resolve.sync('next', { basedir: standaloneDir }));
|
||||
|
||||
// @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340
|
||||
@@ -209,10 +214,7 @@ export function createHandler({
|
||||
.config as NextConfig;
|
||||
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
|
||||
|
||||
app = next({
|
||||
dev: false,
|
||||
dir: standaloneDir,
|
||||
}) as NextNodeServer;
|
||||
app = next({ dir: standaloneDir }) as NextNodeServer;
|
||||
|
||||
handler = app.getRequestHandler();
|
||||
preparePromise = app.prepare();
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/main/*"],
|
||||
"~common/*": ["src/common/*"]
|
||||
|
||||
@@ -1,4 +1,718 @@
|
||||
[
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove language_model_settings and remove isDeprecatedEdition."]
|
||||
},
|
||||
"date": "2025-11-17",
|
||||
"version": "2.0.0-next.69"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["The tool to fail execution on ollama when a message contains b…."]
|
||||
},
|
||||
"date": "2025-11-16",
|
||||
"version": "2.0.0-next.68"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor to virtua."]
|
||||
},
|
||||
"date": "2025-11-16",
|
||||
"version": "2.0.0-next.67"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support to collapse message."]
|
||||
},
|
||||
"date": "2025-11-16",
|
||||
"version": "2.0.0-next.66"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-16",
|
||||
"version": "2.0.0-next.65"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor package types."]
|
||||
},
|
||||
"date": "2025-11-15",
|
||||
"version": "2.0.0-next.64"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Show orphaned tool message and support delete tool message."]
|
||||
},
|
||||
"date": "2025-11-15",
|
||||
"version": "2.0.0-next.63"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-15",
|
||||
"version": "2.0.0-next.62"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-15",
|
||||
"version": "2.0.0-next.61"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Reduce threshold."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.60"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.59"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support DeepSeek Interleaved thinking."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.58"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Revert background style."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.57"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add folder creation UI and clean up debug code."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.56"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Create Pages in Knowledge Base."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.55"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor and support move locale file intervention."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.54"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add GPT-5.1 models."],
|
||||
"improvements": ["Fix approving render and improve Conversation style."]
|
||||
},
|
||||
"date": "2025-11-14",
|
||||
"version": "2.0.0-next.53"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Filter out reasoning fields from messages in ChatCompletion API."]
|
||||
},
|
||||
"date": "2025-11-13",
|
||||
"version": "2.0.0-next.52"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update ERNIE-5.0-Thinking-Preview model."]
|
||||
},
|
||||
"date": "2025-11-13",
|
||||
"version": "2.0.0-next.51"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix oidc accountId mismatch."]
|
||||
},
|
||||
"date": "2025-11-13",
|
||||
"version": "2.0.0-next.50"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support tool invention."],
|
||||
"fixes": ["Update lost i18n files."]
|
||||
},
|
||||
"date": "2025-11-13",
|
||||
"version": "2.0.0-next.49"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-12",
|
||||
"version": "2.0.0-next.48"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix mcp server return image error."]
|
||||
},
|
||||
"date": "2025-11-11",
|
||||
"version": "2.0.0-next.47"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix thread display."]
|
||||
},
|
||||
"date": "2025-11-11",
|
||||
"version": "2.0.0-next.46"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Edge to node runtime."]
|
||||
},
|
||||
"date": "2025-11-10",
|
||||
"version": "2.0.0-next.45"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix reasoning issue with claude and Response API thinking."]
|
||||
},
|
||||
"date": "2025-11-10",
|
||||
"version": "2.0.0-next.44"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Abnormal animation of tokens."]
|
||||
},
|
||||
"date": "2025-11-09",
|
||||
"version": "2.0.0-next.43"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix missing messages when finish runtime."]
|
||||
},
|
||||
"date": "2025-11-09",
|
||||
"version": "2.0.0-next.42"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-09",
|
||||
"version": "2.0.0-next.41"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-08",
|
||||
"version": "2.0.0-next.40"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-08",
|
||||
"version": "2.0.0-next.39"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-08",
|
||||
"version": "2.0.0-next.38"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Don't include runtimeProvider in JWT for non-image operations."]
|
||||
},
|
||||
"date": "2025-11-07",
|
||||
"version": "2.0.0-next.37"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Refactor to use agent runtime as the generation core and support branch mode."]
|
||||
},
|
||||
"date": "2025-11-07",
|
||||
"version": "2.0.0-next.36"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Use react-router-dom change /chat page to spa mode."]
|
||||
},
|
||||
"date": "2025-11-07",
|
||||
"version": "2.0.0-next.35"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
"Add sorting functionality for disabled models and model providers with tooltip support."
|
||||
]
|
||||
},
|
||||
"date": "2025-11-07",
|
||||
"version": "2.0.0-next.34"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor message create name."],
|
||||
"fixes": ["Model name display in the assistant panel disappears."]
|
||||
},
|
||||
"date": "2025-11-06",
|
||||
"version": "2.0.0-next.33"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Should install new version after quit this instance."]
|
||||
},
|
||||
"date": "2025-11-05",
|
||||
"version": "2.0.0-next.32"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-05",
|
||||
"version": "2.0.0-next.31"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Enhance message router with service layer and comprehensive tests."]
|
||||
},
|
||||
"date": "2025-11-05",
|
||||
"version": "2.0.0-next.30"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor chat message model to speed up."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.29"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support install sreamable http mcp server on web."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.28"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor services to a more clean structure."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.27"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add settings (jsonb) column to ai_models table."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.26"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Display assistant message in group."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.25"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve lab style."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.24"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix send message."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.23"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.22"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix oidc auth timeout issue on the desktop."]
|
||||
},
|
||||
"date": "2025-11-04",
|
||||
"version": "2.0.0-next.21"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve oidc layout style."]
|
||||
},
|
||||
"date": "2025-11-03",
|
||||
"version": "2.0.0-next.20"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove NEXT_PUBLIC_SERVICE_MODE env and use server by default."]
|
||||
},
|
||||
"date": "2025-11-03",
|
||||
"version": "2.0.0-next.19"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve built-in client OIDC user flow."]
|
||||
},
|
||||
"date": "2025-11-03",
|
||||
"version": "2.0.0-next.18"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix regex ReDoS."]
|
||||
},
|
||||
"date": "2025-11-03",
|
||||
"version": "2.0.0-next.17"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove deperated code."]
|
||||
},
|
||||
"date": "2025-11-03",
|
||||
"version": "2.0.0-next.16"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-03",
|
||||
"version": "2.0.0-next.15"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove client service."]
|
||||
},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.14"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix image prompt form."]
|
||||
},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.13"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add padding to TopicList component."]
|
||||
},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.12"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Smoothed model descriptions in ko-KR locales."]
|
||||
},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.11"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.10"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Remove dalle builtin plugin."]
|
||||
},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.9"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-11-02",
|
||||
"version": "2.0.0-next.8"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Upgrade to Next 16."]
|
||||
},
|
||||
"date": "2025-11-01",
|
||||
"version": "2.0.0-next.7"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-10-31",
|
||||
"version": "2.0.0-next.6"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Migrating Firecrawl to v2."]
|
||||
},
|
||||
"date": "2025-10-31",
|
||||
"version": "2.0.0-next.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-10-31",
|
||||
"version": "2.0.0-next.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
"Add new bedrock model support, add pricing info for Azure GPT-5 series models."
|
||||
]
|
||||
},
|
||||
"date": "2025-10-30",
|
||||
"version": "2.0.0-next.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": [
|
||||
"Hide marketplace link from Plugin List when market disabled, OIDC error when connecting to self-host instance, only include input_fidelity parameter for gpt-image-1.."
|
||||
]
|
||||
},
|
||||
"date": "2025-10-30",
|
||||
"version": "2.0.0-next.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["2.0 next baseline."],
|
||||
"improvements": ["starting V2"]
|
||||
},
|
||||
"date": "2025-10-30",
|
||||
"version": "2.0.0-next.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["2.0 next init."]
|
||||
},
|
||||
"date": "2025-10-30",
|
||||
"version": "1.143.0-next.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Try 2.0 next."],
|
||||
"improvements": ["starting V2"]
|
||||
},
|
||||
"date": "2025-10-30",
|
||||
"version": "1.143.0-next.1"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-10-30",
|
||||
"version": "1.142.8"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-10-28",
|
||||
"version": "1.142.7"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-10-28",
|
||||
"version": "1.142.6"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add MiniMax-M2 model."]
|
||||
},
|
||||
"date": "2025-10-27",
|
||||
"version": "1.142.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
"Pre render ModelSwitchPanel, The error details of the connectivity check lead to a layout problem."
|
||||
]
|
||||
},
|
||||
"date": "2025-10-27",
|
||||
"version": "1.142.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
"Adjust modal setting form styles for improved layout and responsiveness, Unzip file when uploading in knowledge base [LOB-500]."
|
||||
]
|
||||
},
|
||||
"date": "2025-10-27",
|
||||
"version": "1.142.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve provider modal height when creating custom provider."]
|
||||
},
|
||||
"date": "2025-10-26",
|
||||
"version": "1.142.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-10-26",
|
||||
"version": "1.142.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Use env to control clerk allow origin feature."]
|
||||
},
|
||||
"date": "2025-10-24",
|
||||
"version": "1.142.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Loadmore not work & navbar not show in pwa."]
|
||||
},
|
||||
"date": "2025-10-23",
|
||||
"version": "1.141.10"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve local system tools render."]
|
||||
},
|
||||
"date": "2025-10-23",
|
||||
"version": "1.141.9"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improvement for Agent Team After Alpha Launch [LOB-517]."]
|
||||
},
|
||||
"date": "2025-10-23",
|
||||
"version": "1.141.8"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Allow removal of top_p and similar request parameters."]
|
||||
},
|
||||
"date": "2025-10-23",
|
||||
"version": "1.141.7"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-10-22",
|
||||
"version": "1.141.6"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Change discover page from RSC to SPA to improve performance."]
|
||||
},
|
||||
"date": "2025-10-22",
|
||||
"version": "1.141.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix model runtime cost calculate with CNY."]
|
||||
},
|
||||
"date": "2025-10-22",
|
||||
"version": "1.141.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-10-22",
|
||||
"version": "1.141.3"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.141.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor context engine."]
|
||||
},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.141.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add PDF export functionality to share modal."],
|
||||
"fixes": [
|
||||
"Ignore abort signal errors in TRPC client, slove when pwa user info have code cannot be viewed in full."
|
||||
],
|
||||
"improvements": [
|
||||
"Add knowledge base mansory layout [LOB-496], improve rich text link display."
|
||||
]
|
||||
},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.141.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add ComfyUI integration Phase1(RFC-128)."]
|
||||
},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.140.0"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.139.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Pass threadId to messages in sendMessageInServer."]
|
||||
},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.139.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Show message author in minimap."]
|
||||
},
|
||||
"date": "2025-10-21",
|
||||
"version": "1.139.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Solve when desktop the sider agent list too long."]
|
||||
},
|
||||
"date": "2025-10-20",
|
||||
"version": "1.139.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-10-20",
|
||||
"version": "1.139.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support image generation for siliconcloud."]
|
||||
},
|
||||
"date": "2025-10-19",
|
||||
"version": "1.139.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Refactor upload router into lambda and decide to remove it in V2."]
|
||||
},
|
||||
"date": "2025-10-18",
|
||||
"version": "1.138.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix response API tools calling issue."]
|
||||
},
|
||||
"date": "2025-10-18",
|
||||
"version": "1.138.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix topic fetch not correct in custom agent."]
|
||||
},
|
||||
"date": "2025-10-18",
|
||||
"version": "1.138.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve welcome message."]
|
||||
},
|
||||
"date": "2025-10-16",
|
||||
"version": "1.138.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Automatic topic creation switch does not work."]
|
||||
},
|
||||
"date": "2025-10-16",
|
||||
"version": "1.138.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support Group Chat, Mention, and Multi-Agent Orchestration with feature flag."]
|
||||
|
||||
@@ -17,6 +17,9 @@ LOBE_PORT=3210
|
||||
CASDOOR_PORT=8000
|
||||
MINIO_PORT=9000
|
||||
APP_URL=http://localhost:3210
|
||||
# INTERNAL_APP_URL is optional, used for server-to-server calls
|
||||
# to bypass CDN/proxy. If not set, defaults to APP_URL.
|
||||
# Example: INTERNAL_APP_URL=http://localhost:3210
|
||||
AUTH_URL=http://localhost:3210/api/auth
|
||||
|
||||
# Postgres related, which are the necessary environment variables for DB
|
||||
|
||||
@@ -230,7 +230,7 @@ services:
|
||||
otel-tracing-test:
|
||||
profiles:
|
||||
- otel-test
|
||||
image: ghcr.io/grafana/xk6-client-tracing:v0.0.7
|
||||
image: ghcr.io/grafana/xk6-client-tracing:v0.0.9
|
||||
container_name: lobe-otel-tracing-test
|
||||
network_mode: 'service:network-service'
|
||||
restart: always
|
||||
|
||||
@@ -151,7 +151,7 @@ services:
|
||||
otel-tracing-test:
|
||||
profiles:
|
||||
- otel-test
|
||||
image: ghcr.io/grafana/xk6-client-tracing:v0.0.7
|
||||
image: ghcr.io/grafana/xk6-client-tracing:v0.0.9
|
||||
container_name: lobe-otel-tracing-test
|
||||
network_mode: 'service:network-service'
|
||||
restart: always
|
||||
|
||||
@@ -149,7 +149,7 @@ services:
|
||||
otel-tracing-test:
|
||||
profiles:
|
||||
- otel-test
|
||||
image: ghcr.io/grafana/xk6-client-tracing:v0.0.7
|
||||
image: ghcr.io/grafana/xk6-client-tracing:v0.0.9
|
||||
container_name: lobe-otel-tracing-test
|
||||
network_mode: 'service:network-service'
|
||||
restart: always
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,998 @@
|
||||
---
|
||||
title: ComfyUI 扩展开发指南
|
||||
description: 学习如何为 LobeChat ComfyUI 集成添加新模型、工作流和功能扩展
|
||||
tags:
|
||||
- ComfyUI
|
||||
- 开发指南
|
||||
- 模型扩展
|
||||
- 工作流开发
|
||||
---
|
||||
|
||||
# ComfyUI 扩展开发指南
|
||||
|
||||
本指南基于实际代码实现,帮助开发者扩展 LobeChat 的 ComfyUI 集成功能。
|
||||
|
||||
## 架构概览
|
||||
|
||||
LobeChat ComfyUI 集成采用四层服务架构,围绕 `LobeComfyUI` 主类构建:
|
||||
|
||||
```plaintext
|
||||
packages/model-runtime/src/providers/comfyui/
|
||||
├── index.ts # LobeComfyUI 主类入口
|
||||
├── services/ # 四大核心服务
|
||||
│ ├── comfyuiClient.ts # ComfyUIClientService - 客户端和认证
|
||||
│ ├── modelResolver.ts # ModelResolverService - 模型解析
|
||||
│ ├── workflowBuilder.ts # WorkflowBuilderService - 工作流构建
|
||||
│ └── imageService.ts # ImageService - 图像生成
|
||||
├── config/ # 配置系统
|
||||
│ ├── modelRegistry.ts # 主模型注册表(222个模型)
|
||||
│ ├── fluxModelRegistry.ts # 130个FLUX模型配置
|
||||
│ ├── sdModelRegistry.ts # 92个SD系列模型配置
|
||||
│ ├── systemComponents.ts # VAE/CLIP/T5/LoRA/ControlNet组件
|
||||
│ └── workflowRegistry.ts # 工作流路由配置
|
||||
├── workflows/ # 工作流实现
|
||||
│ ├── flux-dev.ts # FLUX Dev 20步工作流
|
||||
│ ├── flux-schnell.ts # FLUX Schnell 4步快速工作流
|
||||
│ ├── flux-kontext.ts # FLUX Kontext 填充工作流
|
||||
│ ├── sd35.ts # SD3.5 外部编码器工作流
|
||||
│ ├── simple-sd.ts # 通用SD工作流
|
||||
│ └── index.ts # 工作流导出
|
||||
├── utils/ # 工具层
|
||||
│ ├── staticModelLookup.ts # 模型查找函数
|
||||
│ ├── workflowDetector.ts # 模型架构检测
|
||||
│ ├── promptSplitter.ts # FLUX双提示词分割
|
||||
│ ├── seedGenerator.ts # 随机种子生成
|
||||
│ ├── cacheManager.ts # TTL缓存管理
|
||||
│ └── workflowUtils.ts # 工作流工具函数
|
||||
└── errors/ # 错误处理
|
||||
├── base.ts # 基础错误类
|
||||
├── modelResolverError.ts # 模型解析错误
|
||||
├── workflowError.ts # 工作流错误
|
||||
└── servicesError.ts # 服务错误
|
||||
|
||||
src/server/services/comfyui/ # 服务端实现
|
||||
├── core/ # 核心服务器服务
|
||||
│ ├── comfyUIAuthService.ts # 认证服务
|
||||
│ ├── comfyUIClientService.ts # 客户端服务
|
||||
│ ├── comfyUIConnectionService.ts # 连接服务
|
||||
│ ├── errorHandlerService.ts # 错误处理服务
|
||||
│ ├── imageService.ts # 图像生成服务
|
||||
│ ├── modelResolverService.ts # 模型解析服务
|
||||
│ └── workflowBuilderService.ts # 工作流构建服务
|
||||
├── config/ # 服务器端配置
|
||||
│ ├── constants.ts # 常量和默认值
|
||||
│ ├── modelRegistry.ts # 模型注册表
|
||||
│ ├── fluxModelRegistry.ts # FLUX模型
|
||||
│ ├── sdModelRegistry.ts # SD模型
|
||||
│ ├── systemComponents.ts # 系统组件
|
||||
│ └── workflowRegistry.ts # 工作流注册表
|
||||
├── workflows/ # 服务端工作流实现
|
||||
│ ├── flux-dev.ts # FLUX Dev 工作流
|
||||
│ ├── flux-schnell.ts # FLUX Schnell 工作流
|
||||
│ ├── flux-kontext.ts # FLUX Kontext 工作流
|
||||
│ ├── sd35.ts # SD3.5 工作流
|
||||
│ └── simple-sd.ts # Simple SD 工作流
|
||||
├── utils/ # 服务器工具
|
||||
│ ├── cacheManager.ts # 缓存管理
|
||||
│ ├── componentInfo.ts # 组件信息
|
||||
│ ├── imageResizer.ts # 图像调整
|
||||
│ ├── promptSplitter.ts # 提示词分割
|
||||
│ ├── staticModelLookup.ts # 模型查找
|
||||
│ ├── weightDType.ts # 权重数据类型工具
|
||||
│ ├── workflowDetector.ts # 工作流检测
|
||||
│ └── workflowUtils.ts # 工作流工具
|
||||
└── errors/ # 服务器错误处理
|
||||
├── base.ts # 基础错误类
|
||||
├── configError.ts # 配置错误
|
||||
├── modelResolverError.ts # 模型解析器错误
|
||||
├── servicesError.ts # 服务错误
|
||||
├── utilsError.ts # 工具错误
|
||||
└── workflowError.ts # 工作流错误
|
||||
|
||||
packages/model-runtime/src/utils/ # 共享工具
|
||||
└── comfyuiErrorParser.ts # 客户端/服务器统一错误解析器
|
||||
```
|
||||
|
||||
### 核心服务架构
|
||||
|
||||
`LobeComfyUI` 主类初始化四个核心服务:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/index.ts
|
||||
export class LobeComfyUI implements LobeRuntimeAI, AuthenticatedImageRuntime {
|
||||
constructor(options: ComfyUIKeyVault = {}) {
|
||||
// 1. 客户端服务 - 处理认证和API调用
|
||||
this.clientService = new ComfyUIClientService(options);
|
||||
|
||||
// 2. 模型解析服务 - 模型查找和组件选择
|
||||
const modelResolverService = new ModelResolverService(this.clientService);
|
||||
|
||||
// 3. 工作流构建服务 - 路由和构建工作流
|
||||
const workflowBuilderService = new WorkflowBuilderService({
|
||||
clientService: this.clientService,
|
||||
modelResolverService: modelResolverService,
|
||||
});
|
||||
|
||||
// 4. 图像服务 - 统一的图像生成入口
|
||||
this.imageService = new ImageService(
|
||||
this.clientService,
|
||||
modelResolverService,
|
||||
workflowBuilderService,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证系统
|
||||
|
||||
ComfyUI 集成支持四种认证方式,由 `ComfyUIClientService` 内的 `AuthManager` 处理:
|
||||
|
||||
### 支持的认证类型
|
||||
|
||||
```typescript
|
||||
interface ComfyUIKeyVault {
|
||||
baseURL: string;
|
||||
authType?: 'none' | 'basic' | 'bearer' | 'custom';
|
||||
// Basic Auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
// Bearer Token
|
||||
apiKey?: string;
|
||||
// Custom Headers
|
||||
customHeaders?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
### 认证配置示例
|
||||
|
||||
```typescript
|
||||
// 无认证
|
||||
const comfyUI = new LobeComfyUI({
|
||||
baseURL: 'http://localhost:8000',
|
||||
authType: 'none'
|
||||
});
|
||||
|
||||
// 基础认证
|
||||
const comfyUI = new LobeComfyUI({
|
||||
baseURL: 'https://your-comfyui-server.com',
|
||||
authType: 'basic',
|
||||
username: 'your-username',
|
||||
password: 'your-password'
|
||||
});
|
||||
|
||||
// Bearer Token
|
||||
const comfyUI = new LobeComfyUI({
|
||||
baseURL: 'https://your-comfyui-server.com',
|
||||
authType: 'bearer',
|
||||
apiKey: 'your-api-key'
|
||||
});
|
||||
|
||||
// 自定义头部
|
||||
const comfyUI = new LobeComfyUI({
|
||||
baseURL: 'https://your-comfyui-server.com',
|
||||
authType: 'custom',
|
||||
customHeaders: {
|
||||
'X-API-Key': 'your-custom-key',
|
||||
'Authorization': 'Custom your-token'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## WebAPI 路由
|
||||
|
||||
ComfyUI 提供了用于图像生成的 REST WebAPI 路由,支持常规认证和内部服务认证:
|
||||
|
||||
### 路由详情
|
||||
|
||||
```typescript
|
||||
// src/app/(backend)/webapi/create-image/comfyui/route.ts
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 300; // 最长5分钟
|
||||
|
||||
// POST /api/create-image/comfyui
|
||||
{
|
||||
model: string; // 模型标识符
|
||||
params: { // 生成参数
|
||||
prompt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// ... 其他参数
|
||||
};
|
||||
options?: { // 可选生成选项
|
||||
// ... 额外选项
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 认证中间件
|
||||
|
||||
WebAPI 路由使用 `checkAuth` 中间件进行认证:
|
||||
|
||||
```typescript
|
||||
import { checkAuth } from '@/app/(backend)/middleware/auth';
|
||||
|
||||
// 路由自动验证 JWT 令牌
|
||||
// 并将认证上下文传递给 tRPC 调用器
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
WebAPI 路由提供结构化的错误响应:
|
||||
|
||||
```typescript
|
||||
// 从 TRPCError 的 cause 中提取 AgentRuntimeError
|
||||
if (agentError && 'errorType' in agentError) {
|
||||
// 将 errorType 转换为适当的 HTTP 状态码
|
||||
// 401 对应 InvalidProviderAPIKey
|
||||
// 403 对应 PermissionDenied
|
||||
// 404 对应 NotFound
|
||||
// 500+ 对应服务器错误
|
||||
}
|
||||
```
|
||||
|
||||
## 添加新模型
|
||||
|
||||
### 1. 理解模型注册表结构
|
||||
|
||||
模型配置存储在配置文件中:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/config/modelRegistry.ts
|
||||
export interface ModelConfig {
|
||||
modelFamily: 'FLUX' | 'SD1' | 'SDXL' | 'SD3';
|
||||
priority: number; // 1=官方, 2=企业, 3=社区
|
||||
recommendedDtype?: 'default' | 'fp8_e4m3fn' | 'fp8_e5m2';
|
||||
variant: string; // 模型变体标识符
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 添加 FLUX 模型
|
||||
|
||||
在 `fluxModelRegistry.ts` 中添加新模型:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/config/fluxModelRegistry.ts
|
||||
export const FLUX_MODEL_REGISTRY: Record<string, ModelConfig> = {
|
||||
// 现有模型...
|
||||
|
||||
// 添加新的FLUX Dev模型
|
||||
'your-custom-flux-dev.safetensors': {
|
||||
modelFamily: 'FLUX',
|
||||
priority: 2, // 企业级模型
|
||||
variant: 'dev',
|
||||
recommendedDtype: 'default',
|
||||
},
|
||||
|
||||
// 添加量化版本
|
||||
'your-custom-flux-dev-fp8.safetensors': {
|
||||
modelFamily: 'FLUX',
|
||||
priority: 2,
|
||||
variant: 'dev',
|
||||
recommendedDtype: 'fp8_e4m3fn',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 添加 SD 系列模型
|
||||
|
||||
在 `sdModelRegistry.ts` 中添加:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/config/sdModelRegistry.ts
|
||||
export const SD_MODEL_REGISTRY: Record<string, ModelConfig> = {
|
||||
// 现有模型...
|
||||
|
||||
// 添加新的SD3.5模型
|
||||
'your-custom-sd35.safetensors': {
|
||||
modelFamily: 'SD3',
|
||||
priority: 2,
|
||||
variant: 'sd35',
|
||||
recommendedDtype: 'default',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 更新模型 ID 映射(可选)
|
||||
|
||||
如果需要为前端提供友好的模型 ID,在 `modelRegistry.ts` 中添加映射:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/config/modelRegistry.ts
|
||||
export const MODEL_ID_VARIANT_MAP: Record<string, string> = {
|
||||
// 现有映射...
|
||||
|
||||
// 添加新模型的友好ID
|
||||
'my-custom-flux': 'dev', // 映射到dev变体
|
||||
'my-custom-sd35': 'sd35', // 映射到sd35变体
|
||||
};
|
||||
```
|
||||
|
||||
## 创建新工作流
|
||||
|
||||
### 工作流创建原理
|
||||
|
||||
**重要:工作流节点结构来自 ComfyUI 原生导出**
|
||||
|
||||
1. 在 ComfyUI 界面中设计工作流
|
||||
2. 使用 "Export (API Format)" 导出 JSON
|
||||
3. 将 JSON 结构复制到 TypeScript 文件
|
||||
4. 使用`PromptBuilder`包装并参数化
|
||||
|
||||
### 1. 从 ComfyUI 导出工作流
|
||||
|
||||
在 ComfyUI 界面中:
|
||||
|
||||
1. 拖拽节点构建所需工作流
|
||||
2. 连接各节点的输入输出
|
||||
3. 右键点击空白处 → "Export (API Format)"
|
||||
4. 复制生成的 JSON 结构
|
||||
|
||||
### 2. 工作流文件模板
|
||||
|
||||
创建新文件 `workflows/your-workflow.ts`:
|
||||
|
||||
```typescript
|
||||
import { PromptBuilder } from '@saintno/comfyui-sdk';
|
||||
|
||||
import type { WorkflowContext } from '../services/workflowBuilder';
|
||||
import { generateUniqueSeeds } from '../utils/seedGenerator';
|
||||
import { getWorkflowFilenamePrefix } from '../utils/workflowUtils';
|
||||
|
||||
/**
|
||||
* 构建自定义工作流
|
||||
* @param modelFileName - 模型文件名
|
||||
* @param params - 生成参数
|
||||
* @param context - 工作流上下文
|
||||
*/
|
||||
export async function buildYourCustomWorkflow(
|
||||
modelFileName: string,
|
||||
params: Record<string, any>,
|
||||
context: WorkflowContext,
|
||||
): Promise<PromptBuilder<any, any, any>> {
|
||||
|
||||
// 从ComfyUI "Export (API Format)" 获得的JSON结构
|
||||
const workflow = {
|
||||
'1': {
|
||||
_meta: { title: 'Load Checkpoint' },
|
||||
class_type: 'CheckpointLoaderSimple',
|
||||
inputs: {
|
||||
ckpt_name: modelFileName,
|
||||
},
|
||||
},
|
||||
'2': {
|
||||
_meta: { title: 'CLIP Text Encode' },
|
||||
class_type: 'CLIPTextEncode',
|
||||
inputs: {
|
||||
clip: ['1', 1], // 连接到节点1的CLIP输出
|
||||
text: params.prompt,
|
||||
},
|
||||
},
|
||||
'3': {
|
||||
_meta: { title: 'Empty Latent' },
|
||||
class_type: 'EmptyLatentImage',
|
||||
inputs: {
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
batch_size: 1,
|
||||
},
|
||||
},
|
||||
'4': {
|
||||
_meta: { title: 'KSampler' },
|
||||
class_type: 'KSampler',
|
||||
inputs: {
|
||||
model: ['1', 0], // 连接到节点1的MODEL输出
|
||||
positive: ['2', 0], // 连接到节点2的CONDITIONING输出
|
||||
negative: ['2', 0], // 可以配置负面提示词
|
||||
latent_image: ['3', 0],
|
||||
seed: params.seed ?? generateUniqueSeeds(1)[0],
|
||||
steps: params.steps,
|
||||
cfg: params.cfg,
|
||||
sampler_name: 'euler',
|
||||
scheduler: 'normal',
|
||||
denoise: 1.0,
|
||||
},
|
||||
},
|
||||
'5': {
|
||||
_meta: { title: 'VAE Decode' },
|
||||
class_type: 'VAEDecode',
|
||||
inputs: {
|
||||
samples: ['4', 0],
|
||||
vae: ['1', 2], // 连接到节点1的VAE输出
|
||||
},
|
||||
},
|
||||
'6': {
|
||||
_meta: { title: 'Save Image' },
|
||||
class_type: 'SaveImage',
|
||||
inputs: {
|
||||
filename_prefix: getWorkflowFilenamePrefix('buildYourCustomWorkflow', context.variant),
|
||||
images: ['5', 0],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 使用PromptBuilder包装静态JSON
|
||||
const builder = new PromptBuilder(
|
||||
workflow,
|
||||
['width', 'height', 'steps', 'cfg', 'seed'], // 输入参数
|
||||
['images'], // 输出参数
|
||||
);
|
||||
|
||||
// 设置输出节点
|
||||
builder.setOutputNode('images', '6');
|
||||
|
||||
// 设置输入节点路径
|
||||
builder.setInputNode('width', '3.inputs.width');
|
||||
builder.setInputNode('height', '3.inputs.height');
|
||||
builder.setInputNode('steps', '4.inputs.steps');
|
||||
builder.setInputNode('cfg', '4.inputs.cfg');
|
||||
builder.setInputNode('seed', '4.inputs.seed');
|
||||
|
||||
// 设置参数值
|
||||
builder
|
||||
.input('width', params.width)
|
||||
.input('height', params.height)
|
||||
.input('steps', params.steps)
|
||||
.input('cfg', params.cfg)
|
||||
.input('seed', params.seed ?? generateUniqueSeeds(1)[0]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 注册新工作流
|
||||
|
||||
在 `workflowRegistry.ts` 中添加工作流映射:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/config/workflowRegistry.ts
|
||||
import { buildYourCustomWorkflow } from '../workflows/your-workflow';
|
||||
|
||||
export const VARIANT_WORKFLOW_MAP: Record<string, WorkflowBuilder> = {
|
||||
// 现有映射...
|
||||
|
||||
// 添加新工作流
|
||||
'your-variant': buildYourCustomWorkflow,
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 实际工作流示例
|
||||
|
||||
参考 `flux-dev.ts` 的真实实现:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/workflows/flux-dev.ts (简化版)
|
||||
export async function buildFluxDevWorkflow(
|
||||
modelFileName: string,
|
||||
params: Record<string, any>,
|
||||
context: WorkflowContext,
|
||||
): Promise<PromptBuilder<any, any, any>> {
|
||||
// 获取所需组件
|
||||
const selectedT5Model = await context.modelResolverService.getOptimalComponent('t5', 'FLUX');
|
||||
const selectedVAE = await context.modelResolverService.getOptimalComponent('vae', 'FLUX');
|
||||
const selectedCLIP = await context.modelResolverService.getOptimalComponent('clip', 'FLUX');
|
||||
|
||||
// 处理双提示词分割
|
||||
const { t5xxlPrompt, clipLPrompt } = splitPromptForDualCLIP(params.prompt);
|
||||
|
||||
// 静态工作流定义(来自ComfyUI导出)
|
||||
const workflow = {
|
||||
'1': {
|
||||
class_type: 'DualCLIPLoader',
|
||||
inputs: {
|
||||
clip_name1: selectedT5Model,
|
||||
clip_name2: selectedCLIP,
|
||||
type: 'flux',
|
||||
},
|
||||
},
|
||||
// ... 更多节点
|
||||
};
|
||||
|
||||
// 参数注入(必须在workflow文件内完成)
|
||||
workflow['5'].inputs.clip_l = clipLPrompt;
|
||||
workflow['5'].inputs.t5xxl = t5xxlPrompt;
|
||||
workflow['4'].inputs.width = params.width;
|
||||
workflow['4'].inputs.height = params.height;
|
||||
|
||||
// 创建并配置PromptBuilder
|
||||
const builder = new PromptBuilder(workflow, inputs, outputs);
|
||||
// 配置输入输出映射...
|
||||
|
||||
return builder;
|
||||
}
|
||||
```
|
||||
|
||||
## 系统组件管理
|
||||
|
||||
### 组件配置结构
|
||||
|
||||
所有系统组件(VAE、CLIP、T5、LoRA、ControlNet)统一配置在 `systemComponents.ts`:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/config/systemComponents.ts
|
||||
export interface ComponentConfig {
|
||||
modelFamily: string; // 模型家族
|
||||
priority: number; // 1=必需, 2=标准, 3=可选
|
||||
type: string; // 组件类型
|
||||
compatibleVariants?: string[]; // 兼容变体(LoRA/ControlNet)
|
||||
controlnetType?: string; // ControlNet类型
|
||||
}
|
||||
|
||||
export const SYSTEM_COMPONENTS: Record<string, ComponentConfig> = {
|
||||
// VAE组件
|
||||
'ae.safetensors': {
|
||||
modelFamily: 'FLUX',
|
||||
priority: 1,
|
||||
type: 'vae',
|
||||
},
|
||||
|
||||
// CLIP组件
|
||||
'clip_l.safetensors': {
|
||||
modelFamily: 'FLUX',
|
||||
priority: 1,
|
||||
type: 'clip',
|
||||
},
|
||||
|
||||
// T5编码器
|
||||
't5xxl_fp16.safetensors': {
|
||||
modelFamily: 'FLUX',
|
||||
priority: 1,
|
||||
type: 't5',
|
||||
},
|
||||
|
||||
// LoRA适配器
|
||||
'realism_lora.safetensors': {
|
||||
compatibleVariants: ['dev'],
|
||||
modelFamily: 'FLUX',
|
||||
priority: 1,
|
||||
type: 'lora',
|
||||
},
|
||||
|
||||
// ControlNet模型
|
||||
'flux-controlnet-canny-v3.safetensors': {
|
||||
compatibleVariants: ['dev'],
|
||||
controlnetType: 'canny',
|
||||
modelFamily: 'FLUX',
|
||||
priority: 1,
|
||||
type: 'controlnet',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 添加新组件
|
||||
|
||||
```typescript
|
||||
// 添加新的LoRA
|
||||
'your-custom-lora.safetensors': {
|
||||
compatibleVariants: ['dev', 'schnell'],
|
||||
modelFamily: 'FLUX',
|
||||
priority: 2,
|
||||
type: 'lora',
|
||||
},
|
||||
|
||||
// 添加新的ControlNet
|
||||
'your-controlnet-pose.safetensors': {
|
||||
compatibleVariants: ['dev'],
|
||||
controlnetType: 'pose',
|
||||
modelFamily: 'FLUX',
|
||||
priority: 2,
|
||||
type: 'controlnet',
|
||||
},
|
||||
```
|
||||
|
||||
### 组件查询 API
|
||||
|
||||
```typescript
|
||||
import { getAllComponentsWithNames, getOptimalComponent } from '../config/systemComponents';
|
||||
|
||||
// 获取最优组件
|
||||
const bestVAE = getOptimalComponent('vae', 'FLUX');
|
||||
const bestT5 = getOptimalComponent('t5', 'FLUX');
|
||||
|
||||
// 查询特定类型的组件
|
||||
const availableLoras = getAllComponentsWithNames({
|
||||
type: 'lora',
|
||||
modelFamily: 'FLUX',
|
||||
compatibleVariant: 'dev'
|
||||
});
|
||||
|
||||
// 查询ControlNet
|
||||
const cannyControlNets = getAllComponentsWithNames({
|
||||
type: 'controlnet',
|
||||
controlnetType: 'canny',
|
||||
modelFamily: 'FLUX'
|
||||
});
|
||||
```
|
||||
|
||||
## 模型解析和查找
|
||||
|
||||
### ModelResolverService 工作原理
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/services/modelResolver.ts
|
||||
export class ModelResolverService {
|
||||
async resolveModelFileName(modelId: string): Promise<string | undefined> {
|
||||
// 1. 清理模型ID
|
||||
const cleanId = modelId.replace(/^comfyui\//, '');
|
||||
|
||||
// 2. 检查模型ID映射
|
||||
const mappedVariant = MODEL_ID_VARIANT_MAP[cleanId];
|
||||
if (mappedVariant) {
|
||||
const prioritizedModels = getModelsByVariant(mappedVariant);
|
||||
const serverModels = await this.getAvailableModelFiles();
|
||||
|
||||
// 按优先级查找第一个可用模型
|
||||
for (const filename of prioritizedModels) {
|
||||
if (serverModels.includes(filename)) {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 直接注册表查找
|
||||
if (MODEL_REGISTRY[cleanId]) {
|
||||
return cleanId;
|
||||
}
|
||||
|
||||
// 4. 检查服务器文件存在性
|
||||
if (isModelFile(cleanId)) {
|
||||
const serverModels = await this.getAvailableModelFiles();
|
||||
if (serverModels.includes(cleanId)) {
|
||||
return cleanId;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模型查找示例
|
||||
|
||||
```typescript
|
||||
// 实际使用示例
|
||||
const resolver = new ModelResolverService(clientService);
|
||||
|
||||
// 友好ID查找
|
||||
const fluxDevFile = await resolver.resolveModelFileName('flux-dev');
|
||||
// 返回: 'flux1-dev.safetensors' (如果存在)
|
||||
|
||||
// 直接文件名查找
|
||||
const directFile = await resolver.resolveModelFileName('my-custom-model.safetensors');
|
||||
// 返回: 'my-custom-model.safetensors' (如果存在)
|
||||
|
||||
// 变体查找
|
||||
const devModels = getModelsByVariant('dev');
|
||||
console.log(devModels.slice(0, 3));
|
||||
// 输出: ['flux1-dev.safetensors', 'flux1-dev-fp8.safetensors', ...]
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误类型层次
|
||||
|
||||
```plaintext
|
||||
// packages/model-runtime/src/providers/comfyui/errors/
|
||||
ComfyUIInternalError // 基础错误
|
||||
├── ModelResolverError // 模型解析错误
|
||||
├── WorkflowError // 工作流错误
|
||||
├── ServicesError // 服务错误
|
||||
└── UtilsError // 工具错误
|
||||
```
|
||||
|
||||
### 错误处理示例
|
||||
|
||||
```typescript
|
||||
import { ModelResolverError, WorkflowError } from '../errors';
|
||||
|
||||
try {
|
||||
const result = await comfyUI.createImage({
|
||||
model: 'nonexistent-model',
|
||||
params: { prompt: '测试' }
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ModelResolverError) {
|
||||
console.log('模型解析失败:', error.message);
|
||||
console.log('错误原因:', error.reason);
|
||||
console.log('错误详情:', error.details);
|
||||
} else if (error instanceof WorkflowError) {
|
||||
console.log('工作流错误:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 统一错误解析器
|
||||
|
||||
客户端和服务器端错误处理可使用共享的错误解析器:
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/utils/comfyuiErrorParser.ts
|
||||
import { parseComfyUIErrorMessage, cleanComfyUIErrorMessage } from '../utils/comfyuiErrorParser';
|
||||
|
||||
// 解析错误消息并确定错误类型
|
||||
const { error, errorType } = parseComfyUIErrorMessage(rawError);
|
||||
|
||||
// 清理 ComfyUI 格式的错误消息
|
||||
const cleanMessage = cleanComfyUIErrorMessage(errorMessage);
|
||||
```
|
||||
|
||||
错误解析器处理:
|
||||
|
||||
- HTTP 状态码映射到错误类型
|
||||
- 服务器端错误增强
|
||||
- 模型文件缺失检测
|
||||
- 网络错误识别
|
||||
- 工作流验证错误
|
||||
|
||||
## 测试架构与开发
|
||||
|
||||
### 测试架构概述
|
||||
|
||||
ComfyUI 集成使用了统一的测试架构,确保测试的可维护性和定制友好性。该架构包括:
|
||||
|
||||
- **统一 Mock 系统**:集中管理所有外部依赖的模拟
|
||||
- **参数化测试**:自动适应新模型,无需修改现有测试
|
||||
- **夹具系统**:从配置文件中获取测试数据,确保准确性
|
||||
- **覆盖率目标**:ComfyUI 模块维持 97%+ 覆盖率
|
||||
|
||||
### 测试文件结构
|
||||
|
||||
```plaintext
|
||||
packages/model-runtime/src/providers/comfyui/__tests__/
|
||||
├── setup/
|
||||
│ └── unifiedMocks.ts # 统一Mock配置
|
||||
├── fixtures/
|
||||
│ ├── parameters.fixture.ts # 参数测试夹具
|
||||
│ └── workflow.fixture.ts # 工作流测试夹具
|
||||
├── integration/
|
||||
│ ├── parameterMapping.test.ts # 参数映射集成测试
|
||||
│ └── workflowBuilder.test.ts # 工作流构建测试
|
||||
├── services/ # 各服务单元测试
|
||||
└── workflows/ # 工作流单元测试
|
||||
```
|
||||
|
||||
### 添加新模型测试
|
||||
|
||||
当添加新模型时,测试会自动识别并运行相应的参数映射测试。你只需要:
|
||||
|
||||
#### 1. 在模型配置中添加参数架构
|
||||
|
||||
```typescript
|
||||
// packages/model-bank/src/aiModels/comfyui.ts
|
||||
export const myNewModelParamsSchema = {
|
||||
prompt: { type: 'string', required: true },
|
||||
steps: { type: 'number', default: 20, min: 1, max: 150 },
|
||||
cfg: { type: 'number', default: 7.0, min: 1.0, max: 30.0 }
|
||||
};
|
||||
```
|
||||
|
||||
#### 2. 创建工作流构建器
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/workflows/myNewModel.ts
|
||||
export async function buildMyNewModelWorkflow(
|
||||
modelName: string,
|
||||
params: MyNewModelParams,
|
||||
context: ComfyUIContext
|
||||
) {
|
||||
const workflow = { /* 工作流定义 */ };
|
||||
|
||||
// 参数注入
|
||||
workflow['1'].inputs.prompt = params.prompt;
|
||||
workflow['2'].inputs.steps = params.steps;
|
||||
|
||||
return workflow;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 在夹具中注册模型
|
||||
|
||||
```typescript
|
||||
// packages/model-runtime/src/providers/comfyui/__tests__/fixtures/parameters.fixture.ts
|
||||
import {
|
||||
myNewModelParamsSchema,
|
||||
// ... 其他架构
|
||||
} from '../../../../../model-bank/src/aiModels/comfyui';
|
||||
|
||||
export const parametersFixture = {
|
||||
models: {
|
||||
'my-new-model': {
|
||||
schema: myNewModelParamsSchema,
|
||||
defaults: {
|
||||
steps: myNewModelParamsSchema.steps.default,
|
||||
cfg: myNewModelParamsSchema.cfg.default,
|
||||
},
|
||||
boundaries: {
|
||||
min: { steps: myNewModelParamsSchema.steps.min },
|
||||
max: { steps: myNewModelParamsSchema.steps.max }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 测试最佳实践
|
||||
|
||||
#### 使用统一 Mock 系统
|
||||
|
||||
```typescript
|
||||
import { setupAllMocks } from '../setup/unifiedMocks';
|
||||
|
||||
describe('MyTest', () => {
|
||||
const mocks = setupAllMocks();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 编写参数映射测试
|
||||
|
||||
参数映射测试会自动运行,验证前端参数正确注入到工作流中:
|
||||
|
||||
```typescript
|
||||
// 测试会自动包含新注册的模型
|
||||
describe.each(
|
||||
Object.entries(models).filter(([name]) => workflowBuilders[name])
|
||||
)(
|
||||
'%s parameter mapping',
|
||||
(modelName, modelConfig) => {
|
||||
it('should map schema parameters to workflow', async () => {
|
||||
const params = {
|
||||
prompt: 'test prompt',
|
||||
...modelConfig.defaults,
|
||||
};
|
||||
|
||||
const workflow = await builder(`${modelName}.safetensors`, params, mockContext);
|
||||
expect(workflow).toBeDefined();
|
||||
});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
#### 定制友好的测试原则
|
||||
|
||||
- **不测试工作流结构**:工作流是 ComfyUI 官方格式,只测试参数映射
|
||||
- **使用配置驱动的数据**:测试数据来自模型配置文件,确保一致性
|
||||
- **避免脆性断言**:不检查具体的节点 ID 或内部结构
|
||||
- **支持扩展**:新增模型应该只影响覆盖率,不破坏现有测试
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行 ComfyUI 相关测试
|
||||
cd packages/model-runtime
|
||||
bunx vitest run --silent='passed-only' 'src/comfyui'
|
||||
|
||||
# 查看覆盖率
|
||||
bunx vitest run --coverage 'src/comfyui'
|
||||
|
||||
# 运行特定测试文件
|
||||
bunx vitest run 'src/comfyui/__tests__/integration/parameterMapping.test.ts'
|
||||
```
|
||||
|
||||
### 覆盖率目标
|
||||
|
||||
- **整体覆盖率**:ComfyUI 模块维持 97%+ 覆盖率
|
||||
- **核心功能**:100% 分支覆盖率
|
||||
- **新增功能**:保持或提升现有覆盖率水平
|
||||
|
||||
## 开发和测试
|
||||
|
||||
### 1. 本地开发设置
|
||||
|
||||
```bash
|
||||
# 启动ComfyUI调试模式
|
||||
DEBUG=lobe-image:* pnpm dev
|
||||
```
|
||||
|
||||
### 2. 测试新功能
|
||||
|
||||
```typescript
|
||||
// 创建测试文件
|
||||
import { buildYourCustomWorkflow } from './your-workflow';
|
||||
|
||||
describe('Custom Workflow', () => {
|
||||
test('should build workflow correctly', async () => {
|
||||
const mockContext = {
|
||||
clientService: mockClientService,
|
||||
modelResolverService: mockModelResolver,
|
||||
};
|
||||
|
||||
const workflow = await buildYourCustomWorkflow(
|
||||
'test-model.safetensors',
|
||||
{ prompt: '测试', width: 512, height: 512 },
|
||||
mockContext
|
||||
);
|
||||
|
||||
expect(workflow).toBeDefined();
|
||||
// 验证工作流结构...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 模型配置测试
|
||||
|
||||
```typescript
|
||||
import { getModelConfig, getAllModelNames } from '../config/modelRegistry';
|
||||
|
||||
describe('Model Registry', () => {
|
||||
test('should find new model', () => {
|
||||
const config = getModelConfig('your-new-model.safetensors');
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.variant).toBe('dev');
|
||||
expect(config?.modelFamily).toBe('FLUX');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 完整使用示例
|
||||
|
||||
### 基础图像生成
|
||||
|
||||
```typescript
|
||||
import { LobeComfyUI } from '@/libs/model-runtime/comfyui';
|
||||
|
||||
const comfyUI = new LobeComfyUI({
|
||||
baseURL: 'http://localhost:8000',
|
||||
authType: 'none'
|
||||
});
|
||||
|
||||
// FLUX Dev模型生成
|
||||
const result = await comfyUI.createImage({
|
||||
model: 'flux-dev',
|
||||
params: {
|
||||
prompt: '美丽的风景画,高质量,详细',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
steps: 20,
|
||||
cfg: 3.5,
|
||||
seed: -1
|
||||
}
|
||||
});
|
||||
|
||||
console.log('生成图像URL:', result.imageUrl);
|
||||
```
|
||||
|
||||
### SD3.5 模型使用
|
||||
|
||||
```typescript
|
||||
// SD3.5会自动检测可用编码器
|
||||
const sd35Result = await comfyUI.createImage({
|
||||
model: 'stable-diffusion-35',
|
||||
params: {
|
||||
prompt: '未来主义城市景观',
|
||||
width: 1344,
|
||||
height: 768,
|
||||
steps: 28,
|
||||
cfg: 4.5
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 企业优化模型
|
||||
|
||||
```typescript
|
||||
// 系统会自动选择最佳可用变体(如FP8量化版本)
|
||||
const optimizedResult = await comfyUI.createImage({
|
||||
model: 'flux-dev',
|
||||
params: {
|
||||
prompt: '专业商务肖像',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
steps: 15 // FP8模型可以用更少步数
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保 ComfyUI 服务正常运行并可访问
|
||||
- 检查所有必需的模型文件是否已正确安装
|
||||
- 注意模型文件的命名规范和路径配置
|
||||
- 定期检查和更新工作流配置以支持新功能
|
||||
- 注意不同模型系列的参数差异和兼容性
|
||||
- 添加新模型时,请遵循测试架构指南确保测试完整性
|
||||
- 在提交代码前务必运行相关测试确保覆盖率达标
|
||||
|
||||
通过遵循这些指南,开发者可以有效地在 LobeChat 中使用和扩展 ComfyUI 功能,为用户提供强大的图像生成和处理能力。
|
||||
@@ -11,13 +11,13 @@ But here is the easier approach that can reduce your pain.
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
First, copy the example environment file to create your development configuration:
|
||||
First, copy the example environment file to create your Docker Compose configuration:
|
||||
|
||||
```bash
|
||||
cp .env.example.development .env.development
|
||||
cp docker-compose/local/.env.example docker-compose/local/.env
|
||||
```
|
||||
|
||||
This file contains all necessary environment variables for server-side database mode and configures:
|
||||
Edit `docker-compose/local/.env` as needed for your development setup. This file contains all necessary environment variables for the Docker services and configures:
|
||||
|
||||
- **Service Mode**: `NEXT_PUBLIC_SERVICE_MODE=server`
|
||||
- **Database**: PostgreSQL with connection string
|
||||
@@ -72,7 +72,7 @@ When working with image generation features (text-to-image, image-to-image), the
|
||||
|
||||
### Image Generation Configuration
|
||||
|
||||
The existing Docker Compose configuration already includes MinIO storage service and all necessary environment variables in `.env.example.development`. No additional setup is required.
|
||||
The existing Docker Compose configuration already includes MinIO storage service and all necessary environment variables in `docker-compose/local/.env.example`. No additional setup is required.
|
||||
|
||||
### Image Generation Architecture
|
||||
|
||||
@@ -84,7 +84,7 @@ The image generation feature requires:
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
The `.env.example.development` file includes all necessary S3 environment variables:
|
||||
The `docker-compose/local/.env.example` file includes all necessary S3 environment variables:
|
||||
|
||||
```bash
|
||||
# S3 Storage Configuration (MinIO for local development)
|
||||
|
||||
@@ -11,13 +11,13 @@ LobeChat 提供了内置的客户端数据库体验。
|
||||
|
||||
### 环境配置
|
||||
|
||||
首先,复制示例环境文件来创建你的开发配置:
|
||||
首先,复制示例环境文件来创建你的 Docker Compose 配置:
|
||||
|
||||
```bash
|
||||
cp .env.example.development .env.development
|
||||
cp docker-compose/local/.env.example docker-compose/local/.env
|
||||
```
|
||||
|
||||
此文件包含服务端数据库模式所需的所有环境变量,配置了:
|
||||
根据需要编辑 `docker-compose/local/.env` 文件以适应你的开发设置。此文件包含 Docker 服务所需的所有环境变量,配置了:
|
||||
|
||||
- **服务模式**: `NEXT_PUBLIC_SERVICE_MODE=server`
|
||||
- **数据库**: 带连接字符串的 PostgreSQL
|
||||
@@ -72,7 +72,7 @@ docker-compose -f docker-compose.development.yml ps
|
||||
|
||||
### 图像生成配置
|
||||
|
||||
现有的 Docker Compose 配置已经包含了 MinIO 存储服务以及 `.env.example.development` 中的所有必要环境变量。无需额外配置。
|
||||
现有的 Docker Compose 配置已经包含了 MinIO 存储服务以及 `docker-compose/local/.env.example` 中的所有必要环境变量。无需额外配置。
|
||||
|
||||
### 图像生成架构
|
||||
|
||||
@@ -84,7 +84,7 @@ docker-compose -f docker-compose.development.yml ps
|
||||
|
||||
### 存储配置
|
||||
|
||||
`.env.example.development` 文件包含所有必要的 S3 环境变量:
|
||||
`docker-compose/local/.env.example` 文件包含所有必要的 S3 环境变量:
|
||||
|
||||
```bash
|
||||
# S3 存储配置(本地开发使用 MinIO)
|
||||
|
||||
@@ -6,6 +6,7 @@ table agents {
|
||||
tags jsonb [default: `[]`]
|
||||
avatar text
|
||||
background_color text
|
||||
market_identifier text
|
||||
plugins jsonb [default: `[]`]
|
||||
client_id text
|
||||
user_id text [not null]
|
||||
@@ -41,6 +42,7 @@ table agents_files {
|
||||
|
||||
indexes {
|
||||
(file_id, agent_id, user_id) [pk]
|
||||
agent_id [name: 'agents_files_agent_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ table agents_knowledge_bases {
|
||||
|
||||
indexes {
|
||||
(agent_id, knowledge_base_id) [pk]
|
||||
agent_id [name: 'agents_knowledge_bases_agent_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +78,7 @@ table ai_models {
|
||||
context_window_tokens integer
|
||||
source varchar(20)
|
||||
released_at varchar(10)
|
||||
settings jsonb [default: `{}`]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -193,6 +197,7 @@ table documents {
|
||||
file_id text
|
||||
user_id text [not null]
|
||||
client_id text
|
||||
editor_data jsonb
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -333,6 +338,7 @@ table message_groups {
|
||||
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'message_groups_client_id_user_id_unique', unique]
|
||||
topic_id [name: 'message_groups_topic_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +346,7 @@ table message_plugins {
|
||||
id text [pk, not null]
|
||||
tool_call_id text
|
||||
type text [default: 'default']
|
||||
intervention jsonb
|
||||
api_name text
|
||||
arguments text
|
||||
identifier text
|
||||
@@ -443,6 +450,7 @@ table messages {
|
||||
user_id [name: 'messages_user_id_idx']
|
||||
session_id [name: 'messages_session_id_idx']
|
||||
thread_id [name: 'messages_thread_id_idx']
|
||||
agent_id [name: 'messages_agent_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +812,8 @@ table agents_to_sessions {
|
||||
|
||||
indexes {
|
||||
(agent_id, session_id) [pk]
|
||||
session_id [name: 'agents_to_sessions_session_id_idx']
|
||||
agent_id [name: 'agents_to_sessions_agent_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,6 +874,8 @@ table sessions {
|
||||
(client_id, user_id) [name: 'sessions_client_id_user_id_unique', unique]
|
||||
user_id [name: 'sessions_user_id_idx']
|
||||
(id, user_id) [name: 'sessions_id_user_id_idx']
|
||||
(user_id, updated_at) [name: 'sessions_user_id_updated_at_idx']
|
||||
group_id [name: 'sessions_group_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,6 +896,7 @@ table threads {
|
||||
|
||||
indexes {
|
||||
(client_id, user_id) [name: 'threads_client_id_user_id_unique', unique]
|
||||
topic_id [name: 'threads_topic_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,6 +929,8 @@ table topics {
|
||||
(client_id, user_id) [name: 'topics_client_id_user_id_unique', unique]
|
||||
user_id [name: 'topics_user_id_idx']
|
||||
(id, user_id) [name: 'topics_id_user_id_idx']
|
||||
session_id [name: 'topics_session_id_idx']
|
||||
group_id [name: 'topics_group_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,6 +987,8 @@ table user_memories {
|
||||
memory_category varchar(255)
|
||||
memory_layer varchar(255)
|
||||
memory_type varchar(255)
|
||||
metadata jsonb
|
||||
tags text[]
|
||||
title varchar(255)
|
||||
summary text
|
||||
summary_vector_1024 vector(1024)
|
||||
@@ -992,9 +1009,10 @@ table user_memories {
|
||||
|
||||
table user_memories_contexts {
|
||||
id varchar(255) [pk, not null]
|
||||
user_id text
|
||||
user_memory_ids jsonb
|
||||
labels jsonb
|
||||
extracted_labels jsonb
|
||||
metadata jsonb
|
||||
tags text[]
|
||||
associated_objects jsonb
|
||||
associated_subjects jsonb
|
||||
title text
|
||||
@@ -1018,9 +1036,10 @@ table user_memories_contexts {
|
||||
|
||||
table user_memories_experiences {
|
||||
id varchar(255) [pk, not null]
|
||||
user_memory_id text
|
||||
labels jsonb
|
||||
extracted_labels jsonb
|
||||
user_id text
|
||||
user_memory_id varchar(255)
|
||||
metadata jsonb
|
||||
tags text[]
|
||||
type varchar(255)
|
||||
situation text
|
||||
situation_vector vector(1024)
|
||||
@@ -1030,7 +1049,6 @@ table user_memories_experiences {
|
||||
action_vector vector(1024)
|
||||
key_learning text
|
||||
key_learning_vector vector(1024)
|
||||
metadata jsonb
|
||||
score_confidence real [default: 0]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -1045,17 +1063,17 @@ table user_memories_experiences {
|
||||
}
|
||||
|
||||
table user_memories_identities {
|
||||
current_focuses text
|
||||
id varchar(255) [pk, not null]
|
||||
user_id text
|
||||
user_memory_id varchar(255)
|
||||
metadata jsonb
|
||||
tags text[]
|
||||
type varchar(255)
|
||||
description text
|
||||
description_vector vector(1024)
|
||||
experience text
|
||||
extracted_labels jsonb
|
||||
id varchar(255) [pk, not null]
|
||||
labels jsonb
|
||||
relationship text
|
||||
episodic_date "timestamp with time zone"
|
||||
relationship varchar(255)
|
||||
role text
|
||||
type varchar(255)
|
||||
user_memory_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
@@ -1068,11 +1086,10 @@ table user_memories_identities {
|
||||
|
||||
table user_memories_preferences {
|
||||
id varchar(255) [pk, not null]
|
||||
context_id varchar(255)
|
||||
user_id text
|
||||
user_memory_id varchar(255)
|
||||
labels jsonb
|
||||
extracted_labels jsonb
|
||||
extracted_scopes jsonb
|
||||
metadata jsonb
|
||||
tags text[]
|
||||
conclusion_directives text
|
||||
conclusion_directives_vector vector(1024)
|
||||
type varchar(255)
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# 集成测试指南
|
||||
|
||||
## 概述
|
||||
|
||||
集成测试验证多个模块协同工作的正确性,确保完整的调用链路(Router → Service → Model → Database)正常运行。
|
||||
|
||||
## 为什么需要集成测试?
|
||||
|
||||
即使单元测试覆盖率很高(80%+),仍可能出现集成问题:
|
||||
|
||||
### 常见问题示例
|
||||
|
||||
```typescript
|
||||
// ❌ 问题:参数在调用链中丢失
|
||||
// Router 层
|
||||
const messageId = await messageModel.create({
|
||||
content: 'test',
|
||||
sessionId: 'xxx',
|
||||
topicId: 'yyy', // ← 传入了 topicId
|
||||
});
|
||||
|
||||
// Model 层(假设实现有问题)
|
||||
async create(data) {
|
||||
return this.db.insert(messages).values({
|
||||
content: data.content,
|
||||
sessionId: data.sessionId,
|
||||
// ❌ 忘记传递 topicId
|
||||
});
|
||||
}
|
||||
|
||||
// 结果:单元测试通过(因为 mock 了 Model),但实际运行时 topicId 丢失
|
||||
```
|
||||
|
||||
### 集成测试能发现的问题
|
||||
|
||||
1. **参数传递遗漏**: containerId、threadId、topicId 等在调用链中丢失
|
||||
2. **数据库约束**: 外键关系、级联删除等在 mock 中无法验证
|
||||
3. **事务完整性**: 跨表操作的原子性
|
||||
4. **权限验证**: 跨用户访问控制
|
||||
5. **真实场景**: 模拟用户的完整操作流程
|
||||
|
||||
## 运行集成测试
|
||||
|
||||
```bash
|
||||
# 运行所有集成测试
|
||||
pnpm test:integration
|
||||
|
||||
# 运行特定文件
|
||||
pnpm vitest tests/integration/routers/message.integration.test.ts
|
||||
|
||||
# 监听模式
|
||||
pnpm vitest tests/integration --watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
pnpm test:integration --coverage
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/integration/
|
||||
├── README.md # 集成测试说明
|
||||
├── setup.ts # 通用设置和工具函数
|
||||
└── routers/ # Router 层集成测试
|
||||
├── message.integration.test.ts # Message Router 测试
|
||||
├── session.integration.test.ts # Session Router 测试
|
||||
├── topic.integration.test.ts # Topic Router 测试
|
||||
└── chat-flow.integration.test.ts # 完整聊天流程测试
|
||||
```
|
||||
|
||||
## 编写集成测试
|
||||
|
||||
### 基本模板
|
||||
|
||||
```typescript
|
||||
// @vitest-environment node
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '@/database/models/__tests__/_util';
|
||||
import { messages, sessions, users } from '@/database/schemas';
|
||||
import { LobeChatDatabase } from '@/database/type';
|
||||
import { messageRouter } from '@/server/routers/lambda/message';
|
||||
|
||||
import { cleanupTestUser, createTestContext, createTestUser } from '../setup';
|
||||
|
||||
describe('Your Feature Integration Tests', () => {
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 1. 获取测试数据库
|
||||
serverDB = await getTestDB();
|
||||
|
||||
// 2. 创建测试用户
|
||||
userId = await createTestUser(serverDB);
|
||||
|
||||
// 3. 准备其他测试数据
|
||||
// ...
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 清理测试数据
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
});
|
||||
|
||||
it('should do something', async () => {
|
||||
// 1. 创建 tRPC caller
|
||||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// 2. 执行操作
|
||||
const result = await caller.someMethod({
|
||||
/* params */
|
||||
});
|
||||
|
||||
// 3. 验证结果
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// 4. 🔥 关键:从数据库验证
|
||||
const [dbRecord] = await serverDB.select().from(messages).where(eq(messages.id, result));
|
||||
|
||||
expect(dbRecord).toMatchObject({
|
||||
// 验证所有关键字段
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 最佳实践
|
||||
|
||||
#### 1. 测试完整的调用链路
|
||||
|
||||
```typescript
|
||||
it('should create message with correct associations', async () => {
|
||||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// 执行操作
|
||||
const messageId = await caller.createMessage({
|
||||
content: 'Test',
|
||||
sessionId: testSessionId,
|
||||
topicId: testTopicId,
|
||||
});
|
||||
|
||||
// ✅ 从数据库验证,而不是只验证返回值
|
||||
const [message] = await serverDB.select().from(messages).where(eq(messages.id, messageId));
|
||||
|
||||
expect(message.sessionId).toBe(testSessionId);
|
||||
expect(message.topicId).toBe(testTopicId);
|
||||
expect(message.userId).toBe(userId);
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 测试级联操作
|
||||
|
||||
```typescript
|
||||
it('should cascade delete messages when session is deleted', async () => {
|
||||
const sessionCaller = sessionRouter.createCaller(createTestContext(userId));
|
||||
const messageCaller = messageRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// 创建 session 和 messages
|
||||
const sessionId = await sessionCaller.createSession({
|
||||
/* ... */
|
||||
});
|
||||
await messageCaller.createMessage({ sessionId /* ... */ });
|
||||
|
||||
// 删除 session
|
||||
await sessionCaller.removeSession({ id: sessionId });
|
||||
|
||||
// ✅ 验证相关消息也被删除
|
||||
const remainingMessages = await serverDB
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.sessionId, sessionId));
|
||||
|
||||
expect(remainingMessages).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 测试跨 Router 协作
|
||||
|
||||
```typescript
|
||||
it('should handle complete chat flow', async () => {
|
||||
const sessionCaller = sessionRouter.createCaller(createTestContext(userId));
|
||||
const topicCaller = topicRouter.createCaller(createTestContext(userId));
|
||||
const messageCaller = messageRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// 1. 创建 session
|
||||
const sessionId = await sessionCaller.createSession({
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// 2. 创建 topic
|
||||
const topicId = await topicCaller.createTopic({ sessionId /* ... */ });
|
||||
|
||||
// 3. 创建 message
|
||||
const messageId = await messageCaller.createMessage({
|
||||
sessionId,
|
||||
topicId,
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// ✅ 验证完整的关联关系
|
||||
const [message] = await serverDB.select().from(messages).where(eq(messages.id, messageId));
|
||||
|
||||
expect(message.sessionId).toBe(sessionId);
|
||||
expect(message.topicId).toBe(topicId);
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. 测试错误场景
|
||||
|
||||
```typescript
|
||||
it('should prevent cross-user access', async () => {
|
||||
// 用户 A 创建 session
|
||||
const sessionId = await sessionRouter.createCaller(createTestContext(userA)).createSession({
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// 用户 B 尝试访问
|
||||
const callerB = messageRouter.createCaller(createTestContext(userB));
|
||||
|
||||
// ✅ 应该抛出错误
|
||||
await expect(
|
||||
callerB.createMessage({
|
||||
sessionId,
|
||||
content: 'Unauthorized',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. 测试并发场景
|
||||
|
||||
```typescript
|
||||
it('should handle concurrent operations', async () => {
|
||||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// 并发创建多个消息
|
||||
const promises = Array.from({ length: 10 }, (_, i) =>
|
||||
caller.createMessage({
|
||||
content: `Message ${i}`,
|
||||
sessionId: testSessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
const messageIds = await Promise.all(promises);
|
||||
|
||||
// ✅ 验证所有消息都创建成功且唯一
|
||||
expect(messageIds).toHaveLength(10);
|
||||
expect(new Set(messageIds).size).toBe(10);
|
||||
});
|
||||
```
|
||||
|
||||
### 数据隔离
|
||||
|
||||
每个测试用例应该独立,不依赖其他测试:
|
||||
|
||||
```typescript
|
||||
beforeEach(async () => {
|
||||
// 为每个测试创建新的数据
|
||||
userId = await createTestUser(serverDB);
|
||||
testSessionId = await createTestSession(serverDB, userId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 清理测试数据
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
});
|
||||
```
|
||||
|
||||
### 测试命名
|
||||
|
||||
使用清晰的命名描述测试意图:
|
||||
|
||||
```typescript
|
||||
// ✅ 好的命名
|
||||
it('should create message with correct sessionId and topicId');
|
||||
it('should cascade delete messages when session is deleted');
|
||||
it('should prevent cross-user access to messages');
|
||||
|
||||
// ❌ 不好的命名
|
||||
it('test message creation');
|
||||
it('test delete');
|
||||
```
|
||||
|
||||
## 与单元测试的区别
|
||||
|
||||
| 维度 | 单元测试 | 集成测试 |
|
||||
| ------- | --------- | ------- |
|
||||
| **范围** | 单个函数 / 类 | 多个模块协作 |
|
||||
| **依赖** | Mock 外部依赖 | 使用真实依赖 |
|
||||
| **数据库** | Mock | 真实测试数据库 |
|
||||
| **速度** | 快(毫秒级) | 慢(秒级) |
|
||||
| **数量** | 多(60%) | 少(30%) |
|
||||
| **目的** | 验证逻辑正确性 | 验证集成正确性 |
|
||||
|
||||
## 测试金字塔
|
||||
|
||||
```
|
||||
/\
|
||||
/E2E\ ← 10% (关键业务流程)
|
||||
/------\
|
||||
/ 集成 \ ← 30% (API 集成测试) ⭐ 本指南重点
|
||||
/----------\
|
||||
/ 单元测试 \ ← 60% (已有 80%+)
|
||||
/--------------\
|
||||
```
|
||||
|
||||
## 覆盖目标
|
||||
|
||||
### 优先级 P0(必须覆盖)
|
||||
|
||||
- ✅ 跨层级的 ID 传递(sessionId、topicId、containerId、threadId)
|
||||
- ✅ 权限验证(用户只能访问自己的资源)
|
||||
- ✅ 级联删除(删除 session 时相关数据也删除)
|
||||
- ✅ 外键约束(不能创建不存在的关联)
|
||||
|
||||
### 优先级 P1(应该覆盖)
|
||||
|
||||
- 并发场景(多个请求同时操作)
|
||||
- 分页查询(正确的数据分页)
|
||||
- 搜索功能(关键词搜索)
|
||||
- 批量操作(批量创建 / 删除)
|
||||
|
||||
### 优先级 P2(可以覆盖)
|
||||
|
||||
- 统计功能(计数、排名)
|
||||
- 复杂查询(多条件筛选)
|
||||
- 性能测试(大量数据场景)
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. 查看测试数据库状态
|
||||
|
||||
```typescript
|
||||
it('debug test', async () => {
|
||||
// 执行操作
|
||||
await caller.createMessage({
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// 打印数据库状态
|
||||
const allMessages = await serverDB.select().from(messages);
|
||||
console.log('All messages:', allMessages);
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 使用 Drizzle Studio
|
||||
|
||||
```bash
|
||||
# 启动 Drizzle Studio 查看测试数据库
|
||||
pnpm db:studio
|
||||
```
|
||||
|
||||
### 3. 保留测试数据
|
||||
|
||||
```typescript
|
||||
afterEach(async () => {
|
||||
// 临时注释掉清理代码,保留数据用于调试
|
||||
// await cleanupTestUser(serverDB, userId);
|
||||
});
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 集成测试很慢怎么办?
|
||||
|
||||
A:
|
||||
|
||||
1. 只测试关键路径,不要过度测试
|
||||
2. 使用 `test.concurrent` 并行执行独立的测试
|
||||
3. 优化测试数据准备,避免重复创建
|
||||
|
||||
### Q: 测试之间相互影响怎么办?
|
||||
|
||||
A:
|
||||
|
||||
1. 确保每个测试使用独立的 userId
|
||||
2. 在 `afterEach` 中彻底清理数据
|
||||
3. 使用事务隔离(如果数据库支持)
|
||||
|
||||
### Q: 如何测试需要认证的 API?
|
||||
|
||||
A: 使用 `createTestContext(userId)` 创建带认证信息的上下文:
|
||||
|
||||
```typescript
|
||||
const caller = messageRouter.createCaller(createTestContext(userId));
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Vitest 文档](https://vitest.dev/)
|
||||
- [Drizzle ORM 文档](https://orm.drizzle.team/)
|
||||
- [tRPC 测试指南](https://trpc.io/docs/server/testing)
|
||||
- [测试金字塔](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎补充更多集成测试用例!请参考现有测试文件的风格。
|
||||
@@ -26,12 +26,13 @@ By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CL
|
||||
|
||||
Before using NextAuth, please set the following variables in LobeChat's environment variables:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | Required | This is used to enable the NextAuth service. Set it to `1` to enable it; changing this setting requires recompiling the application. Users deploying with the `lobehub/lobe-chat-database` image have this configuration added by default. |
|
||||
| `NEXT_AUTH_SECRET` | Required | The key used to encrypt Auth.js session tokens. You can use the following command: `openssl rand -base64 32`, or visit `https://generate-secret.vercel.app/32` to generate the key. |
|
||||
| `NEXTAUTH_URL` | Required | This URL specifies the callback address for Auth.js when performing OAuth verification. Set this only if the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Optional | This environment variable is used to enable multiple identity verification sources simultaneously, separated by commas, for example, `auth0,microsoft-entra-id,authentik`. |
|
||||
| Environment Variable | Type | Description |
|
||||
| -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | Required | This is used to enable the NextAuth service. Set it to `1` to enable it; changing this setting requires recompiling the application. Users deploying with the `lobehub/lobe-chat-database` image have this configuration added by default. |
|
||||
| `AUTH_SECRET` | Required | The key used to encrypt Auth.js session tokens. You can use the following command: `openssl rand -base64 32`, or visit `https://generate-secret.vercel.app/32` to generate the key. |
|
||||
| `AUTH_URL` | Required | This URL specifies the callback address for Auth.js when performing OAuth verification. Set this only if the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Optional | This environment variable is used to enable multiple identity verification sources simultaneously, separated by commas, for example, `auth0,microsoft-entra-id,authentik`. |
|
||||
| `NEXT_AUTH_SSO_SESSION_STRATEGY` | Optional | The session strategy for Auth.js. Options are `jwt` or `database`. Default is `jwt`. |
|
||||
|
||||
Currently supported identity verification services include:
|
||||
|
||||
@@ -67,21 +68,31 @@ To simultaneously enable multiple identity verification sources, please set the
|
||||
|
||||
The order corresponds to the display order of the SSO providers.
|
||||
|
||||
| SSO Provider | Value |
|
||||
| --------------------- | ----------------------- |
|
||||
| Auth0 | `auth0` |
|
||||
| Authenlia | `authenlia` |
|
||||
| Authentik | `authentik` |
|
||||
| Casdoor | `casdoor` |
|
||||
| Cloudflare Zero Trust | `cloudflare-zero-trust` |
|
||||
| Github | `github` |
|
||||
| Logto | `logto` |
|
||||
| Microsoft Entra ID | `microsoft-entra-id` |
|
||||
| ZITADEL | `zitadel` |
|
||||
| Keycloak | `keycloak` |
|
||||
| Google | `google` |
|
||||
| Okta | `okta` |
|
||||
| SSO Provider | Value | Additional Features |
|
||||
| --------------------- | ----------------------- | ------------------- |
|
||||
| Auth0 | `auth0` | |
|
||||
| Authenlia | `authenlia` | |
|
||||
| Authentik | `authentik` | |
|
||||
| Casdoor | `casdoor` | `Webhook` |
|
||||
| Cloudflare Zero Trust | `cloudflare-zero-trust` | |
|
||||
| Github | `github` | |
|
||||
| Logto | `logto` | `Webhook` |
|
||||
| Microsoft Entra ID | `microsoft-entra-id` | |
|
||||
| ZITADEL | `zitadel` | |
|
||||
| Keycloak | `keycloak` | |
|
||||
| Google | `google` | |
|
||||
| Okta | `okta` | |
|
||||
|
||||
## Additional Features
|
||||
|
||||
### Webhook Support
|
||||
|
||||
Allow LobeChat to receive notifications when user information is updated in the identity provider. Supported providers include Casdoor and Logto. Please refer to the specific provider documentation for configuration details.
|
||||
|
||||
### Database Session
|
||||
|
||||
Allow the session store in database, see also the [Auth.js Session Documentation](https://authjs.dev/concepts/session-strategies#database-session).
|
||||
|
||||
## Other SSO Providers
|
||||
|
||||
Please refer to the [NextAuth.js](https://next-auth.js.org/providers) documentation and feel free to submit a Pull Request.
|
||||
Please refer to the [Auth.js](https://authjs.dev/getting-started/authentication/oauth) documentation and feel free to submit a Pull Request.
|
||||
|
||||
@@ -24,12 +24,13 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
|
||||
|
||||
在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------------ | -- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | 必选 | 用于启用 NextAuth 服务,设置为 `1` 以启用,更改此项需要重新编译应用。使用 `lobehub/lobe-chat-database` 镜像部署的用户已经默认添加了该项配置。 |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令: `openssl rand -base64 32`,或者访问 `https://generate-secret.vercel.app/32` 生成秘钥。 |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 可选 | 该环境变量用于同时启用多个身份验证源,以逗号 `,` 分割,例如 `auth0,microsoft-entra-id,authentik`。 |
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| -------------------------------- | -- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | 必选 | 用于启用 NextAuth 服务,设置为 `1` 以启用,更改此项需要重新编译应用。使用 `lobehub/lobe-chat-database` 镜像部署的用户已经默认添加了该项配置。 |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令: `openssl rand -base64 32`,或者访问 `https://generate-secret.vercel.app/32` 生成秘钥。 |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 可选 | 该环境变量用于同时启用多个身份验证源,以逗号 `,` 分割,例如 `auth0,microsoft-entra-id,authentik`。 |
|
||||
| `NEXT_AUTH_SSO_SESSION_STRATEGY` | 可选 | Auth.js 的会话策略。选项为 `jwt` 或 `database`。默认值为 `jwt`。 |
|
||||
|
||||
目前支持的身份验证服务有:
|
||||
|
||||
@@ -63,19 +64,29 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
|
||||
|
||||
顺序为 SSO 提供商的显示顺序。
|
||||
|
||||
| SSO 提供商 | 值 |
|
||||
| --------------------- | ----------------------- |
|
||||
| Auth0 | `auth0` |
|
||||
| Authenlia | `authenlia` |
|
||||
| Authentik | `authentik` |
|
||||
| Casdoor | `casdoor` |
|
||||
| Cloudflare Zero Trust | `cloudflare-zero-trust` |
|
||||
| Github | `github` |
|
||||
| Logto | `logto` |
|
||||
| Microsoft Entra ID | `microsoft-entra-id` |
|
||||
| ZITADEL | `zitadel` |
|
||||
| Keycloak | `keycloak` |
|
||||
| Okta | `okta` |
|
||||
| SSO 提供商 | 值 | 额外功能 |
|
||||
| --------------------- | ----------------------- | --------- |
|
||||
| Auth0 | `auth0` | |
|
||||
| Authenlia | `authenlia` | |
|
||||
| Authentik | `authentik` | |
|
||||
| Casdoor | `casdoor` | `Webhook` |
|
||||
| Cloudflare Zero Trust | `cloudflare-zero-trust` | |
|
||||
| Github | `github` | |
|
||||
| Logto | `logto` | `Webhook` |
|
||||
| Microsoft Entra ID | `microsoft-entra-id` | |
|
||||
| ZITADEL | `zitadel` | |
|
||||
| Keycloak | `keycloak` | |
|
||||
| Okta | `okta` | |
|
||||
|
||||
## 额外功能
|
||||
|
||||
### Webhook 支持
|
||||
|
||||
允许 LobeChat 在身份提供商中用户信息更新时接收通知。支持的提供商包括 Casdoor 和 Logto。请参考具体提供商文档进行配置。
|
||||
|
||||
### 数据库会话
|
||||
|
||||
允许会话存储在数据库中,详情请参阅 [Auth.js 会话文档](https://authjs.dev/concepts/session-strategies#database-session)。
|
||||
|
||||
## 其他 SSO 提供商
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LoboChat. Use `auth0` for Auth0. |
|
||||
| `AUTH_AUTH0_ID` | Required | Client ID of the Auth0 application |
|
||||
| `AUTH_AUTH0_SECRET` | Required | Client Secret of the Auth0 application |
|
||||
| `AUTH_AUTH0_ISSUER` | Required | Domain of the Auth0 application, `https://example.auth0.com` |
|
||||
| `NEXTAUTH_URL` | Required | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
You can refer to the related variable details at [📘Environment Variables](/docs/self-hosting/environment-variable#auth0).
|
||||
|
||||
@@ -51,12 +51,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Auth0 请填写 `auth0`。 |
|
||||
| `AUTH_AUTH0_ID` | 必选 | Auth0 应用程序的 Client ID |
|
||||
| `AUTH_AUTH0_SECRET` | 必选 | Auth0 应用程序的 Client Secret |
|
||||
| `AUTH_AUTH0_ISSUER` | 必选 | Auth0 应用程序的 Domain,`https://example.auth0.com` |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#auth-0) 可查阅相关变量详情。
|
||||
|
||||
@@ -54,12 +54,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | The secret used to encrypt Auth.js session tokens. You can generate a secret using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | The secret used to encrypt Auth.js session tokens. You can generate a secret using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the SSO provider for LoboChat. Use `authentik` for Authentik. |
|
||||
| `AUTH_AUTHELIA_ID` | Required | The id just configured in Authelia, example value is lobe-chat |
|
||||
| `AUTH_AUTHELIA_SECRET` | Required | The plaintext corresponding to the secret just configured in Authelia, example value is `insecure_secret` |
|
||||
| `AUTH_AUTHELIA_ISSUER` | Required | Your Authelia URL, for example [https://sso.example.com](https://sso.example.com) |
|
||||
| `NEXTAUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth verification. It only needs to be set when the default generated redirect address is incorrect. [https://chat.example.com/api/auth](https://chat.example.com/api/auth) |
|
||||
| `AUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth verification. It only needs to be set when the default generated redirect address is incorrect. [https://chat.example.com/api/auth](https://chat.example.com/api/auth) |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Go to [📘 Environment Variables](/docs/self-hosting/environment-variable#Authelia) for details about the variables.
|
||||
|
||||
@@ -53,12 +53,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Authelia 请填写 `authelia`。 |
|
||||
| `AUTH_AUTHELIA_ID` | 必选 | 刚刚在 Authelia 配置的 `id`,示例值是 `lobe-chat` |
|
||||
| `AUTH_AUTHELIA_SECRET` | 必选 | 刚刚在 Authelia 配置的 `secret` 对应的明文,示例值是 `insecure_secret` |
|
||||
| `AUTH_AUTHELIA_ISSUER` | 必选 | 您的 Authelia 的网址,例如 `https://sso.example.com` |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://chat.example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://chat.example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variable#Authelia) 可查阅相关变量详情。
|
||||
|
||||
@@ -49,12 +49,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | The secret used to encrypt Auth.js session tokens. You can generate a secret using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | The secret used to encrypt Auth.js session tokens. You can generate a secret using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the SSO provider for LoboChat. Use `authentik` for Authentik. |
|
||||
| `AUTH_AUTHENTIK_ID` | Required | The Client ID from the Authentik application provider details page |
|
||||
| `AUTH_AUTHENTIK_SECRET` | Required | The Client Secret from the Authentik application provider details page |
|
||||
| `AUTH_AUTHENTIK_ISSUER` | Required | The OpenID Configuration Issuer from the Authentik application provider details page |
|
||||
| `NEXTAUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. It only needs to be set when the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. It only needs to be set when the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Go to [📘 Environment Variables](/docs/self-hosting/environment-variable#Authentik) for details about the variables.
|
||||
|
||||
@@ -45,12 +45,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Authentik 请填写 `authentik`。 |
|
||||
| `AUTH_AUTHENTIK_ID` | 必选 | Authentik 提供程序详情页的 客户端 ID |
|
||||
| `AUTH_AUTHENTIK_SECRET` | 必选 | Authentik 提供程序详情页的 客户端 Secret |
|
||||
| `AUTH_AUTHENTIK_ISSUER` | 必选 | Authentik 提供程序详情页的 OpenID 配置颁发者 |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variable#Authentik) 可查阅相关变量详情。
|
||||
|
||||
@@ -134,12 +134,12 @@ If you are deploying using a public network, the following assumptions apply:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | A key for encrypting Auth.js session tokens. You can generate a key using the command: `openssl rand -base64 32`. |
|
||||
| `AUTH_SECRET` | Required | A key for encrypting Auth.js session tokens. You can generate a key using the command: `openssl rand -base64 32`. |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LobeChat. Fill in `casdoor` for using Casdoor. |
|
||||
| `AUTH_CASDOOR_ID` | Required | The client ID from the Casdoor application details page. |
|
||||
| `AUTH_CASDOOR_SECRET` | Required | The client secret from the Casdoor application details page. |
|
||||
| `AUTH_CASDOOR_ISSUER` | Required | The OpenID Connect issuer for the Casdoor provider. |
|
||||
| `NEXTAUTH_URL` | Required | This URL specifies the callback address for Auth.js during OAuth verification and needs to be set only if the default generated redirect address is incorrect. `https://lobe.example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL specifies the callback address for Auth.js during OAuth verification and needs to be set only if the default generated redirect address is incorrect. `https://lobe.example.com/api/auth` |
|
||||
| `CASDOOR_WEBHOOK_SECRET` | Optional | A key used to verify whether the request sent by Casdoor is legal. |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
|
||||
@@ -133,12 +133,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Casdoor 请填写 `casdoor`。 |
|
||||
| `AUTH_CASDOOR_ID` | 必选 | Casdoor 应用详情页的客户端 ID |
|
||||
| `AUTH_CASDOOR_SECRET` | 必选 | Casdoor 应用详情页的客户端密钥 |
|
||||
| `AUTH_CASDOOR_ISSUER` | 必选 | Casdoor 提供程序的 OpenID Connect 颁发者。 |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://lobe.example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://lobe.example.com/api/auth` |
|
||||
| `CASDOOR_WEBHOOK_SECRET` | 可选 | 用于验证 Casdoor 发送的 Webhook 请求是否合法的密钥。 |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
|
||||
@@ -48,12 +48,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | The secret used to encrypt Auth.js session tokens. You can generate a secret using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | The secret used to encrypt Auth.js session tokens. You can generate a secret using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the SSO provider for LoboChat. Use `cloudflare-zero-trust` for Cloudflare Zero Trust. |
|
||||
| `AUTH_CLOUDFLARE_ZERO_TRUST_ID` | Required | The Client ID from the Cloudflare Zero Trust application provider details page |
|
||||
| `AUTH_CLOUDFLARE_ZERO_TRUST_SECRET` | Required | The Client Secret from the Cloudflare Zero Trust application provider details page |
|
||||
| `AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER` | Required | The OpenID Configuration Issuer from the Cloudflare Zero Trust application provider details page |
|
||||
| `NEXTAUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. It only needs to be set when the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. It only needs to be set when the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Go to [📘 Environment Variables](/docs/self-hosting/environment-variable#Cloudflare%20Zero%20Trust) for details about the variables.
|
||||
|
||||
@@ -46,12 +46,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ----------------------------------- | -- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Cloudflare Zero Trust 请填写 `cloudflare-zero-trust`。 |
|
||||
| `AUTH_CLOUDFLARE_ZERO_TRUST_ID` | 必选 | 在 Cloudflare Zero Trust 生成的 `Client ID`,示例值是 `lobe-chat` |
|
||||
| `AUTH_CLOUDFLARE_ZERO_TRUST_SECRET` | 必选 | 在 Cloudflare Zero Trust 生成的 `Client secret`,示例值是 `insecure_secret` |
|
||||
| `AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER` | 必选 | 在 Cloudflare Zero Trust 生成的 `Issuer`,例如 `https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/7db0f` |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://chat.example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://chat.example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variable#Cloudflare%20Zero%20Trust) 可查阅相关变量详情。
|
||||
|
||||
@@ -54,11 +54,11 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate the key using the command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate the key using the command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the Single Sign-On provider for LobeChat. Use `github` for Github. |
|
||||
| `AUTH_GITHUB_ID` | Required | Client ID in the Github App details page. |
|
||||
| `AUTH_GITHUB_SECRET` | Required | Client Secret in the Github App details page. |
|
||||
| `NEXTAUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. Only set it if the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. Only set it if the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Go to [📘 Environment Variables](/docs/self-hosting/environment-variables/auth#github) for detailed
|
||||
|
||||
@@ -52,11 +52,11 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Github 请填写 `github`。 |
|
||||
| `AUTH_GITHUB_ID` | 必选 | Github App 详情页的 客户端 ID |
|
||||
| `AUTH_GITHUB_SECRET` | 必选 | Github App 详情页的 客户端 Secret |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#github) 可查阅相关变量详情。
|
||||
|
||||
@@ -21,49 +21,52 @@ tags:
|
||||
<Steps>
|
||||
### Create a Google Cloud OAuth 2.0 Client
|
||||
|
||||
In your [Google Cloud Console][google-cloud-console], navigate to **APIs & Services > Credentials**.
|
||||
In your [Google Cloud Console][google-cloud-console], navigate to **APIs & Services > Credentials**.
|
||||
|
||||
Click on **Create Credentials** and select **OAuth client ID**.
|
||||
Click on **Create Credentials** and select **OAuth client ID**.
|
||||
|
||||
If you haven't already set up a consent screen, you will be prompted to do so. Complete the OAuth consent screen setup (specify app name, support email, and add authorized users if needed).
|
||||
If you haven't already set up a consent screen, you will be prompted to do so. Complete the OAuth consent screen setup (specify app name, support email, and add authorized users if needed).
|
||||
|
||||
Select **Web application** as the application type.
|
||||
Select **Web application** as the application type.
|
||||
|
||||
In the **Authorized redirect URIs** section, enter:
|
||||
In the **Authorized redirect URIs** section, enter:
|
||||
|
||||
```bash
|
||||
https://your-domain/api/auth/callback/google
|
||||
```
|
||||
```bash
|
||||
https://your-domain/api/auth/callback/google
|
||||
```
|
||||
|
||||
\<Callout type={'info'}>
|
||||
\- You can add or modify redirect URIs after registration, but make sure the URL matches your deployed LobeChat instance.
|
||||
\- Replace "your-domain" with your actual domain. </Callout>
|
||||
<Callout type={'info'}>
|
||||
\- You can add or modify redirect URIs after registration, but make sure the URL matches your deployed LobeChat instance.
|
||||
\- Replace "your-domain" with your actual domain.
|
||||
</Callout>
|
||||
|
||||
Click **Create**.
|
||||
Click **Create**.
|
||||
|
||||
After creation, copy the **Client ID** and **Client Secret**.
|
||||
After creation, copy the **Client ID** and **Client Secret**.
|
||||
|
||||
<Image alt="Google OAuth Setup" inStep src="https://developers.google.com/static/identity/images/gsi/web/gcs-signin-2.png" />
|
||||
|
||||
### Add Users (Optional for Internal Use Only)
|
||||
### Add Users (Optional for Internal Use Only)
|
||||
|
||||
If your application is in **Testing** or **Internal** publishing status, add user emails in the OAuth consent screen under **Test users**.
|
||||
Users not added here will not be able to authenticate.
|
||||
If your application is in **Testing** or **Internal** publishing status, add user emails in the OAuth consent screen under **Test users**.
|
||||
Users not added here will not be able to authenticate.
|
||||
|
||||
### Configure Environment Variables
|
||||
### Configure Environment Variables
|
||||
|
||||
When deploying LobeChat, configure the following environment variables:
|
||||
When deploying LobeChat, configure the following environment variables:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key to encrypt Auth.js session tokens. Generate using: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LobeChat. Use `google` for Google SSO. |
|
||||
| `AUTH_GOOGLE_ID` | Required | Client ID from Google Cloud OAuth. |
|
||||
| `AUTH_GOOGLE_SECRET` | Required | Client Secret from Google Cloud OAuth. |
|
||||
| `NEXTAUTH_URL` | Required | Specifies the callback address for Auth.js when performing OAuth authentication. E.g. `https://your-domain/api/auth` |
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key to encrypt Auth.js session tokens. Generate using: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LobeChat. Use `google` for Google SSO. |
|
||||
| `AUTH_GOOGLE_ID` | Required | Client ID from Google Cloud OAuth. |
|
||||
| `AUTH_GOOGLE_SECRET` | Required | Client Secret from Google Cloud OAuth. |
|
||||
| `AUTH_URL` | Required | Specifies the callback address for Auth.js when performing OAuth authentication. E.g. `https://your-domain/api/auth` |
|
||||
|
||||
\<Callout type={'tip'}>
|
||||
See [📘 environment variables](/docs/self-hosting/environment-variable#google) for more details on these variables. </Callout> </Steps>
|
||||
<Callout type={'tip'}>
|
||||
See [📘 environment variables](/docs/self-hosting/environment-variable#google) for more details on these variables.
|
||||
</Callout>
|
||||
</Steps>
|
||||
|
||||
<Callout>
|
||||
After successful deployment, users can sign in to LobeChat using their Google accounts (those added as Test Users, if not in production).
|
||||
@@ -75,8 +78,8 @@ See the [Google Identity Platform Documentation][google-identity-docs] for advan
|
||||
|
||||
## Related Resources
|
||||
|
||||
* [Quickstart: Configure a Google OAuth client][google-oauth-quickstart]
|
||||
- [Quickstart: Configure a Google OAuth client][google-oauth-quickstart]
|
||||
|
||||
[google-cloud-console]: https://console.cloud.google.com/apis/credentials
|
||||
[google-oauth-quickstart]: https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred
|
||||
[google-identity-docs]: https://developers.google.com/identity
|
||||
[google-oauth-quickstart]: https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred
|
||||
|
||||
@@ -108,12 +108,12 @@ If you deploy using a public network, this guide assumes:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LobeChat. For Keycloak, fill in `keycloak`. |
|
||||
| `AUTH_KEYCLOAK_ID` | Required | Keycloak client ID |
|
||||
| `AUTH_KEYCLOAK_SECRET` | Required | Keycloak client secret |
|
||||
| `AUTH_KEYCLOAK_ISSUER` | Required | OpenID Connect issuer URL for the Keycloak provider, in the format `{keycloak_url}/realms/{realm_name}` |
|
||||
| `NEXTAUTH_URL` | Required | This URL specifies the callback address for Auth.js during OAuth verification. Only needed when the default generated redirect address is incorrect. `https://lobe.example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL specifies the callback address for Auth.js during OAuth verification. Only needed when the default generated redirect address is incorrect. `https://lobe.example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Visit [📘 Environment Variables](/zh/docs/self-hosting/environment-variables/auth#keycloak) for details on related variables.
|
||||
|
||||
@@ -105,12 +105,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Keycloak 请填写 `keycloak`。 |
|
||||
| `AUTH_KEYCLOAK_ID` | 必选 | Keycloak 客户端 ID |
|
||||
| `AUTH_KEYCLOAK_SECRET` | 必选 | Keycloak 客户端密钥 |
|
||||
| `AUTH_KEYCLOAK_ISSUER` | 必选 | Keycloak 提供程序的 OpenID Connect 颁发者 URL,格式为 `{keycloak_url}/realms/{realm_name}` |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://lobe.example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://lobe.example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#keycloak) 可查阅相关变量详情。
|
||||
|
||||
@@ -50,7 +50,9 @@ If you are using Logto Cloud, assume its endpoint domain is `https://example.log
|
||||
Go to `Webhooks`, create a Webhook, and fill in the following fields:
|
||||
|
||||
- Endpoint URL: `https://lobe.example.com/api/webhooks/logto`
|
||||
- Events: `User.Data.Updated`
|
||||
- Events:
|
||||
- `User.Data.Updated`: Allow LobeChat to synchronize user profile information updates from Logto.
|
||||
- `User.SuspensionStatus.Updated`: Allow LobeChat to remove the active session from suspended users from logging in, only available when database session strategy is `database`.
|
||||
|
||||
After successful creation, copy the Webhook's `Signing Key` and fill it in the `LOGTO_WEBHOOK_SIGNING_KEY` environment variable.
|
||||
|
||||
@@ -69,12 +71,12 @@ If you are using Logto Cloud, assume its endpoint domain is `https://example.log
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | The key used to encrypt Auth.js session tokens. You can generate a key using the command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | The key used to encrypt Auth.js session tokens. You can generate a key using the command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LobeChat. For Logto, enter `logto`. |
|
||||
| `AUTH_LOGTO_ID` | Required | The Client ID from the Logto App details page |
|
||||
| `AUTH_LOGTO_SECRET` | Required | The Client Secret from the Logto App details page |
|
||||
| `AUTH_LOGTO_ISSUER` | Required | OpenID Connect issuer of the Logto provider |
|
||||
| `NEXTAUTH_URL` | Required | This URL specifies the callback address for Auth.js during OAuth verification, needed only if the default generated redirect address is incorrect. `https://lobe.example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL specifies the callback address for Auth.js during OAuth verification, needed only if the default generated redirect address is incorrect. `https://lobe.example.com/api/auth` |
|
||||
| `LOGTO_WEBHOOK_SIGNING_KEY` | Optional | The key used to verify the legality of Webhook requests sent by Logto. |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
|
||||
@@ -47,7 +47,9 @@ tags:
|
||||
前往 `Webhooks` ,创建一个 Webhook,填写以下字段:
|
||||
|
||||
- 端点 URL: `https://lobe.example.com/api/webhooks/logto`
|
||||
- 事件: `User.Data.Updated`
|
||||
- 事件:
|
||||
- `User.Data.Updated`: 允许 LobeChat 同步 Logto 中用户资料信息的更新。
|
||||
- `User.SuspensionStatus.Updated`: 允许 LobeChat 将被暂停的用户移除登录会话,仅在数据库会话策略为 `database` 时可用。
|
||||
|
||||
创建成功后,复制 Webhook 的 `签名密钥`。填写到环境变量中的 `LOGTO_WEBHOOK_SIGNING_KEY`。
|
||||
|
||||
@@ -66,12 +68,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| --------------------------- | -- | ------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Logto 请填写 `logto`。 |
|
||||
| `AUTH_LOGTO_ID` | 必选 | Logto App 详情页的 Client ID |
|
||||
| `AUTH_LOGTO_SECRET` | 必选 | Logto App 详情页的 Client Secret |
|
||||
| `AUTH_LOGTO_ISSUER` | 必选 | Logto 提供程序的 OpenID Connect 颁发者 |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://lobe.example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://lobe.example.com/api/auth` |
|
||||
| `LOGTO_WEBHOOK_SIGNING_KEY` | 可选 | 用于验证 Logto 发送的 Webhook 请求是否合法的密钥。 |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
|
||||
@@ -58,12 +58,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ----------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate the key using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate the key using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LoboChat. Use `microsoft-entra-id` for Microsoft Entra ID. |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Required | Client ID of the Microsoft Entra ID application. |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Required | Client Secret of the Microsoft Entra ID application. |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_TENANT_ID` | Required | Tenant ID of the Microsoft Entra ID application. |
|
||||
| `NEXTAUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. It is only necessary to set it when the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. It is only necessary to set it when the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
You can refer to [📘 environment
|
||||
|
||||
@@ -56,12 +56,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ----------------------------------- | -- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Microsoft Entra ID 请填写 `microsoft-entra-id`。 |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | 必选 | Microsoft Entra ID 应用程序的 Client ID |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | 必选 | Microsoft Entra ID 应用程序的 Client Secret |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_TENANT_ID` | 必选 | Microsoft Entra ID 应用程序的 Tenant ID |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variable#microsoft-entra-id) 可查阅相关变量详情。
|
||||
|
||||
@@ -45,12 +45,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LoboChat. Use `okta` for Okta. |
|
||||
| `AUTH_OKTA_ID` | Required | Client ID of the Okta application |
|
||||
| `AUTH_OKTA_SECRET` | Required | Client Secret of the Okta application |
|
||||
| `AUTH_OKTA_ISSUER` | Required | Domain of the Okta application, `https://example.oktapreview.com` |
|
||||
| `NEXTAUTH_URL` | Optional | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Optional | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
You can refer to the related variable details at [📘Environment Variables](/docs/self-hosting/environment-variable/auth#okta).
|
||||
|
||||
@@ -44,12 +44,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------ |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成密钥:`openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成密钥:`openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Okta 请填写 `okta`。 |
|
||||
| `AUTH_OKTA_ID` | 必选 | Okta 应用程序的客户端 ID |
|
||||
| `AUTH_OKTA_SECRET` | 必选 | Okta 应用程序的客户端密钥 |
|
||||
| `AUTH_OKTA_ISSUER` | 必选 | Okta 应用程序的域名,`https://example.oktapreview.com` |
|
||||
| `NEXTAUTH_URL` | 可选 | 该 URL 用于指定 Auth.js 在执行 OAuth 认证时的回调地址。仅当默认地址不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 可选 | 该 URL 用于指定 Auth.js 在执行 OAuth 认证时的回调地址。仅当默认地址不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
您可以在 [📘环境变量](/zh/docs/self-hosting/environment-variables/auth#okta) 查阅相关变量详情。
|
||||
|
||||
@@ -30,11 +30,11 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate the key using the command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate the key using the command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the Single Sign-On provider for LobeChat. Use `github` for Github. |
|
||||
| `WECHAT_CLIENT_ID` | Required | Client ID from the Wechat website application details page |
|
||||
| `WECHAT_CLIENT_SECRET` | Required | Client Secret from the Wechat website application details page |
|
||||
| `NEXTAUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. Only set it if the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | This URL is used to specify the callback address for Auth.js when performing OAuth authentication. Only set it if the default generated redirect address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
Go to [📘 Environment Variables](/en/docs/self-hosting/environment-variables/auth#wechat) for more details about related variables.
|
||||
|
||||
@@ -28,11 +28,11 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成秘钥: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 选择 LoboChat 的单点登录提供商。使用 Github 请填写 `github`。 |
|
||||
| `WECHAT_CLIENT_ID` | 必选 | 微信网站应用详情页的 客户端 ID |
|
||||
| `WECHAT_CLIENT_SECRET` | 必选 | 微信网站应用详情页的 客户端 Secret |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
前往 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#wechat) 可查阅相关变量详情。
|
||||
|
||||
@@ -65,12 +65,12 @@ tags:
|
||||
|
||||
| Environment Variable | Type | Description |
|
||||
| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | Required | Key used to encrypt Auth.js session tokens. You can generate a key using the following command: `openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | Required | Select the single sign-on provider for LoboChat. Use `zitadel` for ZITADEL. |
|
||||
| `AUTH_ZITADEL_ID` | Required | Client ID (`ClientId` as shown in ZITADEL) of the ZITADEL application |
|
||||
| `AUTH_ZITADEL_SECRET` | Required | Client Secret (`ClientSecret` as shown in ZITADEL) of the ZITADEL application |
|
||||
| `AUTH_ZITADEL_ISSUER` | Required | Issuer URL of the ZITADEL application |
|
||||
| `NEXTAUTH_URL` | Required | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
|
||||
| `AUTH_URL` | Required | The URL is used to specify the callback address for the execution of OAuth authentication in Auth.js. It needs to be set only when the default address is incorrect. `https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
You can refer to the related variable details at [📘Environment Variables](/docs/self-hosting/environment-variables/auth#zitadel).
|
||||
|
||||
@@ -62,12 +62,12 @@ tags:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
| ------------------------- | -- | ----------------------------------------------------------------------------------- |
|
||||
| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成密钥:`openssl rand -base64 32` |
|
||||
| `AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令生成密钥:`openssl rand -base64 32` |
|
||||
| `NEXT_AUTH_SSO_PROVIDERS` | 必选 | 为 LobeChat 选择单点登录提供程序。对于 ZITADEL,请填写 `zitadel`。 |
|
||||
| `AUTH_ZITADEL_ID` | 必选 | ZITADEL 应用的 Client ID(`ClientId`)。 |
|
||||
| `AUTH_ZITADEL_SECRET` | 必选 | ZITADEL 应用的 Client Secret(`ClientSecret`)。 |
|
||||
| `AUTH_ZITADEL_ISSUER` | 必选 | ZITADEL 应用的 OpenID Connect 颁发者(issuer)URL。 |
|
||||
| `NEXTAUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 中执行 OAuth 认证的回调地址。仅当默认地址不正确时才需要设置。`https://example.com/api/auth` |
|
||||
| `AUTH_URL` | 必选 | 该 URL 用于指定 Auth.js 中执行 OAuth 认证的回调地址。仅当默认地址不正确时才需要设置。`https://example.com/api/auth` |
|
||||
|
||||
<Callout type={'tip'}>
|
||||
您可以在 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#zitadel) 中查看相关变量的详细信息。
|
||||
|
||||
@@ -49,7 +49,6 @@ You can achieve various feature combinations using the above configuration synta
|
||||
| `token_counter` | Reserved for token counter display. | Enabled |
|
||||
| `welcome_suggest` | Displays welcome suggestions. | Enabled |
|
||||
| `changelog` | Controls changelog modal/page display. | Enabled |
|
||||
| `clerk_sign_up` | Enables the Clerk SignUp functionality. | Enabled |
|
||||
| `market` | Enables the assistant market functionality. | Enabled |
|
||||
| `knowledge_base` | Enables the knowledge base functionality. | Enabled |
|
||||
| `rag_eval` | Controls RAG evaluation feature (/repos/\[id]/evals). | Disabled |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user