mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 19:50:09 +00:00
Compare commits
361 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab508705b1 | |||
| 22da6d4e6b | |||
| f3f64be9c7 | |||
| e8ddfc7397 | |||
| ed03bebc6b | |||
| 2086f742ef | |||
| 8642d3dfdb | |||
| de72121617 | |||
| 1f81dd074b | |||
| a6bc59c911 | |||
| 7d4cc1851c | |||
| 58e8dfb02b | |||
| 72fb1b489e | |||
| 13511b4d50 | |||
| 7a38488976 | |||
| 5f1a03b593 | |||
| 5f0079500b | |||
| 5c7e62b2a2 | |||
| c7e86704af | |||
| f5ae77d3d8 | |||
| f9f3636659 | |||
| ed8fe88f98 | |||
| 54814e15e4 | |||
| 7177ecfc4f | |||
| da23c0fb99 | |||
| 6525dd689b | |||
| 14dca36f35 | |||
| c372183019 | |||
| 09654b9039 | |||
| e87d4595f0 | |||
| e99f47f5ef | |||
| 8fb965fcf9 | |||
| 0ce691f6ca | |||
| 64d6f47fc0 | |||
| f1b0eb771b | |||
| 197c67d4c6 | |||
| 43dd8d2f0b | |||
| 3acf3fea36 | |||
| 69e2d0ff8e | |||
| b62a4e7f35 | |||
| 96452809c0 | |||
| 7d023da072 | |||
| 9e8a1bd956 | |||
| 2b6c6d70ae | |||
| 4e03b47a2c | |||
| 1507de6d4c | |||
| 0546dd3f37 | |||
| 3c0601bdce | |||
| dcd6190c38 | |||
| 593f388db3 | |||
| e6db53a5b7 | |||
| 0902d55c50 | |||
| ec03e49383 | |||
| a4b6520348 | |||
| dd8e4124f8 | |||
| 0ed75c134e | |||
| 720c58f351 | |||
| 1b87a5ac95 | |||
| 059f4cef02 | |||
| ec69abc51d | |||
| 236b61de20 | |||
| 16888531b0 | |||
| 5664888503 | |||
| e262925384 | |||
| 0ca4b7db24 | |||
| b4de17f8ef | |||
| 93aa9a50ae | |||
| 7a4ad5d133 | |||
| 182873b9d2 | |||
| 7329aa54aa | |||
| 3472db8110 | |||
| d2f8ef804c | |||
| e40e8cdb0d | |||
| 6effba00ed | |||
| 9b292f34e8 | |||
| b201208648 | |||
| e20a5f49a8 | |||
| 2ac8db3d0a | |||
| 5baf7e0058 | |||
| 74556d1b1f | |||
| f798aec8d7 | |||
| adfae9bf0f | |||
| a241f93782 | |||
| 2c27828c3c | |||
| 34eb80faf8 | |||
| a766ad87d7 | |||
| ea6c3ea81b | |||
| 7bef57b0e8 | |||
| 92b5ef49be | |||
| 8fb01f595a | |||
| 8bac88b6d0 | |||
| 79fa8cc574 | |||
| ce314b693c | |||
| 78d10d5ff1 | |||
| e29e4187ed | |||
| a16c337fae | |||
| 73305b3922 | |||
| c5602ee74d | |||
| 571e9ef4da | |||
| 7e42a22249 | |||
| 6a18a2f7bb | |||
| b7ea3f3a1e | |||
| a44d9e1e8b | |||
| 55a7f3f4c6 | |||
| 489c73db59 | |||
| 6f7c31c6d1 | |||
| 553d29e644 | |||
| 6f46eecc2d | |||
| 33ce9175b2 | |||
| 90766129e6 | |||
| 83dd8f7bbb | |||
| 3c07c48994 | |||
| d9963fc3de | |||
| 95ecfc0f03 | |||
| e56ff6f41d | |||
| 85b3dd872f | |||
| 92eb695bf9 | |||
| 56a00f8359 | |||
| 63cc148aac | |||
| d9cb5be5b0 | |||
| c61c95b416 | |||
| 73b0c1cf20 | |||
| ee70dfccd9 | |||
| 4c1d58102a | |||
| 89c8180694 | |||
| f34d1ffb50 | |||
| 431331b38f | |||
| 702818c17b | |||
| 7184eeaee0 | |||
| 7ff34439ca | |||
| 9b8660c78f | |||
| a1e5f23327 | |||
| 434caf00c3 | |||
| 34f2c8a192 | |||
| b7f63d3598 | |||
| 6fdbc71868 | |||
| 22fd5cc32c | |||
| 17ac2dffe9 | |||
| a6c1c9e36f | |||
| a5592d205d | |||
| 629d846d45 | |||
| cf69336aa6 | |||
| 6f7d8b7968 | |||
| d3fd5c6267 | |||
| 433515691a | |||
| 4e92d3e81d | |||
| bee7e0786f | |||
| e1b3d6b818 | |||
| a552ace21e | |||
| 1f3986b72d | |||
| eac2655d34 | |||
| 61fa81ed56 | |||
| c3e9d39dfa | |||
| fa2e81110a | |||
| a0a0b780d2 | |||
| 934279ab23 | |||
| aa75df4be7 | |||
| b3daa1a59c | |||
| df1921c65e | |||
| ae89f18a07 | |||
| 5941025eb9 | |||
| 821d3b001d | |||
| d4f6737fe9 | |||
| e45fd8fcf6 | |||
| 79c74d2a0e | |||
| c9f7aa0c28 | |||
| b4931047e0 | |||
| ddfe652eb6 | |||
| 93ea3a4a90 | |||
| 6369e8bf52 | |||
| ec7f572c35 | |||
| baf6bc10b0 | |||
| b806270f21 | |||
| 370674b8cc | |||
| 2b5bec4e48 | |||
| 195cc12be1 | |||
| 34b05755a3 | |||
| ebdd442e91 | |||
| f9cd93020c | |||
| 46a56d809b | |||
| f602ecc3f6 | |||
| c3cebc30bf | |||
| ebc62a30cb | |||
| 277799e2d5 | |||
| 4703fb3703 | |||
| 9642555318 | |||
| f33b6f162a | |||
| 7d34140a91 | |||
| af1d09da83 | |||
| 13166a24b1 | |||
| 87866ac387 | |||
| 014782d0ba | |||
| 102e2c41e0 | |||
| d3b2c792f6 | |||
| cca9b04544 | |||
| 06c3f6f255 | |||
| 4652fc2c4a | |||
| 0fb69ae068 | |||
| 1ff83a7e61 | |||
| 49557e202e | |||
| 9011d4169a | |||
| e9848cce2a | |||
| 0fce82c42b | |||
| c687111769 | |||
| ce2ac7c849 | |||
| 49e180e88b | |||
| 598646818a | |||
| afc87764ab | |||
| 30921420dc | |||
| 1c0c5c0c72 | |||
| ae9ed3cd50 | |||
| d96a5792bc | |||
| 90e48ac050 | |||
| 951c57518f | |||
| 0ef8753c75 | |||
| 6506cd7d8e | |||
| aaff1cc950 | |||
| 90c0e00e82 | |||
| 3d55c0dd2d | |||
| 1ad3b438d3 | |||
| c8ff06211a | |||
| 430d7a5138 | |||
| 0239b38038 | |||
| bd49a32ccf | |||
| 109013b6e3 | |||
| 23fd49bf09 | |||
| d4b6543220 | |||
| 88b872b62f | |||
| 8d25c95dfc | |||
| bd49702901 | |||
| bb19818f21 | |||
| 645ac37cdb | |||
| 68a2e1a4fa | |||
| e19b6d5fe1 | |||
| d4d9393789 | |||
| 0f7166685e | |||
| 0880e4590f | |||
| 8e861175c7 | |||
| 627dc76fe2 | |||
| 869ca49ba1 | |||
| f7dc6be85c | |||
| 9e2e28505e | |||
| 760957514b | |||
| ef4e0f3771 | |||
| fec639349d | |||
| 335a55c44c | |||
| f6c576b314 | |||
| 3bd6361a55 | |||
| d3554a1462 | |||
| 108607c15a | |||
| c753a60cc9 | |||
| fc971d99aa | |||
| 722013022a | |||
| 7f60e62c8a | |||
| 070a991e52 | |||
| 9bd1693882 | |||
| 08dc64b671 | |||
| 48c9589a63 | |||
| 08914f4aa4 | |||
| d4e2de81dd | |||
| e78bdb504a | |||
| ecf4ce1e14 | |||
| 04b351e0f2 | |||
| ce99a7980e | |||
| e2ada4d3a2 | |||
| 6372920786 | |||
| bcbb224b58 | |||
| 142f14956a | |||
| a2755798a9 | |||
| a98751e9b9 | |||
| 4868885aac | |||
| 2af0fc88cd | |||
| 6e2a54f4fe | |||
| c026f15834 | |||
| 70ea380205 | |||
| c90b034cf3 | |||
| d77cf05f3c | |||
| 84087c24e5 | |||
| 8ba3ce9f96 | |||
| 24606b73b0 | |||
| 64a87ccc83 | |||
| f88253c32e | |||
| f79ac666ce | |||
| 3147c65d8d | |||
| 636a035fd1 | |||
| b456eef093 | |||
| 2b2caab656 | |||
| 21ad53a51b | |||
| df862e2616 | |||
| 452a2e6b5e | |||
| f7db45e392 | |||
| 02b1efa523 | |||
| 8b8e5e4abd | |||
| 822290af9c | |||
| 8c11cbb818 | |||
| ea7b7bd1de | |||
| a5e618cfcc | |||
| 8d40478b59 | |||
| 64e290b5c6 | |||
| cdf36079d5 | |||
| 8f40ae5efd | |||
| d05b39569c | |||
| 95a62e87bf | |||
| a6eaf00302 | |||
| b7c447ba38 | |||
| 5f46880d35 | |||
| 35f8a882e8 | |||
| b7cf19f302 | |||
| 9d93c95435 | |||
| 209c436d43 | |||
| dbbf134386 | |||
| d4fc611cb2 | |||
| 23d77f784e | |||
| 4605d32639 | |||
| 477dd9e8ea | |||
| e30939da9d | |||
| d7e6e2d1ce | |||
| b95ddd990e | |||
| 47a8b6eb62 | |||
| f52ef195ba | |||
| e20b7d4b97 | |||
| 5b8574a365 | |||
| e03040a12e | |||
| e57891a89a | |||
| 37da558e71 | |||
| 2770093712 | |||
| 2e3094e4c9 | |||
| 75ced15278 | |||
| ba8274351e | |||
| 632f6ace3c | |||
| a2b11d1ee4 | |||
| 2b8d08ae25 | |||
| 26e407d324 | |||
| e3b40abc30 | |||
| 772de1ebff | |||
| 486b628975 | |||
| 32afe7d855 | |||
| 6514f2be5c | |||
| cb3e5e365a | |||
| 3788958f28 | |||
| 88ed461120 | |||
| 4f084a6ba4 | |||
| a0730799e8 | |||
| 2b687eec26 | |||
| f9fe24b896 | |||
| c205df5cb6 | |||
| 1e7dcf3840 | |||
| 85a61f628a | |||
| 434f1bffa1 | |||
| 1503ac5096 | |||
| 3d1b513c45 | |||
| a2d470b9d2 | |||
| 9f017cce0d | |||
| 459ec162de | |||
| a65af38653 | |||
| 79dec7a8e7 | |||
| fb1e1ca423 | |||
| bbb7d90fc6 | |||
| 00960c7ff6 | |||
| a0d5732108 | |||
| 1ead26f82a |
@@ -1,228 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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
|
||||
+2
-1
@@ -4,5 +4,6 @@ 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
|
||||
+3
-11
@@ -13,17 +13,6 @@
|
||||
# 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 #########
|
||||
########################################
|
||||
@@ -284,6 +273,9 @@ 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,6 +8,8 @@ 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
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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.**
|
||||
@@ -1,67 +0,0 @@
|
||||
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,7 +21,6 @@ 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,18 +1,16 @@
|
||||
name: Desktop PR Build
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发
|
||||
|
||||
# 确保同一 PR 同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
concurrency:
|
||||
group: pr-${{ github.event.pull_request.number }}-${{ github.workflow }}
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Add default permissions
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
PR_TAG_PREFIX: pr- # PR 构建版本的前缀标识
|
||||
@@ -30,9 +28,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -64,9 +62,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -109,9 +107,9 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
@@ -201,7 +199,7 @@ jobs:
|
||||
|
||||
# 上传构建产物
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -228,9 +226,9 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -240,7 +238,7 @@ jobs:
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
@@ -266,7 +264,7 @@ jobs:
|
||||
|
||||
# 上传合并后的构建产物
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: merged-release-pr
|
||||
path: release/
|
||||
@@ -289,7 +287,7 @@ jobs:
|
||||
|
||||
# 下载合并后的构建产物
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: merged-release-pr
|
||||
path: release
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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@v5
|
||||
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}`);
|
||||
@@ -0,0 +1,163 @@
|
||||
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@v5
|
||||
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,32 +1,29 @@
|
||||
name: Publish Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
# PR 构建时取消旧的运行,但 release 构建不取消
|
||||
cancel-in-progress: ${{ github.event_name != 'release' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: lobehub/lobehub
|
||||
REGISTRY_IMAGE: lobehub/lobe-chat
|
||||
PR_TAG_PREFIX: pr-
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 添加 PR label 触发条件
|
||||
if: |
|
||||
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'))
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'trigger:build-docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -53,12 +50,11 @@ jobs:
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request_target'
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
sanitized_branch=$(echo "${BRANCH_NAME}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
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
|
||||
@@ -68,10 +64,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_target' }}
|
||||
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_target' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request_target' }}
|
||||
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
|
||||
@@ -104,7 +100,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -122,7 +118,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
@@ -133,12 +129,11 @@ jobs:
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request_target'
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
sanitized_branch=$(echo "${BRANCH_NAME}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
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
|
||||
@@ -147,9 +142,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_target' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request_target' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request_target' }}
|
||||
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
|
||||
@@ -166,21 +161,3 @@ 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,21 +14,10 @@ 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@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -44,14 +33,11 @@ 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 Cucumber HTML report (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: e2e/reports
|
||||
@@ -59,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Upload screenshots (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-screenshots
|
||||
path: e2e/screenshots
|
||||
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -53,9 +53,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -94,9 +94,9 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
# 上传构建产物 (工作流处理重命名,不依赖 electron-builder 钩子)
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -208,9 +208,9 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
|
||||
# 上传合并后的构建产物
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
steps:
|
||||
# 下载合并后的构建产物
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -24,6 +23,7 @@ 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@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -54,7 +54,8 @@ jobs:
|
||||
env:
|
||||
DATABASE_TEST_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_DRIVER: node
|
||||
KEY_VAULTS_SECRET: Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=
|
||||
NEXT_PUBLIC_SERVICE_MODE: server
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
S3_PUBLIC_DOMAIN: https://example.com
|
||||
APP_URL: https://home.com
|
||||
|
||||
|
||||
+12
-48
@@ -21,7 +21,6 @@ jobs:
|
||||
- python-interpreter
|
||||
- context-engine
|
||||
- agent-runtime
|
||||
- conversation-flow
|
||||
|
||||
name: Test package ${{ matrix.package }}
|
||||
|
||||
@@ -29,9 +28,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -64,9 +63,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -97,9 +96,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
@@ -120,43 +119,6 @@ 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
|
||||
|
||||
@@ -170,6 +132,7 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@@ -177,9 +140,9 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pnpm
|
||||
@@ -194,7 +157,7 @@ jobs:
|
||||
- name: Test Client DB
|
||||
run: pnpm --filter @lobechat/database test:client-db
|
||||
env:
|
||||
KEY_VAULTS_SECRET: Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
S3_PUBLIC_DOMAIN: https://example.com
|
||||
APP_URL: https://home.com
|
||||
|
||||
@@ -203,7 +166,8 @@ jobs:
|
||||
env:
|
||||
DATABASE_TEST_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DATABASE_DRIVER: node
|
||||
KEY_VAULTS_SECRET: Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=
|
||||
NEXT_PUBLIC_SERVICE_MODE: server
|
||||
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
|
||||
S3_PUBLIC_DOMAIN: https://example.com
|
||||
APP_URL: https://home.com
|
||||
|
||||
|
||||
+4
-1
@@ -93,7 +93,6 @@ robots.txt
|
||||
.husky/prepare-commit-msg
|
||||
|
||||
# Documents and media
|
||||
*.patch
|
||||
*.pdf
|
||||
|
||||
# Cloud service keys
|
||||
@@ -118,3 +117,7 @@ CLAUDE.local.md
|
||||
prd
|
||||
GEMINI.md
|
||||
e2e/reports
|
||||
|
||||
# local eas account key for android
|
||||
service-account-key.json
|
||||
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
npm run type-check
|
||||
|
||||
# Check if there are changes in apps/mobile directory
|
||||
if git diff --cached --name-only | grep -q "^apps/mobile/"; then
|
||||
(cd apps/mobile && npm run type-check)
|
||||
fi
|
||||
|
||||
npx --no-install lint-staged
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
const config = require('@lobehub/lint').semanticRelease;
|
||||
|
||||
config.branches = [
|
||||
'main',
|
||||
{
|
||||
name: 'next',
|
||||
prerelease: true,
|
||||
},
|
||||
];
|
||||
|
||||
config.plugins.push([
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
|
||||
-1865
File diff suppressed because it is too large
Load Diff
+4
-57
@@ -37,9 +37,6 @@ 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
|
||||
@@ -51,21 +48,13 @@ 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}" \
|
||||
@@ -101,12 +90,7 @@ RUN \
|
||||
# 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
|
||||
&& pnpm i
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -122,16 +106,6 @@ 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
|
||||
|
||||
@@ -164,7 +138,6 @@ ENV HOSTNAME="0.0.0.0" \
|
||||
|
||||
# General Variables
|
||||
ENV ACCESS_CODE="" \
|
||||
APP_URL="" \
|
||||
API_KEY_SELECT_MODE="" \
|
||||
DEFAULT_AGENT_CONFIG="" \
|
||||
SYSTEM_AGENT="" \
|
||||
@@ -172,30 +145,6 @@ 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
|
||||
@@ -314,9 +263,7 @@ ENV \
|
||||
# BFL
|
||||
BFL_API_KEY="" BFL_MODEL_LIST="" \
|
||||
# Vercel AI Gateway
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST="" \
|
||||
# Cerebras
|
||||
CEREBRAS_API_KEY="" CEREBRAS_MODEL_LIST=""
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST=""
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
## 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
|
||||
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
|
||||
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="" \
|
||||
# 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
|
||||
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
|
||||
ENABLED_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"]
|
||||
@@ -0,0 +1,272 @@
|
||||
## 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
|
||||
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
|
||||
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="" \
|
||||
# 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
|
||||
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
|
||||
ENABLED_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,10 +1,3 @@
|
||||
> \[!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]
|
||||
@@ -246,11 +239,54 @@ We have implemented support for the following model service providers:
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
<details><summary><kbd>See more providers (+-10)</kbd></summary>
|
||||
- **[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 (+31)</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.
|
||||
- **[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>
|
||||
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
> 📊 Total providers: [<kbd>**41**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
@@ -345,12 +381,12 @@ In addition, these plugins are not limited to news aggregation, but can also ext
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| 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` |
|
||||
| Recent Submits | Description |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [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` |
|
||||
| [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` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
|
||||
+51
-15
@@ -1,10 +1,3 @@
|
||||
> \[!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]
|
||||
@@ -246,11 +239,54 @@ LobeChat 支持文件上传与知识库功能,你可以上传文件、图片
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
<details><summary><kbd>See more providers (+-10)</kbd></summary>
|
||||
- **[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 (+31)</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 等,分别针对不同应用场景进行优化,提供高性价比的解决方案。
|
||||
- **[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>
|
||||
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
> 📊 Total providers: [<kbd>**41**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
@@ -338,12 +374,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [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/>`网页` `搜索` |
|
||||
| 最近新增 | 描述 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| [购物工具](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/>`网页` `搜索` |
|
||||
| [必应网页搜索](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | 通过 BingApi 搜索互联网上的信息<br/>`bingsearch` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
|
||||
+18
-18
@@ -32,33 +32,33 @@
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"get-port-please": "^3.2.0",
|
||||
"get-port-please": "^3.1.2",
|
||||
"pdfjs-dist": "4.10.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@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.25.1",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@lobehub/i18n-cli": "^1.20.3",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@typescript/native-preview": "7.0.0-dev.20250711.1",
|
||||
"consola": "^3.4.2",
|
||||
"consola": "^3.1.0",
|
||||
"cookie": "^1.0.2",
|
||||
"electron": "^38.7.0",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-is": "^3.0.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-log": "^5.3.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-vite": "^3.1.0",
|
||||
"execa": "^9.6.0",
|
||||
"electron-vite": "^3.0.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
@@ -66,13 +66,13 @@
|
||||
"just-diff": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"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",
|
||||
"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",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataSyncConfig, MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
|
||||
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import querystring from 'node:querystring';
|
||||
@@ -14,38 +14,39 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Implements OAuth authorization flow using intermediate page + polling mechanism
|
||||
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
|
||||
*/
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Remote server configuration controller
|
||||
* 远程服务器配置控制器
|
||||
*/
|
||||
private get remoteServerConfigCtr() {
|
||||
return this.app.getController(RemoteServerConfigCtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current PKCE parameters
|
||||
* 当前的 PKCE 参数
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Construct redirect_uri, ensuring the same URI is used for authorization and token exchange
|
||||
* @param remoteUrl Remote server URL
|
||||
* 构造 redirect_uri,确保授权和令牌交换时使用相同的 URI
|
||||
* @param remoteUrl 远程服务器 URL
|
||||
* @param includeHandoffId 是否包含 handoff ID(仅在授权时需要)
|
||||
*/
|
||||
private constructRedirectUri(remoteUrl: string): string {
|
||||
const callbackUrl = new URL('/oidc/callback/desktop', remoteUrl);
|
||||
@@ -58,12 +59,9 @@ 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);
|
||||
|
||||
// Cache remote server URL for subsequent polling
|
||||
// 缓存远程服务器 URL 用于后续轮询
|
||||
this.cachedRemoteUrl = remoteUrl;
|
||||
|
||||
logger.info(
|
||||
@@ -116,31 +114,6 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询机制获取凭证
|
||||
*/
|
||||
@@ -160,7 +133,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.clearAuthorizationState();
|
||||
this.stopPolling();
|
||||
this.broadcastAuthorizationFailed('Authorization timed out');
|
||||
return;
|
||||
}
|
||||
@@ -194,14 +167,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during credential polling:', error);
|
||||
this.clearAuthorizationState();
|
||||
this.stopPolling();
|
||||
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
|
||||
}
|
||||
}, pollInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling
|
||||
* 停止轮询
|
||||
*/
|
||||
private stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
@@ -211,30 +184,18 @@ 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; // Check every 2 minutes
|
||||
const checkInterval = 2 * 60 * 1000; // 每 2 分钟检查一次
|
||||
logger.debug('Starting auto-refresh timer');
|
||||
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// Check if token is expiring soon (refresh 5 minutes in advance)
|
||||
// 检查 token 是否即将过期 (提前 5 分钟刷新)
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
@@ -247,7 +208,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.broadcastTokenRefreshed();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed: ${result.error}`);
|
||||
// If auto-refresh fails, stop timer and clear token
|
||||
// 如果自动刷新失败,停止定时器并清除 token
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
@@ -261,7 +222,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-refresh timer
|
||||
* 停止自动刷新定时器
|
||||
*/
|
||||
private stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer) {
|
||||
@@ -272,8 +233,8 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for credentials
|
||||
* Sends HTTP request directly to remote server
|
||||
* 轮询获取凭证
|
||||
* 直接发送 HTTP 请求到远程服务器
|
||||
*/
|
||||
private async pollForCredentials(): Promise<{ code: string; state: string } | null> {
|
||||
if (!this.authRequestState || !this.cachedRemoteUrl) {
|
||||
@@ -281,17 +242,17 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use cached remote server URL
|
||||
// 使用缓存的远程服务器 URL
|
||||
const remoteUrl = this.cachedRemoteUrl;
|
||||
|
||||
// Construct request URL
|
||||
// 构造请求 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()}`);
|
||||
|
||||
// Send HTTP request directly
|
||||
// 直接发送 HTTP 请求
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -299,9 +260,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;
|
||||
}
|
||||
|
||||
@@ -309,7 +270,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;
|
||||
@@ -550,7 +511,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize after app is ready
|
||||
* 应用启动后初始化
|
||||
*/
|
||||
afterAppReady() {
|
||||
logger.debug('AuthCtr initialized, checking for existing tokens');
|
||||
@@ -558,7 +519,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all timers
|
||||
* 清理所有定时器
|
||||
*/
|
||||
cleanup() {
|
||||
logger.debug('Cleaning up AuthCtr timers');
|
||||
@@ -567,14 +528,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-refresh functionality
|
||||
* Checks for valid token at app startup and starts auto-refresh timer if token exists
|
||||
* 初始化自动刷新功能
|
||||
* 在应用启动时检查是否有有效的 token,如果有就启动自动刷新定时器
|
||||
*/
|
||||
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',
|
||||
@@ -582,36 +543,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;
|
||||
}
|
||||
|
||||
// Check if token has already expired
|
||||
// 检查 token 是否已经过期
|
||||
const currentTime = Date.now();
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
|
||||
// Attempt to refresh token
|
||||
// 尝试刷新 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}`);
|
||||
// Clear token and require re-authorization only on refresh failure
|
||||
// 只有在刷新失败时才清除 token 并要求重新授权
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
@@ -619,7 +580,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()}`,
|
||||
);
|
||||
|
||||
@@ -467,35 +467,15 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
keywords: params.keywords,
|
||||
});
|
||||
logger.debug('Received file search request:', { 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,
|
||||
const options: Omit<SearchOptions, 'keywords'> = {
|
||||
limit: 30,
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await this.searchService.search(options.keywords, options);
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -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) {
|
||||
// Call MenuManager method to rebuild application menu
|
||||
// 调用 MenuManager 的方法来重建应用菜单
|
||||
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;
|
||||
}
|
||||
|
||||
// On macOS, we may need to explicitly request notification permissions
|
||||
// 在 macOS 上,我们可能需要显式请求通知权限
|
||||
if (macOS()) {
|
||||
logger.debug('macOS detected, notification permissions should be handled by system');
|
||||
}
|
||||
|
||||
// Set app user model ID on Windows
|
||||
// 在 Windows 上设置应用用户模型 ID
|
||||
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('Received desktop notification request:', params);
|
||||
logger.debug('收到桌面通知请求:', params);
|
||||
|
||||
try {
|
||||
// Check notification support
|
||||
// 检查通知支持
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('System does not support desktop notifications');
|
||||
logger.warn('系统不支持桌面通知');
|
||||
return { error: 'Desktop notifications not supported', success: false };
|
||||
}
|
||||
|
||||
// Check if window is hidden
|
||||
// 检查窗口是否隐藏
|
||||
const isWindowHidden = this.isMainWindowHidden();
|
||||
|
||||
if (!isWindowHidden) {
|
||||
logger.debug('Main window is visible, skipping desktop notification');
|
||||
logger.debug('主窗口可见,跳过桌面通知');
|
||||
return { reason: 'Window is visible', skipped: true, success: true };
|
||||
}
|
||||
|
||||
logger.info('Window is hidden, showing desktop notification:', params.title);
|
||||
logger.info('窗口已隐藏,显示桌面通知:', 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('Notification shown');
|
||||
logger.info('通知已显示');
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
logger.debug('User clicked notification, showing main window');
|
||||
logger.debug('用户点击通知,显示主窗口');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
logger.debug('Notification closed');
|
||||
logger.debug('通知已关闭');
|
||||
});
|
||||
|
||||
notification.on('failed', (error) => {
|
||||
logger.error('Notification display failed:', error);
|
||||
logger.error('通知显示失败:', error);
|
||||
});
|
||||
|
||||
// Use Promise to ensure notification is shown
|
||||
// 使用 Promise 来确保通知显示
|
||||
return new Promise((resolve) => {
|
||||
notification.show();
|
||||
|
||||
// Give the notification some time to display, then check the result
|
||||
// 给通知一些时间来显示,然后检查结果
|
||||
setTimeout(() => {
|
||||
logger.info('Notification display call completed');
|
||||
logger.info('通知显示调用完成');
|
||||
resolve({ success: true });
|
||||
}, 100);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to show desktop notification:', error);
|
||||
logger.error('显示桌面通知失败:', 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('Window state check:', { isFocused, isMinimized, isVisible });
|
||||
logger.debug('窗口状态检查:', { isFocused, isMinimized, isVisible });
|
||||
|
||||
// Window is hidden if: not visible, minimized, or not focused
|
||||
// 窗口隐藏的条件:不可见或最小化或失去焦点
|
||||
return !isVisible || isMinimized || !isFocused;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check window state:', error);
|
||||
return true; // Consider window hidden on error to ensure notifications can be shown
|
||||
logger.error('检查窗口状态失败:', error);
|
||||
return true; // 发生错误时认为窗口隐藏,确保通知能显示
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'Remote server is not active or configured', success: false };
|
||||
return { error: '远程服务器未激活或未配置', success: false };
|
||||
}
|
||||
|
||||
// Get refresh token
|
||||
// 获取刷新令牌
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
logger.error('No refresh token available for refresh operation.');
|
||||
return { error: 'No refresh token available', success: false };
|
||||
return { error: '没有可用的刷新令牌', 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 = `Token refresh failed: ${response.status} ${response.statusText} ${
|
||||
const errorMessage = `刷新令牌失败: ${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: 'Missing tokens in refresh response', success: false };
|
||||
return { error: '刷新响应中缺少令牌', 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: `Exception occurred during token refresh: ${errorMessage}`, success: false };
|
||||
return { error: `刷新令牌时发生异常: ${errorMessage}`, success: false };
|
||||
} finally {
|
||||
// Ensure the promise reference is cleared once the operation completes
|
||||
logger.debug('Clearing the refresh promise reference.');
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
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({
|
||||
|
||||
@@ -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('Toggle main window visibility via shortcut');
|
||||
logger.debug('通过快捷键切换主窗口可见性');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tray balloon notification
|
||||
* @param options Balloon options
|
||||
* @returns Operation result
|
||||
* 显示托盘气泡通知
|
||||
* @param options 气泡选项
|
||||
* @returns 操作结果
|
||||
*/
|
||||
@ipcClientEvent('showTrayNotification')
|
||||
async showNotification(options: ShowTrayNotificationParams) {
|
||||
logger.debug('Show tray balloon notification');
|
||||
logger.debug('显示托盘气泡通知');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const mainTray = this.app.trayManager.getMainTray();
|
||||
@@ -42,19 +42,19 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray icon
|
||||
* @param options Icon options
|
||||
* @returns Operation result
|
||||
* 更新托盘图标
|
||||
* @param options 图标选项
|
||||
* @returns 操作结果
|
||||
*/
|
||||
@ipcClientEvent('updateTrayIcon')
|
||||
async updateTrayIcon(options: UpdateTrayIconParams) {
|
||||
logger.debug('Update tray icon');
|
||||
logger.debug('更新托盘图标');
|
||||
|
||||
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('Failed to update tray icon:', error);
|
||||
logger.error('更新托盘图标失败:', error);
|
||||
return {
|
||||
error: String(error),
|
||||
success: false,
|
||||
@@ -74,19 +74,19 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray tooltip text
|
||||
* @param options Tooltip text options
|
||||
* @returns Operation result
|
||||
* 更新托盘提示文本
|
||||
* @param options 提示文本选项
|
||||
* @returns 操作结果
|
||||
*/
|
||||
@ipcClientEvent('updateTrayTooltip')
|
||||
async updateTrayTooltip(options: UpdateTrayTooltipParams) {
|
||||
logger.debug('Update tray tooltip text');
|
||||
logger.debug('更新托盘提示文本');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const mainTray = this.app.trayManager.getMainTray();
|
||||
@@ -98,7 +98,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
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() {
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -345,10 +345,7 @@ describe('LocalFileCtr', () => {
|
||||
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
||||
|
||||
expect(result).toEqual(mockResults);
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('test', {
|
||||
keywords: 'test',
|
||||
limit: 30,
|
||||
});
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('test', { limit: 30 });
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
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: 'Tray notifications are only supported on Windows platform',
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('TrayMenuCtr', () => {
|
||||
expect(mockGetMainTray).toHaveBeenCalled();
|
||||
expect(mockDisplayBalloon).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: 'Tray notifications are only supported on Windows platform',
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
success: false
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe('TrayMenuCtr', () => {
|
||||
const result = await trayMenuCtr.updateTrayIcon(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,7 @@ describe('TrayMenuCtr', () => {
|
||||
const result = await trayMenuCtr.updateTrayTooltip(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
@@ -248,7 +248,7 @@ describe('TrayMenuCtr', () => {
|
||||
|
||||
expect(mockUpdateTooltip).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: 'Tray functionality is only supported on Windows platform',
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,13 +19,13 @@ const ipcDecorator =
|
||||
};
|
||||
|
||||
/**
|
||||
* IPC client event decorator for controllers
|
||||
* controller 用的 ipc client event 装饰器
|
||||
*/
|
||||
export const ipcClientEvent = (method: keyof ClientDispatchEvents) =>
|
||||
ipcDecorator(method, 'client');
|
||||
|
||||
/**
|
||||
* IPC server event decorator for controllers
|
||||
* controller 用的 ipc server event 装饰器
|
||||
*/
|
||||
export const ipcServerEvent = (method: keyof ServerDispatchEvents) =>
|
||||
ipcDecorator(method, 'server');
|
||||
@@ -56,8 +56,8 @@ const protocolDecorator =
|
||||
|
||||
/**
|
||||
* Protocol handler decorator
|
||||
* @param urlType Protocol URL type (e.g., 'plugin')
|
||||
* @param action Action type (e.g., 'install')
|
||||
* @param urlType 协议URL类型 (如: 'plugin')
|
||||
* @param action 操作类型 (如: 'install')
|
||||
*/
|
||||
export const createProtocolHandler = (urlType: string) => (action: string) =>
|
||||
protocolDecorator(urlType, action);
|
||||
|
||||
@@ -358,9 +358,6 @@ 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) => {
|
||||
@@ -494,37 +491,4 @@ 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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,7 @@ export class ProtocolManager {
|
||||
|
||||
// Check if already registered
|
||||
const isCurrentlyRegistered = app.isDefaultProtocolClient(this.protocolScheme);
|
||||
logger.debug(
|
||||
`🔗 [Protocol] ${this.protocolScheme}:// is currently registered: ${isCurrentlyRegistered}`,
|
||||
);
|
||||
logger.debug(`🔗 [Protocol] Is currently default protocol client: ${isCurrentlyRegistered}`);
|
||||
|
||||
// Register as default protocol client
|
||||
let registrationResult: boolean;
|
||||
@@ -73,9 +71,7 @@ export class ProtocolManager {
|
||||
registrationResult = app.setAsDefaultProtocolClient(this.protocolScheme);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`🔗 [Protocol] Registration result for ${this.protocolScheme}://: ${registrationResult}`,
|
||||
);
|
||||
logger.debug(`🔗 [Protocol] Registration result: ${registrationResult}`);
|
||||
|
||||
if (!registrationResult) {
|
||||
logger.error(
|
||||
@@ -87,9 +83,7 @@ export class ProtocolManager {
|
||||
|
||||
// Verify registration
|
||||
const isRegisteredAfter = app.isDefaultProtocolClient(this.protocolScheme);
|
||||
logger.debug(
|
||||
`🔗 [Protocol] Final registration status for ${this.protocolScheme}://: ${isRegisteredAfter}`,
|
||||
);
|
||||
logger.debug(`🔗 [Protocol] Final registration status: ${isRegisteredAfter}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +123,7 @@ 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,21 +9,6 @@ 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;
|
||||
@@ -141,38 +126,16 @@ 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) {
|
||||
@@ -185,12 +148,7 @@ export class StaticFileServerManager {
|
||||
}
|
||||
|
||||
// 使用 FileService 获取文件
|
||||
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`,
|
||||
);
|
||||
const fileResult = await this.fileService.getFile(`desktop://${filePath}`);
|
||||
|
||||
// 再次检查响应状态
|
||||
if (res.destroyed || res.headersSent) {
|
||||
@@ -200,8 +158,11 @@ export class StaticFileServerManager {
|
||||
|
||||
// 设置响应头
|
||||
res.writeHead(200, {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
// 缓存一年
|
||||
'Access-Control-Allow-Origin': 'http://localhost:*',
|
||||
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
// 允许 localhost 的任意端口
|
||||
'Content-Length': Buffer.byteLength(fileResult.content),
|
||||
'Content-Type': fileResult.mimeType,
|
||||
});
|
||||
@@ -212,27 +173,16 @@ 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, {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('File Not Found');
|
||||
} else {
|
||||
res.writeHead(500, {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Content-Type': 'text/plain',
|
||||
});
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
} catch (writeError) {
|
||||
|
||||
@@ -141,29 +141,8 @@ export class UpdaterManager {
|
||||
// Mark application for exit
|
||||
this.app.isQuiting = true;
|
||||
|
||||
// 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);
|
||||
// Delay installation by 1 second to ensure window is closed
|
||||
autoUpdater.quitAndInstall();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
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,11 +23,12 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Build the command first, regardless of execution method
|
||||
const { cmd, args, commandString } = this.buildSearchCommand(options);
|
||||
logger.debug(`Executing command: ${commandString}`);
|
||||
const command = this.buildSearchCommand(options);
|
||||
logger.debug(`Executing command: ${command}`);
|
||||
|
||||
// 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
|
||||
@@ -136,39 +137,31 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
/**
|
||||
* Build mdfind command string
|
||||
* @param options Search options
|
||||
* @returns Command components (cmd, args array, and command string for logging)
|
||||
* @returns Complete command string
|
||||
*/
|
||||
private buildSearchCommand(options: SearchOptions): {
|
||||
args: string[];
|
||||
cmd: string;
|
||||
commandString: string;
|
||||
} {
|
||||
// Command and arguments array
|
||||
const cmd = 'mdfind';
|
||||
const args: string[] = [];
|
||||
private buildSearchCommand(options: SearchOptions): string {
|
||||
// Basic command
|
||||
let command = 'mdfind';
|
||||
|
||||
// Add options
|
||||
const mdFindOptions: string[] = [];
|
||||
|
||||
// macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing
|
||||
|
||||
// Search in specific directory
|
||||
if (options.onlyIn) {
|
||||
args.push('-onlyin', options.onlyIn);
|
||||
mdFindOptions.push(`-onlyin "${options.onlyIn}"`);
|
||||
}
|
||||
|
||||
// Live update
|
||||
if (options.liveUpdate) {
|
||||
args.push('-live');
|
||||
mdFindOptions.push('-live');
|
||||
}
|
||||
|
||||
// Detailed metadata
|
||||
if (options.detailed) {
|
||||
args.push(
|
||||
'-attr',
|
||||
'kMDItemDisplayName',
|
||||
'kMDItemContentType',
|
||||
'kMDItemKind',
|
||||
'kMDItemFSSize',
|
||||
'kMDItemFSCreationDate',
|
||||
'kMDItemFSContentChangeDate',
|
||||
mdFindOptions.push(
|
||||
'-attr kMDItemDisplayName kMDItemContentType kMDItemKind kMDItemFSSize kMDItemFSCreationDate kMDItemFSContentChangeDate',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,10 +171,9 @@ 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 a flexible name search rather than exact phrase match
|
||||
// treat it as plain text search
|
||||
if (!options.keywords.includes('kMDItem')) {
|
||||
// Use kMDItemFSName for filename matching with wildcards for better flexibility
|
||||
queryExpression = `kMDItemFSName == "*${options.keywords.replaceAll('"', '\\"')}*"cd`;
|
||||
queryExpression = `"${options.keywords.replaceAll('"', '\\"')}"`;
|
||||
} else {
|
||||
queryExpression = options.keywords;
|
||||
}
|
||||
@@ -252,15 +244,15 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Add query expression to args
|
||||
if (queryExpression) {
|
||||
args.push(queryExpression);
|
||||
// Combine complete command
|
||||
if (mdFindOptions.length > 0) {
|
||||
command += ' ' + mdFindOptions.join(' ');
|
||||
}
|
||||
|
||||
// Build command string for logging
|
||||
const commandString = `${cmd} ${args.map((arg) => (arg.includes(' ') || arg.includes('*') ? `"${arg}"` : arg)).join(' ')}`;
|
||||
// Finally add query expression
|
||||
command += ` ${queryExpression}`;
|
||||
|
||||
return { args, cmd, commandString };
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,531 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,349 +0,0 @@
|
||||
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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,492 +0,0 @@
|
||||
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 } from 'electron';
|
||||
import { type Protocol, type Session, protocol } from 'electron';
|
||||
import type { NextConfig } from 'next';
|
||||
import type NextNodeServer from 'next/dist/server/next-server';
|
||||
import assert from 'node:assert';
|
||||
@@ -202,11 +202,6 @@ 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
|
||||
@@ -214,7 +209,10 @@ export function createHandler({
|
||||
.config as NextConfig;
|
||||
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
|
||||
|
||||
app = next({ dir: standaloneDir }) as NextNodeServer;
|
||||
app = next({
|
||||
dev: false,
|
||||
dir: standaloneDir,
|
||||
}) as NextNodeServer;
|
||||
|
||||
handler = app.getRequestHandler();
|
||||
preparePromise = app.prepare();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/main/*"],
|
||||
"~common/*": ["src/common/*"]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# API Integration and Services
|
||||
|
||||
## Service Architecture
|
||||
|
||||
The app integrates with various APIs and services for chat functionality and data management.
|
||||
|
||||
### Core Services
|
||||
|
||||
#### Session Service
|
||||
- [services/session.ts](mdc:services/session.ts) - Session management API
|
||||
- [services/type.ts](mdc:services/type.ts) - Service type definitions
|
||||
- [services/mock/](mdc:services/mock) - Mock data for development
|
||||
- [services/mock/getGroupedSessions.json](mdc:services/mock/getGroupedSessions.json) - Mock session data
|
||||
|
||||
#### OpenAI Integration
|
||||
- [store/openai.ts](mdc:store/openai.ts) - OpenAI configuration store
|
||||
- Manages API keys and proxy settings
|
||||
- Persists configuration in AsyncStorage
|
||||
|
||||
### API Utilities
|
||||
|
||||
#### HTTP Utilities
|
||||
- [utils/fetchSSE.ts](mdc:utils/fetchSSE.ts) - Server-Sent Events implementation
|
||||
- [utils/trpc.ts](mdc:utils/trpc.ts) - tRPC client configuration
|
||||
- [utils/jwt.ts](mdc:utils/jwt.ts) - JWT token handling
|
||||
|
||||
#### Data Processing
|
||||
- [utils/merge.ts](mdc:utils/merge.ts) - Deep merge utility
|
||||
- [utils/componentScanner.ts](mdc:utils/componentScanner.ts) - Component scanning utilities
|
||||
|
||||
|
||||
## Chat Integration
|
||||
|
||||
### OpenAI API
|
||||
- Uses Server-Sent Events for streaming responses
|
||||
- Supports multiple models and configurations
|
||||
- Handles API key management and proxy settings
|
||||
|
||||
### Message Handling
|
||||
- Real-time message streaming
|
||||
- Error handling and retry logic
|
||||
- Message persistence in local storage
|
||||
|
||||
## Data Flow Patterns
|
||||
|
||||
### State Management Integration
|
||||
```typescript
|
||||
// Store updates trigger API calls
|
||||
const sendMessage = async (message: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.sendMessage(message);
|
||||
updateChatState(response);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Implement proper error boundaries
|
||||
- Show user-friendly error messages
|
||||
- Retry failed requests with exponential backoff
|
||||
- Log errors for debugging
|
||||
|
||||
### Loading States
|
||||
- Show loading indicators during API calls
|
||||
- Implement skeleton screens for better UX
|
||||
- Handle partial loading states
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Mock Data
|
||||
- Use mock services during development
|
||||
- [services/mock/](mdc:services/mock) - Mock data files
|
||||
- Switch between mock and real APIs easily
|
||||
|
||||
### Environment Configuration
|
||||
- Use environment variables for API keys
|
||||
- Support different environments (dev, staging, prod)
|
||||
- Secure sensitive configuration
|
||||
|
||||
### API Versioning
|
||||
- Support multiple API versions
|
||||
- Implement graceful deprecation
|
||||
- Maintain backward compatibility
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Management
|
||||
- Never commit API keys to version control
|
||||
- Use secure storage for sensitive data
|
||||
- Implement proper key rotation
|
||||
|
||||
### Data Validation
|
||||
- Validate all API responses
|
||||
- Use TypeScript for type safety
|
||||
- Implement input sanitization
|
||||
|
||||
### Network Security
|
||||
- Use HTTPS for all API calls
|
||||
- Implement certificate pinning if needed
|
||||
- Handle network errors gracefully
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# App Structure and Routing
|
||||
|
||||
## Expo Router File Structure
|
||||
|
||||
The app uses Expo Router with file-based routing. Each directory represents a route group or screen.
|
||||
|
||||
### Main Routes
|
||||
- [src/app/index.tsx](mdc:src/app/index.tsx) - Root redirect logic (signed in → session, signed out → sign-in)
|
||||
- [src/app/_layout.tsx](mdc:src/app/_layout.tsx) - Root layout with providers ( Toast, Portal, ActionSheet)
|
||||
|
||||
### Chat Routes
|
||||
- [src/app/(chat)/_layout.tsx](mdc:src/app/(chat)/_layout.tsx) - Chat layout with bottom tabs
|
||||
- [src/app/(chat)/session/index.tsx](mdc:src/app/(chat)/session/index.tsx) - Session list screen
|
||||
- [src/app/(chat)/session/_layout.tsx](mdc:src/app/(chat)/session/_layout.tsx) - Session layout
|
||||
- [src/app/(chat)/chat/index.tsx](mdc:src/app/(chat)/chat/index.tsx) - Main chat interface
|
||||
- [src/app/(chat)/chat/_layout.tsx](mdc:src/app/(chat)/chat/_layout.tsx) - Chat layout
|
||||
- [src/app/(chat)/detail/index.tsx](mdc:src/app/(chat)/detail/index.tsx) - Chat detail view
|
||||
- [src/app/(chat)/detail/_layout.tsx](mdc:src/app/(chat)/detail/_layout.tsx) - Detail layout
|
||||
|
||||
### Settings Routes
|
||||
- [src/app/(setting)/_layout.tsx](mdc:src/app/(setting)/_layout.tsx) - Settings layout
|
||||
- [src/app/(setting)/setting/index.tsx](mdc:src/app/(setting)/setting/index.tsx) - Settings screen
|
||||
- [src/app/(setting)/setting/_layout.tsx](mdc:src/app/(setting)/setting/_layout.tsx) - Settings layout
|
||||
- [src/app/(setting)/setting/provider.tsx](mdc:src/app/(setting)/setting/provider.tsx) - Settings provider
|
||||
|
||||
### Playground Routes
|
||||
- [src/app/playground/_layout.tsx](mdc:src/app/playground/_layout.tsx) - Playground layout
|
||||
- [src/app/playground/index.tsx](mdc:src/app/playground/index.tsx) - Component playground
|
||||
- [src/app/playground/highlighter.tsx](mdc:src/app/playground/highlighter.tsx) - Code highlighting demo
|
||||
- [src/app/playground/markdown.tsx](mdc:src/app/playground/markdown.tsx) - Markdown rendering demo
|
||||
- [src/app/playground/toast.tsx](mdc:src/app/playground/toast.tsx) - Toast notifications demo
|
||||
- [src/app/playground/tooltip.tsx](mdc:src/app/playground/tooltip.tsx) - Tooltip component demo
|
||||
|
||||
## Route Groups
|
||||
- `(chat)` - Main chat functionality
|
||||
- `(setting)` - App settings and configuration
|
||||
- `playground` - Component development and testing
|
||||
|
||||
## Navigation Patterns
|
||||
- Use `Redirect` component for programmatic navigation
|
||||
- Use bottom tabs for main navigation in chat section
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
description: 组件导入规范 - demos 和组件内部的导入路径规范
|
||||
globs: src/components/**/*.tsx,src/components/**/*.ts
|
||||
---
|
||||
|
||||
# 组件导入规范
|
||||
|
||||
本规范定义了 LobeChat Mobile 组件库中不同文件类型应该如何导入依赖。
|
||||
|
||||
## 规范概述
|
||||
|
||||
### 1. Demo 文件 (`demos/*.tsx`)
|
||||
|
||||
**✅ 正确做法:从 `@lobehub/ui-rn` 导入所有公开组件**
|
||||
|
||||
```tsx
|
||||
// demos/basic.tsx
|
||||
import { Button, Space, Text, useTheme } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export default () => {
|
||||
const token = useTheme();
|
||||
return (
|
||||
<Space gap={16}>
|
||||
<Button>示例按钮</Button>
|
||||
<Text>示例文本</Text>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**❌ 错误做法:使用 `@/components` 路径**
|
||||
|
||||
```tsx
|
||||
// ❌ 不要这样做
|
||||
import Text from '@/components/Text';
|
||||
import { useTheme } from '@/components/styles';
|
||||
```
|
||||
|
||||
### 2. 组件主文件 (`ComponentName.tsx`)
|
||||
|
||||
**✅ 正确做法:使用相对路径引用其他组件**
|
||||
|
||||
```tsx
|
||||
// Card/Card.tsx
|
||||
import React from 'react';
|
||||
import Block from '../Block'; // ✅ 相对路径
|
||||
import Text from '../Text'; // ✅ 相对路径
|
||||
|
||||
import { useStyles } from './style';
|
||||
import type { CardProps } from './type';
|
||||
```
|
||||
|
||||
**❌ 错误做法:使用绝对路径 `@/components`**
|
||||
|
||||
```tsx
|
||||
// ❌ 不要这样做
|
||||
import Block from '@/components/Block';
|
||||
import Text from '@/components/Text';
|
||||
```
|
||||
|
||||
### 3. 样式文件 (`style.ts`)
|
||||
|
||||
**✅ 正确做法:使用 `@/components/styles` 导入主题工具**
|
||||
|
||||
```tsx
|
||||
// style.ts
|
||||
import { createStyles } from '@/components/styles'; // ✅ 允许
|
||||
|
||||
export const useStyles = createStyles(({ token, stylish }) => ({
|
||||
root: {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### 4. 类型文件 (`type.ts`)
|
||||
|
||||
**✅ 正确做法:使用相对路径引用其他组件的类型**
|
||||
|
||||
```tsx
|
||||
// Card/type.ts
|
||||
import type { BlockProps } from '../Block'; // ✅ 相对路径
|
||||
import type { ViewProps } from 'react-native';
|
||||
|
||||
export interface CardProps extends ViewProps {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**❌ 错误做法:使用绝对路径**
|
||||
|
||||
```tsx
|
||||
// ❌ 不要这样做
|
||||
import type { BlockProps } from '@/components/Block';
|
||||
```
|
||||
|
||||
## 导入顺序规范
|
||||
|
||||
遵循以下导入顺序:
|
||||
|
||||
```tsx
|
||||
// 1. 外部库 - React 相关
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
// 2. 外部库 - 第三方库
|
||||
import { Lucide } from '@lobehub/ui';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 3. 内部组件 (demos 使用 @lobehub/ui-rn)
|
||||
import { Button, Space, Text, useTheme } from '@lobehub/ui-rn';
|
||||
|
||||
// 4. 相对路径导入 (组件内部使用)
|
||||
import { useStyles } from './style';
|
||||
import type { ComponentProps } from './type';
|
||||
```
|
||||
|
||||
## 常见场景示例
|
||||
|
||||
### Demo 文件完整示例
|
||||
|
||||
```tsx
|
||||
// demos/basic.tsx
|
||||
import { Button, Space, Text } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<Space gap={16}>
|
||||
<Button type="primary">主要按钮</Button>
|
||||
<Button type="default">默认按钮</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 组件文件完整示例
|
||||
|
||||
```tsx
|
||||
// Alert/index.tsx
|
||||
import React, { memo } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
// 相对路径引用其他组件
|
||||
import ActionIcon from '../ActionIcon';
|
||||
import Icon from '../Icon';
|
||||
import Text from '../Text';
|
||||
|
||||
// 本地文件
|
||||
import { useStyles } from './style';
|
||||
import type { AlertProps } from './type';
|
||||
|
||||
const Alert = memo<AlertProps>((props) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
export default Alert;
|
||||
```
|
||||
|
||||
### 包含 Theme 的示例
|
||||
|
||||
```tsx
|
||||
// demos/advanced.tsx
|
||||
import { Button, Text, useTheme } from '@lobehub/ui-rn'; // ✅ 从 @lobehub/ui-rn 导入
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export default () => {
|
||||
const token = useTheme(); // 使用主题 token
|
||||
|
||||
return (
|
||||
<View style={{ padding: token.padding }}>
|
||||
<Text>示例内容</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 特殊情况
|
||||
|
||||
### 1. ThemeProvider 内部文件
|
||||
|
||||
ThemeProvider 模块内部可以使用绝对路径引用自己的子模块:
|
||||
|
||||
```tsx
|
||||
// ThemeProvider/getDesignToken.ts
|
||||
import { lightAlgorithm } from '@/components/styles/algorithm/light'; // ✅ 允许
|
||||
import type { AliasToken } from '@/components/styles/interface'; // ✅ 允许
|
||||
```
|
||||
|
||||
### 2. 工具函数和类型
|
||||
|
||||
非组件的工具文件可以使用 `@/components/styles`:
|
||||
|
||||
```tsx
|
||||
// theme/createStyles.ts
|
||||
import type { ThemeAppearance } from '@/components/ThemeProvider/types'; // ✅ 允许
|
||||
```
|
||||
|
||||
## 验证命令
|
||||
|
||||
使用以下命令验证导入规范:
|
||||
|
||||
```bash
|
||||
# 检查 demos 文件中的 @/components 引用(应该为 0)
|
||||
find src/components -path "*/demos/*.tsx" -exec grep -l "@/components" {} \;
|
||||
|
||||
# 检查组件间的绝对路径引用(应该很少)
|
||||
grep -r "from '@/components/[A-Z]" src/components --include="*.tsx" --exclude-dir="demos"
|
||||
```
|
||||
|
||||
## 自动修复脚本
|
||||
|
||||
如果发现违反规范的导入,可以使用以下脚本修复:
|
||||
|
||||
```javascript
|
||||
// fix-imports.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 检测并修复 demos 文件
|
||||
function fixDemoImports(filePath) {
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// 将 @/components/ComponentName 改为从 @lobehub/ui-rn 导入
|
||||
content = content.replace(
|
||||
/import\s+(\w+)\s+from\s+['"]@\/components\/(\w+)['"]/g,
|
||||
(match, name, component) => {
|
||||
// 添加到 @lobehub/ui-rn 导入中
|
||||
return `// import ${name} from '@lobehub/ui-rn'`;
|
||||
}
|
||||
);
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
```
|
||||
|
||||
## 常见错误和解决方案
|
||||
|
||||
### 错误 1: Demo 文件使用 `@/components`
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import Text from '@/components/Text';
|
||||
|
||||
// ✅ 修复
|
||||
import { Text } from '@lobehub/ui-rn';
|
||||
```
|
||||
|
||||
### 错误 2: 组件文件使用绝对路径引用其他组件
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import Block from '@/components/Block';
|
||||
|
||||
// ✅ 修复
|
||||
import Block from '../Block';
|
||||
```
|
||||
|
||||
### 错误 3: 重复导入
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import { Button } from '@lobehub/ui-rn';
|
||||
import Button from '../../Button';
|
||||
|
||||
// ✅ 修复 - 只保留一个
|
||||
import { Button } from '@lobehub/ui-rn'; // demos 中
|
||||
// 或
|
||||
import Button from '../../Button'; // 组件内部
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
| 文件类型 | 导入其他组件 | 导入 Theme | 示例 |
|
||||
|---------|------------|-----------|------|
|
||||
| `demos/*.tsx` | `@lobehub/ui-rn` | `@lobehub/ui-rn` | `import { Button, useTheme } from '@lobehub/ui-rn'` |
|
||||
| `ComponentName.tsx` | 相对路径 | 相对路径或 `@/components/styles` | `import Block from '../Block'` |
|
||||
| `style.ts` | - | `@/components/styles` | `import { createStyles } from '@/components/styles'` |
|
||||
| `type.ts` | 相对路径(类型) | 相对路径(类型) | `import type { BlockProps } from '../Block'` |
|
||||
|
||||
**核心原则:**
|
||||
- ✅ Demos 展示公开 API,使用 `@lobehub/ui-rn`
|
||||
- ✅ 组件内部保持模块化,使用相对路径
|
||||
- ✅ 样式文件可以使用 `@/components/styles` 获取主题工具
|
||||
- ✅ 保持导入语句的一致性和清晰性
|
||||
@@ -0,0 +1,791 @@
|
||||
---
|
||||
globs: src/components/**
|
||||
description: 组件库标准结构、样式管理和开发流程规范
|
||||
---
|
||||
|
||||
# 组件库结构规范
|
||||
|
||||
本规范定义了 LobeChat Mobile 组件库的标准结构、样式管理方式和开发流程。
|
||||
|
||||
## 组件目录结构
|
||||
|
||||
每个组件应遵循以下标准目录结构:
|
||||
|
||||
```
|
||||
src/components/ComponentName/
|
||||
├── ComponentName.tsx # 组件主文件
|
||||
├── index.ts # 导出文件
|
||||
├── index.md # 组件文档
|
||||
├── style.ts # 样式定义
|
||||
├── type.ts # TypeScript 类型定义
|
||||
└── demos/ # 示例目录
|
||||
├── index.tsx # 示例入口
|
||||
├── basic.tsx # 基础示例
|
||||
└── ... # 其他示例
|
||||
```
|
||||
|
||||
### 文件职责说明
|
||||
|
||||
#### 1. `ComponentName.tsx` - 组件主文件
|
||||
|
||||
组件的核心实现文件,命名采用 PascalCase:
|
||||
|
||||
```tsx
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { cva } from '@/components/styles';
|
||||
import { useStyles } from './style';
|
||||
import type { ComponentNameProps } from './type';
|
||||
|
||||
const ComponentName = memo<ComponentNameProps>(
|
||||
({ variant = 'default', children, style, ...rest }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
// 使用 CVA 管理样式变体
|
||||
const variants = useMemo(
|
||||
() =>
|
||||
cva(styles.root, {
|
||||
variants: {
|
||||
variant: {
|
||||
default: styles.default,
|
||||
primary: styles.primary,
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'primary',
|
||||
pressed: true,
|
||||
style: styles.primaryActive,
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}),
|
||||
[styles],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={({ hovered, pressed }) => [
|
||||
variants({ variant, hovered, pressed }),
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComponentName.displayName = 'ComponentName';
|
||||
|
||||
export default ComponentName;
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- 使用 `memo` 包裹组件优化性能
|
||||
- 从 `./style` 导入 `useStyles` 钩子
|
||||
- 从 `./type` 导入类型定义
|
||||
- 使用 `cva` 管理复杂的样式变体逻辑
|
||||
- 使用 `useMemo` 缓存 CVA 配置,依赖 `styles`
|
||||
- 设置 `displayName` 便于调试
|
||||
|
||||
#### 2. `style.ts` - 样式定义文件
|
||||
|
||||
使用 `createStyles` 创建主题感知的样式:
|
||||
|
||||
```tsx
|
||||
import { createStyles } from '@/components/styles';
|
||||
|
||||
export const useStyles = createStyles(({ token, stylish }) => ({
|
||||
// 基础样式
|
||||
root: {
|
||||
borderRadius: token.borderRadius,
|
||||
position: 'relative' as const,
|
||||
},
|
||||
|
||||
// 变体样式 - 使用 stylish 预设
|
||||
default: stylish.variantFilled,
|
||||
primary: stylish.variantOutlined,
|
||||
|
||||
// 交互状态样式
|
||||
primaryActive: stylish.variantOutlinedActive,
|
||||
primaryHover: stylish.variantOutlinedHover,
|
||||
|
||||
// 附加效果
|
||||
shadow: stylish.shadow,
|
||||
glass: stylish.blur,
|
||||
}));
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- 统一使用 `createStyles` 工厂函数
|
||||
- 通过 `token` 访问主题变量(颜色、间距、圆角等)
|
||||
- 通过 `stylish` 访问预设样式(变体、阴影、模糊等)
|
||||
- 使用 `as const` 确保字面量类型
|
||||
- 样式名称应语义化,便于在 CVA 中引用
|
||||
|
||||
#### 3. `type.ts` - 类型定义文件
|
||||
|
||||
定义组件的 Props 接口:
|
||||
|
||||
```tsx
|
||||
import type { ViewProps } from 'react-native';
|
||||
|
||||
export interface ComponentNameProps extends ViewProps {
|
||||
/**
|
||||
* 样式变体
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: 'default' | 'primary' | 'secondary';
|
||||
|
||||
/**
|
||||
* 是否显示阴影
|
||||
*/
|
||||
shadow?: boolean;
|
||||
|
||||
/**
|
||||
* 是否启用玻璃效果
|
||||
*/
|
||||
glass?: boolean;
|
||||
|
||||
/**
|
||||
* 点击回调
|
||||
*/
|
||||
onPress?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- 继承自基础组件的 Props(如 `ViewProps`, `FlexboxProps`)
|
||||
- 为每个 prop 添加 JSDoc 注释
|
||||
- 使用 `@default` 标注默认值
|
||||
- 使用字面量类型限制可选值
|
||||
|
||||
#### 4. `index.ts` - 导出文件
|
||||
|
||||
标准导出格式:
|
||||
|
||||
```tsx
|
||||
export { default } from './ComponentName';
|
||||
export type * from './type';
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- 默认导出组件本身
|
||||
- 使用 `export type *` 导出所有类型
|
||||
|
||||
#### 5. `index.md` - 组件文档
|
||||
|
||||
使用 YAML frontmatter 定义元数据:
|
||||
|
||||
```markdown
|
||||
---
|
||||
group: Layout
|
||||
title: ComponentName
|
||||
description: 组件的简短描述,说明主要功能和特点。
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ 功能特性 1
|
||||
- ✅ 功能特性 2
|
||||
- ✅ TypeScript 支持
|
||||
- ✅ 主题适配
|
||||
|
||||
## Basic Usage
|
||||
|
||||
\`\`\`tsx
|
||||
import { ComponentName } from '@lobehub/ui-rn';
|
||||
|
||||
<ComponentName variant="primary">
|
||||
<Text>示例内容</Text>
|
||||
</ComponentName>
|
||||
\`\`\`
|
||||
|
||||
## API
|
||||
|
||||
详细的 Props 说明...
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- `group`: 组件分类(Layout、Form、Display 等)
|
||||
- `title`: 组件名称
|
||||
- `description`: 简短描述
|
||||
- 包含功能特性、使用示例、API 文档
|
||||
|
||||
#### 6. `demos/` - 示例目录
|
||||
|
||||
demos 目录用于存放组件的交互式示例,这些示例会在 Playground 中展示。
|
||||
|
||||
**目录结构:**
|
||||
|
||||
```
|
||||
demos/
|
||||
├── index.tsx # Demo 配置入口(必需)
|
||||
├── basic.tsx # 基础用法示例(必需)
|
||||
├── variants.tsx # 变体示例
|
||||
├── sizes.tsx # 尺寸示例
|
||||
├── colors.tsx # 颜色示例
|
||||
├── states.tsx # 状态示例
|
||||
└── ... # 其他特定功能示例
|
||||
```
|
||||
|
||||
##### 6.1 `demos/index.tsx` - Demo 配置文件
|
||||
|
||||
配置文件定义了所有 demo 的元数据和加载方式:
|
||||
|
||||
```tsx
|
||||
import type { DemoConfig } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
|
||||
import BasicDemo from './basic';
|
||||
import ColorsDemo from './colors';
|
||||
import SizesDemo from './sizes';
|
||||
import VariantsDemo from './variants';
|
||||
|
||||
const demos: DemoConfig = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
{ component: <VariantsDemo />, key: 'variants', title: '不同视觉风格' },
|
||||
{ component: <SizesDemo />, key: 'sizes', title: '尺寸' },
|
||||
{ component: <ColorsDemo />, key: 'colors', title: '颜色' },
|
||||
];
|
||||
|
||||
export default demos;
|
||||
```
|
||||
|
||||
**DemoConfig 类型定义:**
|
||||
|
||||
```tsx
|
||||
interface DemoItem {
|
||||
/** Demo 组件实例 */
|
||||
component: React.ReactNode;
|
||||
/** Demo 唯一标识,使用 kebab-case */
|
||||
key: string;
|
||||
/** Demo 标题,用于在 Playground 中显示 */
|
||||
title: string;
|
||||
}
|
||||
|
||||
type DemoConfig = DemoItem[];
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- 从 `@lobehub/ui-rn` 导入 `DemoConfig` 类型
|
||||
- 导入所有 demo 组件(使用 PascalCase + Demo 后缀)
|
||||
- 配置数组按重要性排序,`basic` 应始终放在第一位
|
||||
- `key` 使用 kebab-case 命名(如 `basic`, `variants`, `disabled-state`)
|
||||
- `title` 使用中文描述,简洁明了
|
||||
- 使用默认导出导出配置数组
|
||||
|
||||
##### 6.2 Demo 文件规范
|
||||
|
||||
每个 demo 文件应遵循以下规范:
|
||||
|
||||
```tsx
|
||||
// demos/basic.tsx
|
||||
import { ComponentName, Flexbox } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<ComponentName variant="default">
|
||||
<Text>默认示例</Text>
|
||||
</ComponentName>
|
||||
<ComponentName variant="primary">
|
||||
<Text>主要示例</Text>
|
||||
</ComponentName>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**关键要点:**
|
||||
- 使用默认导出
|
||||
- 使用箭头函数组件
|
||||
- 不需要包裹额外的容器组件(Playground 会自动处理)
|
||||
- 使用 `Flexbox` 组件进行布局,设置合适的 `gap` 值
|
||||
- 示例应简洁明了,专注于展示单一特性或相关特性组
|
||||
|
||||
##### 6.3 Demo 文件命名规范
|
||||
|
||||
推荐的 demo 文件名及其用途:
|
||||
|
||||
| 文件名 | 用途 | 优先级 |
|
||||
|--------|------|--------|
|
||||
| `basic.tsx` | 基础用法,展示最常见的使用场景 | 必需 ⭐ |
|
||||
| `variants.tsx` | 展示所有样式变体(filled, outlined 等) | 推荐 |
|
||||
| `sizes.tsx` | 展示不同尺寸(small, medium, large) | 推荐 |
|
||||
| `colors.tsx` | 展示不同颜色主题 | 可选 |
|
||||
| `disabled.tsx` | 展示禁用状态 | 可选 |
|
||||
| `loading.tsx` | 展示加载状态 | 可选 |
|
||||
| `interactive.tsx` | 展示交互行为(点击、hover 等) | 可选 |
|
||||
| `layout.tsx` | 展示布局应用场景 | 可选 |
|
||||
| `advanced.tsx` | 展示高级用法或复杂场景 | 可选 |
|
||||
|
||||
##### 6.4 Demo 最佳实践
|
||||
|
||||
**✅ 推荐的做法:**
|
||||
|
||||
```tsx
|
||||
// 1. 清晰的分组和间距
|
||||
export default () => {
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<Text style={{ fontWeight: 'bold' }}>默认变体</Text>
|
||||
<Block variant="filled">
|
||||
<Text>Filled Block</Text>
|
||||
</Block>
|
||||
|
||||
<Text style={{ fontWeight: 'bold' }}>轮廓变体</Text>
|
||||
<Block variant="outlined">
|
||||
<Text>Outlined Block</Text>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
// 2. 使用状态展示交互
|
||||
export default () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<Button onPress={() => setCount(c => c + 1)}>
|
||||
<Text>点击次数: {count}</Text>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// 3. 使用 Alert 或 Toast 展示反馈
|
||||
export default () => {
|
||||
return (
|
||||
<Button onPress={() => Alert.alert('提示', '按钮被点击了')}>
|
||||
<Text>点击我</Text>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**❌ 避免的做法:**
|
||||
|
||||
```tsx
|
||||
// ❌ 不要使用外部状态管理
|
||||
import { useStore } from '@/store'; // 避免
|
||||
|
||||
// ❌ 不要使用复杂的业务逻辑
|
||||
export default () => {
|
||||
const data = fetchDataFromAPI(); // 避免
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ 不要使用过多嵌套
|
||||
export default () => {
|
||||
return (
|
||||
<View>
|
||||
<View>
|
||||
<View>
|
||||
<View> // 过度嵌套
|
||||
<Component />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ 不要在 demo 中定义复杂组件
|
||||
const ComplexComponent = () => { /* 大量代码 */ }; // 应该独立文件
|
||||
export default () => <ComplexComponent />;
|
||||
```
|
||||
|
||||
##### 6.5 常见 Demo 模板
|
||||
|
||||
**基础用法模板:**
|
||||
|
||||
```tsx
|
||||
import { ComponentName } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
|
||||
export default () => {
|
||||
return <ComponentName>基本示例</ComponentName>;
|
||||
};
|
||||
```
|
||||
|
||||
**变体展示模板:**
|
||||
|
||||
```tsx
|
||||
import { ComponentName, Flexbox } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<ComponentName variant="filled">Filled</ComponentName>
|
||||
<ComponentName variant="outlined">Outlined</ComponentName>
|
||||
<ComponentName variant="borderless">Borderless</ComponentName>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**交互示例模板:**
|
||||
|
||||
```tsx
|
||||
import { ComponentName, Flexbox } from '@lobehub/ui-rn';
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Text } from 'react-native';
|
||||
|
||||
export default () => {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<ComponentName
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onPress={() => Alert.alert('提示', `当前值: ${value}`)}
|
||||
/>
|
||||
<Text>当前值: {value}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## CVA (Class Variance Authority) 用法
|
||||
|
||||
CVA 用于管理组件的样式变体逻辑,提供类型安全的样式组合。
|
||||
|
||||
### 基础用法
|
||||
|
||||
```tsx
|
||||
import { cva } from '@/components/styles';
|
||||
|
||||
const variants = useMemo(
|
||||
() =>
|
||||
cva(styles.root, {
|
||||
// 定义变体
|
||||
variants: {
|
||||
variant: {
|
||||
filled: styles.filled,
|
||||
outlined: styles.outlined,
|
||||
borderless: styles.borderless,
|
||||
},
|
||||
size: {
|
||||
small: styles.small,
|
||||
medium: styles.medium,
|
||||
large: styles.large,
|
||||
},
|
||||
},
|
||||
// 默认值
|
||||
defaultVariants: {
|
||||
variant: 'filled',
|
||||
size: 'medium',
|
||||
},
|
||||
}),
|
||||
[styles],
|
||||
);
|
||||
|
||||
// 使用变体
|
||||
<View style={variants({ variant: 'outlined', size: 'large' })} />
|
||||
```
|
||||
|
||||
### 复合变体 (Compound Variants)
|
||||
|
||||
处理多个变体组合时的特殊样式:
|
||||
|
||||
```tsx
|
||||
const variants = useMemo(
|
||||
() =>
|
||||
cva(styles.root, {
|
||||
variants: {
|
||||
variant: {
|
||||
filled: styles.filled,
|
||||
outlined: styles.outlined,
|
||||
},
|
||||
hovered: {
|
||||
false: null,
|
||||
true: styles.hover,
|
||||
},
|
||||
pressed: {
|
||||
false: null,
|
||||
true: styles.pressed,
|
||||
},
|
||||
},
|
||||
// 复合变体:特定组合的特殊样式
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'outlined',
|
||||
hovered: true,
|
||||
style: styles.outlinedHover,
|
||||
},
|
||||
{
|
||||
variant: 'outlined',
|
||||
pressed: true,
|
||||
style: styles.outlinedActive,
|
||||
},
|
||||
{
|
||||
variant: 'filled',
|
||||
pressed: true,
|
||||
style: styles.filledActive,
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'filled',
|
||||
},
|
||||
}),
|
||||
[styles],
|
||||
);
|
||||
```
|
||||
|
||||
### 与交互状态结合
|
||||
|
||||
结合 React Native 的交互状态(hovered、pressed):
|
||||
|
||||
```tsx
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
variants({ variant, hovered, pressed }),
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
**CVA 最佳实践:**
|
||||
- 始终使用 `useMemo` 缓存 CVA 配置,避免重复创建
|
||||
- 将 `styles` 对象加入依赖数组
|
||||
- 将交互状态(hovered、pressed)作为变体处理
|
||||
- 使用 `compoundVariants` 处理组合逻辑,保持代码清晰
|
||||
- 为布尔变体提供 `false: null` 选项
|
||||
|
||||
## 开发流程
|
||||
|
||||
### 添加新组件
|
||||
|
||||
1. **创建组件目录结构**
|
||||
```bash
|
||||
mkdir -p src/components/NewComponent/demos
|
||||
```
|
||||
|
||||
2. **创建必需文件**
|
||||
|
||||
按以下顺序创建文件:
|
||||
|
||||
- `type.ts` - TypeScript 类型定义
|
||||
- `style.ts` - 样式定义
|
||||
- `NewComponent.tsx` - 组件实现
|
||||
- `index.ts` - 导出文件
|
||||
- `index.md` - 组件文档(包含 frontmatter)
|
||||
- `demos/basic.tsx` - 基础示例
|
||||
- `demos/index.tsx` - Demo 配置文件(必需)
|
||||
|
||||
3. **配置 demos/index.tsx**
|
||||
|
||||
创建 demo 配置文件,引入所有示例:
|
||||
|
||||
```tsx
|
||||
import type { DemoConfig } from '@lobehub/ui-rn';
|
||||
import React from 'react';
|
||||
|
||||
import BasicDemo from './basic';
|
||||
// 引入其他 demo...
|
||||
|
||||
const demos: DemoConfig = [
|
||||
{ component: <BasicDemo />, key: 'basic', title: '基础用法' },
|
||||
// 添加其他 demo 配置...
|
||||
];
|
||||
|
||||
export default demos;
|
||||
```
|
||||
|
||||
4. **更新组件索引**
|
||||
|
||||
在 [src/components/index.ts](mdc:src/components/index.ts) 中添加导出:
|
||||
```tsx
|
||||
export { default as NewComponent, type NewComponentProps } from './NewComponent';
|
||||
```
|
||||
|
||||
5. **生成 Playground 数据** ⚠️
|
||||
|
||||
添加或修改组件后,**必须**运行以下命令更新 Playground:
|
||||
|
||||
```bash
|
||||
pnpm run build:playground
|
||||
```
|
||||
|
||||
或使用完整命令:
|
||||
|
||||
```bash
|
||||
pnpm run workflow:playground
|
||||
```
|
||||
|
||||
这个命令会:
|
||||
- 扫描所有组件目录
|
||||
- 读取 `index.md` 中的 frontmatter 元数据
|
||||
- 生成 `app/playground/.data/index.json` 数据文件
|
||||
- 生成 `app/playground/.data/import.ts` 导入文件
|
||||
- 更新 Playground 应用的组件列表
|
||||
|
||||
**注意**:如果不运行此命令,新组件将不会出现在 Playground 中。
|
||||
|
||||
### 更新现有组件
|
||||
|
||||
1. **修改组件实现**
|
||||
- 更新 `ComponentName.tsx` 的组件逻辑
|
||||
- 更新 `style.ts` 的样式定义
|
||||
- 更新 `type.ts` 的类型定义
|
||||
|
||||
2. **更新文档和示例**
|
||||
- 更新 `index.md` 文档,确保示例代码与实际 API 一致
|
||||
- 如果添加了新功能,创建对应的 demo 文件(如 `demos/newFeature.tsx`)
|
||||
- 在 `demos/index.tsx` 中注册新的 demo
|
||||
|
||||
3. **运行 Playground 更新**
|
||||
- 如果修改了组件的 `group` 或 `title`,运行 `pnpm run build:playground`
|
||||
- 如果添加了新的 demo,也需要运行此命令
|
||||
|
||||
### 测试组件
|
||||
|
||||
在 Playground 中测试组件:
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
pnpm start
|
||||
|
||||
# 在 iOS 模拟器中运行
|
||||
pnpm ios
|
||||
|
||||
# 在 Android 模拟器中运行
|
||||
pnpm android
|
||||
```
|
||||
|
||||
导航到 Playground 页面查看和测试组件。
|
||||
|
||||
## 命名规范
|
||||
|
||||
- **组件文件**:PascalCase(如 `ActionIcon.tsx`)
|
||||
- **样式文件**:camelCase(如 `style.ts`)
|
||||
- **类型文件**:camelCase(如 `type.ts`)
|
||||
- **导出文件**:固定名称 `index.ts`
|
||||
- **文档文件**:固定名称 `index.md`
|
||||
|
||||
### Demo 命名规范
|
||||
|
||||
- **Demo 文件**:camelCase(如 `basic.tsx`, `variants.tsx`, `disabled.tsx`)
|
||||
- **Demo 配置文件**:固定名称 `demos/index.tsx`
|
||||
- **Demo 导入名称**:PascalCase + Demo 后缀(如 `BasicDemo`, `VariantsDemo`)
|
||||
- **Demo key**:kebab-case(如 `'basic'`, `'variants'`, `'disabled-state'`)
|
||||
- **Demo title**:中文描述(如 `'基础用法'`, `'不同视觉风格'`, `'禁用状态'`)
|
||||
|
||||
**示例:**
|
||||
|
||||
```tsx
|
||||
// demos/disabled-state.tsx
|
||||
export default () => { /* ... */ };
|
||||
|
||||
// demos/index.tsx
|
||||
import DisabledStateDemo from './disabled-state';
|
||||
|
||||
const demos: DemoConfig = [
|
||||
{ component: <DisabledStateDemo />, key: 'disabled-state', title: '禁用状态' },
|
||||
];
|
||||
```
|
||||
|
||||
## 样式最佳实践
|
||||
|
||||
1. **优先使用 stylish 预设**
|
||||
```tsx
|
||||
filled: stylish.variantFilled, // ✅ 推荐
|
||||
filled: { backgroundColor: token.colorBgContainer }, // ❌ 避免
|
||||
```
|
||||
|
||||
2. **保持样式的可组合性**
|
||||
```tsx
|
||||
// ✅ 好的做法:细粒度的样式定义
|
||||
root: { borderRadius: token.borderRadius },
|
||||
shadow: stylish.shadow,
|
||||
glass: stylish.blur,
|
||||
|
||||
// ❌ 避免:耦合多个效果
|
||||
rootWithShadow: { ...stylish.shadow, borderRadius: token.borderRadius },
|
||||
```
|
||||
|
||||
3. **使用语义化的样式名称**
|
||||
```tsx
|
||||
// ✅ 清晰的语义
|
||||
variantPrimary: stylish.variantFilled,
|
||||
variantPrimaryActive: stylish.variantFilledActive,
|
||||
|
||||
// ❌ 避免无意义的名称
|
||||
style1: stylish.variantFilled,
|
||||
activeStyle: stylish.variantFilledActive,
|
||||
```
|
||||
|
||||
## 相关文件参考
|
||||
|
||||
### 组件结构示例
|
||||
|
||||
- [Block 组件实现](mdc:src/components/Block/Block.tsx) - CVA 用法示例
|
||||
- [Block 样式定义](mdc:src/components/Block/style.ts) - createStyles 示例
|
||||
- [Block 类型定义](mdc:src/components/Block/type.ts) - Props 接口示例
|
||||
- [Block 文档](mdc:src/components/Block/index.md) - 组件文档示例
|
||||
|
||||
### Demo 示例
|
||||
|
||||
- [Block Demo 配置](mdc:src/components/Block/demos/index.tsx) - Demo 配置文件示例
|
||||
- [Block 基础示例](mdc:src/components/Block/demos/basic.tsx) - 基础 demo 示例
|
||||
- [Block 交互示例](mdc:src/components/Block/demos/clickable.tsx) - 交互 demo 示例
|
||||
- [ActionIcon Demo 配置](mdc:src/components/ActionIcon/demos/index.tsx) - 多 demo 配置示例
|
||||
|
||||
### 工具和类型
|
||||
|
||||
- [DemoConfig 类型定义](mdc:src/components/types.ts) - Demo 配置类型
|
||||
- [Playground 数据生成脚本](mdc:scripts/playground/index.ts) - 自动化流程
|
||||
- [组件导出索引](mdc:src/components/index.ts) - 导出规范
|
||||
|
||||
## 快速检查清单
|
||||
|
||||
在提交新组件或更新组件之前,请确认:
|
||||
|
||||
### 新组件清单
|
||||
|
||||
- [ ] 创建了组件目录 `src/components/ComponentName/`
|
||||
- [ ] 创建了 `ComponentName.tsx` 主文件
|
||||
- [ ] 创建了 `type.ts` 类型定义
|
||||
- [ ] 创建了 `style.ts` 样式定义
|
||||
- [ ] 创建了 `index.ts` 导出文件
|
||||
- [ ] 创建了 `index.md` 文档,包含正确的 frontmatter(group, title, description)
|
||||
- [ ] 创建了 `demos/basic.tsx` 基础示例
|
||||
- [ ] 创建了 `demos/index.tsx` 配置文件
|
||||
- [ ] 在 `src/components/index.ts` 中添加了导出
|
||||
- [ ] 运行了 `pnpm run build:playground`
|
||||
- [ ] 在 Playground 中测试了组件
|
||||
|
||||
### Demo 清单
|
||||
|
||||
- [ ] `demos/index.tsx` 从 `@lobehub/ui-rn` 导入了 `DemoConfig` 类型
|
||||
- [ ] 所有 demo 文件使用默认导出箭头函数组件
|
||||
- [ ] `demos/index.tsx` 中的 demo 配置使用了正确的命名:
|
||||
- `key` 使用 kebab-case
|
||||
- `title` 使用中文
|
||||
- 导入名称使用 PascalCase + Demo 后缀
|
||||
- [ ] `basic` demo 放在配置数组的第一位
|
||||
- [ ] demo 代码简洁,专注于单一功能展示
|
||||
|
||||
### 样式清单
|
||||
|
||||
- [ ] 使用 `createStyles` 创建样式
|
||||
- [ ] 优先使用 `stylish` 预设而非自定义样式
|
||||
- [ ] 从 `token` 获取主题变量
|
||||
- [ ] 如果使用 CVA,已用 `useMemo` 包裹并依赖 `styles`
|
||||
- [ ] 样式名称语义化清晰
|
||||
|
||||
### 文档清单
|
||||
|
||||
- [ ] `index.md` 包含正确的 frontmatter
|
||||
- [ ] 文档包含 Features 列表
|
||||
- [ ] 文档包含 Basic Usage 示例
|
||||
- [ ] 文档示例代码与实际 API 一致
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
description: 包含添加 debug 日志请求时
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Debug 包使用指南
|
||||
|
||||
本项目使用 [debug](mdc:https:/github.com/debug-js/debug) 包进行调试日志记录。使用此规则来确保团队成员统一调试日志格式。
|
||||
|
||||
## 基本用法
|
||||
|
||||
1. 导入 debug 包:
|
||||
|
||||
```typescript
|
||||
import debug from 'debug';
|
||||
```
|
||||
|
||||
2. 创建一个命名空间的日志记录器:
|
||||
|
||||
```typescript
|
||||
// 格式: lobe:[模块]:[子模块]
|
||||
const log = debug('lobe-[模块名]:[子模块名]');
|
||||
```
|
||||
一个文件尽量只创建一个 log 命名空间,子模块名使用文件名
|
||||
|
||||
|
||||
3. 使用日志记录器:
|
||||
|
||||
```typescript
|
||||
log('简单消息');
|
||||
log('带变量的消息: %O', object);
|
||||
log('格式化数字: %d', number);
|
||||
```
|
||||
|
||||
## 命名空间约定
|
||||
|
||||
- 桌面应用相关: `lobe-desktop:[模块]`
|
||||
- 服务端相关: `lobe-server:[模块]`
|
||||
- 客户端相关: `lobe-client:[模块]`
|
||||
- 路由相关: `lobe-[类型]-router:[模块]`
|
||||
|
||||
## 格式说明符
|
||||
|
||||
- `%O` - 对象展开(推荐用于复杂对象)
|
||||
- `%o` - 对象
|
||||
- `%s` - 字符串
|
||||
- `%d` - 数字
|
||||
|
||||
## 示例
|
||||
|
||||
查看 [market/index.ts](mdc:src/server/routers/edge/market/index.ts) 中的使用示例:
|
||||
|
||||
```typescript
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('lobe-edge-router:market');
|
||||
|
||||
log('getAgent input: %O', input);
|
||||
```
|
||||
|
||||
## 启用调试
|
||||
|
||||
要在开发时启用调试输出,需设置环境变量:
|
||||
|
||||
### 在浏览器中
|
||||
|
||||
在控制台执行:
|
||||
```javascript
|
||||
localStorage.debug = 'lobe-*'
|
||||
```
|
||||
|
||||
### 在 Node.js 环境中
|
||||
|
||||
```bash
|
||||
DEBUG=lobe-* npm run dev
|
||||
# 或者
|
||||
DEBUG=lobe-* pnpm dev
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Development Workflow
|
||||
|
||||
## Package Management
|
||||
|
||||
### Using pnpm
|
||||
- **Install dependencies**: `pnpm install`
|
||||
- **Add new package**: `pnpm add <package-name>`
|
||||
- **Add dev dependency**: `pnpm add -D <package-name>`
|
||||
- **Run scripts**: `pnpm <script-name>`
|
||||
|
||||
### Available Scripts
|
||||
- `pnpm start` - Start Expo development server
|
||||
- `pnpm android` - Run on Android device/emulator
|
||||
- `pnpm ios` - Run on iOS device/simulator
|
||||
- `pnpm web` - Run in web browser
|
||||
- `pnpm test` - Run Jest tests in watch mode
|
||||
- `pnpm lint` - Run ESLint
|
||||
- `pnpm i18n` - Generate translations for all languages
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
### Workflow
|
||||
1. **Add translations** to `i18n/default/common.ts` (Chinese source)
|
||||
2. **Use in components** with `useTranslation` hook
|
||||
3. **Generate translations** with `pnpm run i18n`
|
||||
4. **Test** with different languages
|
||||
|
||||
### Key Files
|
||||
- `i18n/default/common.ts` - Source translations (Chinese)
|
||||
- `locales/*/common.json` - Generated translations (18 languages)
|
||||
- `scripts/i18nWorkflow/` - Translation generation scripts
|
||||
|
||||
### Quick Reference
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const { t } = useTranslation();
|
||||
<Text>{t('chat.history')}</Text>
|
||||
```
|
||||
|
||||
See [internationalization.mdc](mdc:.cursor/rules/internationalization.mdc) for detailed guidelines.
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Configuration Files
|
||||
- [package.json](mdc:package.json) - Dependencies and scripts
|
||||
- [tsconfig.json](mdc:tsconfig.json) - TypeScript configuration
|
||||
- [metro.config.js](mdc:metro.config.js) - Metro bundler configuration
|
||||
- [app.config.ts](mdc:app.config.ts) - Expo app configuration (TypeScript, 类型安全)
|
||||
- [eas.json](mdc:eas.json) - EAS Build configuration
|
||||
|
||||
### Environment Setup
|
||||
- [src/polyfills.ts](mdc:src/polyfills.ts) - Polyfills for React Native
|
||||
- [global.d.ts](mdc:global.d.ts) - Global TypeScript declarations
|
||||
|
||||
## Code Organization
|
||||
|
||||
### File Naming Conventions
|
||||
- Components: PascalCase (e.g., `ChatBubble.tsx`)
|
||||
- Hooks: camelCase with `use` prefix (e.g., `useChat.ts`)
|
||||
- Utilities: camelCase (e.g., `fetchSSE.ts`)
|
||||
- Types: camelCase (e.g., `session.ts`)
|
||||
- Constants: UPPER_SNAKE_CASE (e.g., `DEFAULT_AGENT_META`)
|
||||
|
||||
### Import Patterns
|
||||
- Use absolute imports with `@/` prefix
|
||||
- Group imports: React, third-party, internal
|
||||
- Use named exports for components
|
||||
- Use default exports for main components
|
||||
|
||||
### TypeScript Guidelines
|
||||
- Enable strict mode in [tsconfig.json](mdc:tsconfig.json)
|
||||
- Define interfaces for all props and state
|
||||
- Use proper type annotations
|
||||
- Avoid `any` type - use `unknown` or proper types
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
- [components/__tests__/](mdc:components/__tests__/) - Component tests
|
||||
- [components/__tests__/__snapshots__/](mdc:components/__tests__/__snapshots__/) - Jest snapshots
|
||||
- Use Jest with React Native Testing Library
|
||||
|
||||
### Testing Patterns
|
||||
- Test component rendering
|
||||
- Test user interactions
|
||||
- Test error states
|
||||
- Use snapshots for UI regression testing
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Linting
|
||||
- ESLint configuration for React Native
|
||||
- Prettier for code formatting
|
||||
- TypeScript strict checking
|
||||
|
||||
### Best Practices
|
||||
- Follow existing code patterns
|
||||
- Don't change component styles without reason
|
||||
- Implement proper error boundaries
|
||||
- Use proper loading states
|
||||
- Handle edge cases gracefully
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
### EAS Build
|
||||
- [eas.json](mdc:eas.json) - Build configuration
|
||||
- Support for Android and iOS builds
|
||||
- Environment-specific configurations
|
||||
|
||||
### Assets
|
||||
- [assets/](mdc:assets/) - App icons, fonts, and images
|
||||
- [assets/fonts/](mdc:assets/fonts/) - Custom fonts
|
||||
- [assets/images/](mdc:assets/images/) - App images and icons
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Internationalization (i18n) Guidelines
|
||||
|
||||
## Overview
|
||||
This project uses i18next for internationalization with a custom workflow that generates translations from a single source file.
|
||||
|
||||
## Architecture
|
||||
- **Source**: `i18n/default` - Contains all Chinese translations as the source
|
||||
- **Generated**: `locales/* - Auto-generated translations for 18 languages
|
||||
- **Script**: `scripts/i18nWorkflow/` - Handles translation generation
|
||||
|
||||
## Supported Languages
|
||||
18 languages: ar, bg-BG, de-DE, en-US, es-ES, fa-IR, fr-FR, it-IT, ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, ru-RU, tr-TR, vi-VN, zh-CN, zh-TW
|
||||
|
||||
## Adding New Translations
|
||||
|
||||
### Step 1: Add to Source File
|
||||
Add new translation keys to suitable file in `i18n/default` like `chat.ts` and `common.ts`:
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
// ... existing translations
|
||||
newSection: {
|
||||
title: '新标题',
|
||||
description: '新描述',
|
||||
actions: {
|
||||
save: '保存',
|
||||
cancel: '取消'
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Use in Components
|
||||
Use the `useTranslation` hook in React components:
|
||||
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Usage
|
||||
<Text>{t('newSection.title')}</Text>
|
||||
<Text>{t('newSection.actions.save')}</Text>
|
||||
```
|
||||
|
||||
### Step 3: Generate Translations
|
||||
Run the i18n script to generate all language translations:
|
||||
|
||||
```bash
|
||||
pnpm run i18n
|
||||
```
|
||||
|
||||
## Translation Key Naming Convention
|
||||
|
||||
use simpilifed naming keys
|
||||
|
||||
### Structure
|
||||
- Use nested objects for related translations
|
||||
- Use camelCase for keys
|
||||
- Group by feature/section
|
||||
|
||||
### Examples
|
||||
```typescript
|
||||
// Good
|
||||
settings: {
|
||||
theme: {
|
||||
title: '主题设置',
|
||||
light: '浅色模式',
|
||||
dark: '深色模式'
|
||||
}
|
||||
}
|
||||
|
||||
// Good
|
||||
chat: {
|
||||
actions: {
|
||||
copy: '复制',
|
||||
delete: '删除',
|
||||
retry: '重试'
|
||||
},
|
||||
messages: {
|
||||
copied: '已复制',
|
||||
failed: '失败'
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid
|
||||
settingsThemeTitle: '主题设置',
|
||||
settingsThemeLight: '浅色模式'
|
||||
```
|
||||
|
||||
## Common Translation Keys
|
||||
|
||||
### Chat Features
|
||||
```typescript
|
||||
chat: {
|
||||
history: '对话历史',
|
||||
messageCopied: '消息已复制',
|
||||
confirmDelete: '确认删除',
|
||||
copy: '复制',
|
||||
retry: '重试',
|
||||
delete: '删除',
|
||||
send: '发送',
|
||||
stop: '停止',
|
||||
thinking: '思考中...',
|
||||
placeholder: '输入您的消息...'
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Features
|
||||
```typescript
|
||||
settings: {
|
||||
title: '设置',
|
||||
theme: {
|
||||
title: '主题设置',
|
||||
light: '浅色模式',
|
||||
dark: '深色模式',
|
||||
auto: '跟随系统',
|
||||
},
|
||||
locale: {
|
||||
title: '语言设置',
|
||||
auto: {
|
||||
title: '跟随系统',
|
||||
description: '跟随系统语言设置'
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Common Actions
|
||||
```typescript
|
||||
common: {
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
add: '添加',
|
||||
search: '搜索',
|
||||
loading: '加载中...',
|
||||
error: '错误',
|
||||
success: '成功',
|
||||
warning: '警告',
|
||||
info: '信息'
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### App Name
|
||||
- **Do NOT translate** the app name "LobeChat"
|
||||
- Keep it hardcoded in components: `<Text>LobeChat</Text>`
|
||||
- Do not add `app.name` to translation keys
|
||||
|
||||
### Code Comments
|
||||
- Chinese comments in code (like `{/* 状态栏 */}`) do not need translation
|
||||
- Only user-facing text needs internationalization
|
||||
|
||||
### Error Messages
|
||||
- Always use translation keys for error messages
|
||||
- Provide fallback messages in English
|
||||
|
||||
### Placeholders
|
||||
- Translate placeholder text in input fields
|
||||
- Use `placeholderTextColor` for styling
|
||||
|
||||
## Workflow Checklist
|
||||
|
||||
When adding new UI text:
|
||||
|
||||
1. ✅ Add translation key to `i18n/default/common.ts`
|
||||
2. ✅ Use `useTranslation` hook in component
|
||||
3. ✅ Replace hardcoded text with `t('key.path')`
|
||||
4. ✅ Run `pnpm run i18n` to generate translations
|
||||
5. ✅ Test with different languages
|
||||
6. ✅ Verify all 18 languages are generated correctly
|
||||
|
||||
## Testing
|
||||
- Switch languages in settings to verify translations
|
||||
- Check that all user-facing text is translated
|
||||
- Ensure app name remains "LobeChat" in all languages
|
||||
- Verify error messages and placeholders are translated
|
||||
|
||||
## Troubleshooting
|
||||
- If translations don't appear, run `pnpm run i18n` again
|
||||
- Check that translation keys match exactly between source and usage
|
||||
- Verify `useTranslation` hook is imported and used correctly
|
||||
- Ensure translation keys follow the nested object structure
|
||||
@@ -0,0 +1,631 @@
|
||||
---
|
||||
globs: *.ts,*.tsx
|
||||
description: 优先使用自定义组件规范 - 使用 Text、Flexbox、Center、Block 等自定义组件替代 React Native 原生组件
|
||||
---
|
||||
|
||||
# 优先使用自定义组件规范
|
||||
|
||||
本规范定义了在 LobeChat Mobile 项目中应该优先使用自定义组件而不是 React Native 原生组件的场景和原因。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**✅ 优先使用自定义组件**:项目提供了一系列增强的自定义组件,它们提供了更友好的 API、更好的类型安全、主题集成以及样式变体支持。
|
||||
|
||||
**❌ 避免直接使用原生组件**:除非有特殊需求,否则应避免直接使用 `Text`、`View`、`Pressable` 等 React Native 原生组件。
|
||||
|
||||
## 组件映射关系
|
||||
|
||||
| 原生组件 | 自定义组件 | 使用场景 |
|
||||
|---------|----------|---------|
|
||||
| `Text` | `@/components/Text` | 所有文本显示场景 |
|
||||
| `View` (flex 布局) | `@/components/Flexbox` | 需要 flex 布局的容器 |
|
||||
| `View` (居中布局) | `@/components/Center` | 需要内容居中的容器 |
|
||||
| `View` (带样式) | `@/components/Block` | 需要背景、边框、阴影等样式的容器 |
|
||||
| `Pressable` | `@/components/Flexbox` 或 `@/components/Block` | 可点击的布局容器(使用 `onPress` prop) |
|
||||
|
||||
## 1. Text 组件
|
||||
|
||||
### 为什么使用 Text
|
||||
|
||||
`@/components/Text` 提供了比原生 `Text` 更丰富的功能:
|
||||
|
||||
- ✅ **语义化标题**:支持 `h1` ~ `h5` 标题级别
|
||||
- ✅ **文本样式**:`strong`、`italic`、`underline`、`delete`、`code` 等
|
||||
- ✅ **语义化类型**:`danger`、`success`、`warning`、`info`、`secondary`
|
||||
- ✅ **省略号支持**:更友好的 `ellipsis` 配置
|
||||
- ✅ **主题集成**:自动适配主题颜色和字体
|
||||
- ✅ **更好的类型提示**:完整的 TypeScript 支持
|
||||
|
||||
### 使用示例
|
||||
|
||||
```tsx
|
||||
// ❌ 避免使用原生 Text
|
||||
import { Text } from 'react-native';
|
||||
|
||||
<Text style={{ fontWeight: 'bold', color: 'red' }}>错误信息</Text>
|
||||
|
||||
// ✅ 推荐使用自定义 Text
|
||||
import Text from '@/components/Text';
|
||||
|
||||
<Text type="danger" strong>错误信息</Text>
|
||||
```
|
||||
|
||||
### 常见用法
|
||||
|
||||
```tsx
|
||||
import Text from '@/components/Text';
|
||||
|
||||
// 标题
|
||||
<Text as="h1">一级标题</Text>
|
||||
<Text as="h2">二级标题</Text>
|
||||
|
||||
// 文本样式
|
||||
<Text strong>加粗文本</Text>
|
||||
<Text italic>斜体文本</Text>
|
||||
<Text underline>下划线文本</Text>
|
||||
<Text delete>删除线文本</Text>
|
||||
<Text code>代码文本</Text>
|
||||
|
||||
// 语义化类型
|
||||
<Text type="danger">错误提示</Text>
|
||||
<Text type="success">成功提示</Text>
|
||||
<Text type="warning">警告提示</Text>
|
||||
<Text type="info">信息提示</Text>
|
||||
<Text type="secondary">次要文本</Text>
|
||||
|
||||
// 省略号
|
||||
<Text ellipsis>这是一段很长的文本会被截断...</Text>
|
||||
<Text ellipsis={{ rows: 2 }}>这是一段很长的文本,最多显示两行,超出部分会被截断...</Text>
|
||||
|
||||
// 组合使用
|
||||
<Text type="danger" strong numberOfLines={1}>重要错误信息</Text>
|
||||
```
|
||||
|
||||
## 2. Flexbox 组件
|
||||
|
||||
### 为什么使用 Flexbox
|
||||
|
||||
`@/components/Flexbox` 提供了比原生 `View` 更友好的 flex 布局配置:
|
||||
|
||||
- ✅ **简化的 API**:直观的 props 命名 (`horizontal`、`justify`、`align`、`gap`)
|
||||
- ✅ **自动处理方向**:`horizontal` prop 自动切换 `flexDirection`
|
||||
- ✅ **内置交互**:支持 `onPress`,自动切换 `Pressable`/`View`
|
||||
- ✅ **间距支持**:原生支持 `gap` prop
|
||||
- ✅ **更少的样式代码**:常用布局属性作为 props 直接配置
|
||||
|
||||
### 使用示例
|
||||
|
||||
```tsx
|
||||
// ❌ 避免使用原生 View
|
||||
import { View, Pressable } from 'react-native';
|
||||
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<Text>内容 1</Text>
|
||||
<Text>内容 2</Text>
|
||||
</View>
|
||||
|
||||
// ✅ 推荐使用 Flexbox
|
||||
import Flexbox from '@/components/Flexbox';
|
||||
import Text from '@/components/Text';
|
||||
|
||||
<Flexbox horizontal justify="space-between" align="center" gap={16}>
|
||||
<Text>内容 1</Text>
|
||||
<Text>内容 2</Text>
|
||||
</Flexbox>
|
||||
```
|
||||
|
||||
### 常见用法
|
||||
|
||||
```tsx
|
||||
import Flexbox from '@/components/Flexbox';
|
||||
|
||||
// 垂直布局(默认)
|
||||
<Flexbox gap={16}>
|
||||
<Text>项目 1</Text>
|
||||
<Text>项目 2</Text>
|
||||
</Flexbox>
|
||||
|
||||
// 水平布局
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Text>项目 1</Text>
|
||||
<Text>项目 2</Text>
|
||||
</Flexbox>
|
||||
|
||||
// 对齐方式
|
||||
<Flexbox justify="center" align="center">
|
||||
<Text>居中内容</Text>
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal justify="space-between" align="flex-start">
|
||||
<Text>左侧</Text>
|
||||
<Text>右侧</Text>
|
||||
</Flexbox>
|
||||
|
||||
// 可点击容器
|
||||
<Flexbox
|
||||
horizontal
|
||||
gap={8}
|
||||
padding={16}
|
||||
onPress={() => console.log('clicked')}
|
||||
>
|
||||
<Icon name="home" />
|
||||
<Text>首页</Text>
|
||||
</Flexbox>
|
||||
|
||||
// 弹性布局
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Flexbox flex={1}>
|
||||
<Text>占据剩余空间</Text>
|
||||
</Flexbox>
|
||||
<Text>固定宽度</Text>
|
||||
</Flexbox>
|
||||
|
||||
// 换行
|
||||
<Flexbox horizontal wrap="wrap" gap={8}>
|
||||
<Text>标签 1</Text>
|
||||
<Text>标签 2</Text>
|
||||
<Text>标签 3</Text>
|
||||
</Flexbox>
|
||||
```
|
||||
|
||||
## 3. Center 组件
|
||||
|
||||
### 为什么使用 Center
|
||||
|
||||
`@/components/Center` 是专门用于居中布局的组件:
|
||||
|
||||
- ✅ **语义化**:明确表达居中意图
|
||||
- ✅ **简化代码**:不需要手动设置 `justify` 和 `align`
|
||||
- ✅ **默认居中**:`justify="center"` 和 `align="center"` 是默认值
|
||||
- ✅ **基于 Flexbox**:继承所有 Flexbox 的功能
|
||||
|
||||
### 使用示例
|
||||
|
||||
```tsx
|
||||
// ❌ 避免使用原生 View
|
||||
import { View } from 'react-native';
|
||||
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text>居中内容</Text>
|
||||
</View>
|
||||
|
||||
// ✅ 推荐使用 Center
|
||||
import Center from '@/components/Center';
|
||||
import Text from '@/components/Text';
|
||||
|
||||
<Center flex={1}>
|
||||
<Text>居中内容</Text>
|
||||
</Center>
|
||||
```
|
||||
|
||||
### 常见用法
|
||||
|
||||
```tsx
|
||||
import Center from '@/components/Center';
|
||||
|
||||
// 基础居中
|
||||
<Center>
|
||||
<Text>居中内容</Text>
|
||||
</Center>
|
||||
|
||||
// 占满容器并居中
|
||||
<Center flex={1}>
|
||||
<Text>垂直和水平居中</Text>
|
||||
</Center>
|
||||
|
||||
// 固定尺寸居中
|
||||
<Center width={200} height={200}>
|
||||
<Icon name="loading" />
|
||||
</Center>
|
||||
|
||||
// 水平居中(垂直方向可以自定义)
|
||||
<Center horizontal justify="flex-start" gap={8}>
|
||||
<Icon name="user" />
|
||||
<Text>用户名</Text>
|
||||
</Center>
|
||||
|
||||
// 可点击的居中容器
|
||||
<Center
|
||||
width={100}
|
||||
height={100}
|
||||
onPress={() => console.log('clicked')}
|
||||
>
|
||||
<Icon name="add" />
|
||||
<Text>添加</Text>
|
||||
</Center>
|
||||
```
|
||||
|
||||
## 4. Block 组件
|
||||
|
||||
### 为什么使用 Block
|
||||
|
||||
`@/components/Block` 是增强的样式容器组件:
|
||||
|
||||
- ✅ **样式变体**:`filled`、`outlined`、`borderless` 三种预设样式
|
||||
- ✅ **视觉效果**:内置 `shadow` 和 `glass` 效果
|
||||
- ✅ **交互状态**:自动处理 hover 和 press 状态样式
|
||||
- ✅ **主题集成**:使用 `stylish` 预设,自动适配主题
|
||||
- ✅ **CVA 管理**:类型安全的样式组合
|
||||
- ✅ **基于 Flexbox**:继承所有 Flexbox 的布局能力
|
||||
|
||||
### 使用示例
|
||||
|
||||
```tsx
|
||||
// ❌ 避免使用原生 View 或 Pressable
|
||||
import { Pressable } from 'react-native';
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? '#f0f0f0' : '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
// ... 大量样式代码
|
||||
})}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Text>卡片内容</Text>
|
||||
</Pressable>
|
||||
|
||||
// ✅ 推荐使用 Block
|
||||
import Block from '@/components/Block';
|
||||
import Text from '@/components/Text';
|
||||
|
||||
<Block variant="filled" shadow onPress={handlePress}>
|
||||
<Text>卡片内容</Text>
|
||||
</Block>
|
||||
```
|
||||
|
||||
### 常见用法
|
||||
|
||||
```tsx
|
||||
import Block from '@/components/Block';
|
||||
|
||||
// 样式变体
|
||||
<Block variant="filled">
|
||||
<Text>填充背景</Text>
|
||||
</Block>
|
||||
|
||||
<Block variant="outlined">
|
||||
<Text>轮廓边框</Text>
|
||||
</Block>
|
||||
|
||||
<Block variant="borderless">
|
||||
<Text>无边框</Text>
|
||||
</Block>
|
||||
|
||||
// 视觉效果
|
||||
<Block variant="filled" shadow>
|
||||
<Text>带阴影的卡片</Text>
|
||||
</Block>
|
||||
|
||||
<Block variant="filled" glass>
|
||||
<Text>玻璃效果背景</Text>
|
||||
</Block>
|
||||
|
||||
// 可点击卡片
|
||||
<Block
|
||||
variant="outlined"
|
||||
padding={16}
|
||||
gap={8}
|
||||
onPress={() => console.log('clicked')}
|
||||
>
|
||||
<Text as="h3">卡片标题</Text>
|
||||
<Text type="secondary">卡片描述</Text>
|
||||
</Block>
|
||||
|
||||
// 组合布局和样式
|
||||
<Block variant="filled" shadow padding={24}>
|
||||
<Flexbox gap={16}>
|
||||
<Text as="h2">用户信息</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Icon name="user" />
|
||||
<Text>用户名</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
```
|
||||
|
||||
## 5. 组件组合使用
|
||||
|
||||
### 完整示例:用户卡片
|
||||
|
||||
```tsx
|
||||
import Block from '@/components/Block';
|
||||
import Center from '@/components/Center';
|
||||
import Flexbox from '@/components/Flexbox';
|
||||
import Text from '@/components/Text';
|
||||
|
||||
const UserCard = ({ user }) => {
|
||||
return (
|
||||
<Block variant="filled" shadow padding={16} gap={12}>
|
||||
{/* 头部:头像和名称 */}
|
||||
<Flexbox horizontal gap={12} align="center">
|
||||
<Center width={48} height={48}>
|
||||
<Avatar source={user.avatar} />
|
||||
</Center>
|
||||
<Flexbox flex={1} gap={4}>
|
||||
<Text as="h4" strong>{user.name}</Text>
|
||||
<Text type="secondary" fontSize={12}>{user.email}</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Flexbox horizontal gap={24}>
|
||||
<Flexbox gap={4} align="center">
|
||||
<Text strong fontSize={18}>{user.posts}</Text>
|
||||
<Text type="secondary" fontSize={12}>帖子</Text>
|
||||
</Flexbox>
|
||||
<Flexbox gap={4} align="center">
|
||||
<Text strong fontSize={18}>{user.followers}</Text>
|
||||
<Text type="secondary" fontSize={12}>关注者</Text>
|
||||
</Flexbox>
|
||||
<Flexbox gap={4} align="center">
|
||||
<Text strong fontSize={18}>{user.following}</Text>
|
||||
<Text type="secondary" fontSize={12}>关注中</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Block
|
||||
flex={1}
|
||||
variant="filled"
|
||||
padding={12}
|
||||
onPress={() => console.log('follow')}
|
||||
>
|
||||
<Center>
|
||||
<Text>关注</Text>
|
||||
</Center>
|
||||
</Block>
|
||||
<Block
|
||||
flex={1}
|
||||
variant="outlined"
|
||||
padding={12}
|
||||
onPress={() => console.log('message')}
|
||||
>
|
||||
<Center>
|
||||
<Text>消息</Text>
|
||||
</Center>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 6. 何时可以使用原生组件
|
||||
|
||||
以下场景可以考虑使用原生组件:
|
||||
|
||||
### 6.1 特殊需求场景
|
||||
|
||||
```tsx
|
||||
// ScrollView, FlatList, SectionList 等列表组件
|
||||
import { FlatList } from 'react-native';
|
||||
<FlatList data={data} renderItem={renderItem} />
|
||||
|
||||
// Image 组件
|
||||
import { Image } from 'react-native';
|
||||
<Image source={require('./image.png')} />
|
||||
|
||||
// TextInput 组件(如果没有自定义封装)
|
||||
import { TextInput } from 'react-native';
|
||||
<TextInput value={text} onChangeText={setText} />
|
||||
|
||||
// 特殊的原生组件
|
||||
import { SafeAreaView, KeyboardAvoidingView, Modal } from 'react-native';
|
||||
```
|
||||
|
||||
### 6.2 性能关键场景
|
||||
|
||||
在列表项等性能关键场景,如果自定义组件带来性能问题,可以考虑使用原生组件,但需要:
|
||||
|
||||
```tsx
|
||||
// ⚠️ 性能优化场景,谨慎使用
|
||||
import { View, Text } from 'react-native';
|
||||
import { memo } from 'react';
|
||||
|
||||
const ListItem = memo(({ item }) => (
|
||||
<View style={styles.item}>
|
||||
<Text style={styles.text}>{item.title}</Text>
|
||||
</View>
|
||||
));
|
||||
```
|
||||
|
||||
## 7. 导入规范
|
||||
|
||||
### 7.1 应用代码中的导入
|
||||
|
||||
在应用代码(`app/`、`src/features/` 等)中:
|
||||
|
||||
```tsx
|
||||
// ✅ 推荐:使用绝对路径导入
|
||||
import Block from '@/components/Block';
|
||||
import Center from '@/components/Center';
|
||||
import Flexbox from '@/components/Flexbox';
|
||||
import Text from '@/components/Text';
|
||||
```
|
||||
|
||||
### 7.2 组件内部的导入
|
||||
|
||||
在 `src/components/` 目录下的组件中:
|
||||
|
||||
```tsx
|
||||
// ✅ 推荐:使用相对路径导入其他组件
|
||||
import Block from '../Block';
|
||||
import Center from '../Center';
|
||||
import Flexbox from '../Flexbox';
|
||||
import Text from '../Text';
|
||||
```
|
||||
|
||||
### 7.3 Demo 文件中的导入
|
||||
|
||||
在组件的 `demos/` 目录下:
|
||||
|
||||
```tsx
|
||||
// ✅ 推荐:从 @lobehub/ui-rn 导入(展示公开 API)
|
||||
import { Block, Center, Flexbox, Text } from '@lobehub/ui-rn';
|
||||
```
|
||||
|
||||
## 8. 快速参考
|
||||
|
||||
| 需求 | 推荐组件 | 关键 Props |
|
||||
|-----|---------|-----------|
|
||||
| 显示普通文本 | `Text` | `children` |
|
||||
| 显示标题 | `Text` | `as="h1"` ~ `as="h5"` |
|
||||
| 显示错误/成功提示 | `Text` | `type="danger"` / `type="success"` |
|
||||
| 垂直布局 | `Flexbox` | `gap={16}` |
|
||||
| 水平布局 | `Flexbox` | `horizontal gap={8}` |
|
||||
| 内容居中 | `Center` | `flex={1}` |
|
||||
| 卡片容器 | `Block` | `variant="filled" shadow` |
|
||||
| 可点击容器 | `Flexbox` / `Block` | `onPress={handler}` |
|
||||
| 列表项 | `Block` / `Flexbox` | `horizontal align="center" gap={12}` |
|
||||
|
||||
## 9. 常见错误和修复
|
||||
|
||||
### 错误 1: 直接使用原生 Text
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
<View>
|
||||
<Text style={{ fontWeight: 'bold', color: '#e74c3c' }}>
|
||||
错误信息
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
// ✅ 修复
|
||||
import Flexbox from '@/components/Flexbox';
|
||||
import Text from '@/components/Text';
|
||||
|
||||
<Flexbox>
|
||||
<Text type="danger" strong>
|
||||
错误信息
|
||||
</Text>
|
||||
</Flexbox>
|
||||
```
|
||||
|
||||
### 错误 2: 复杂的 View 样式
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import { View } from 'react-native';
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
// ✅ 修复
|
||||
import Flexbox from '@/components/Flexbox';
|
||||
|
||||
<Flexbox
|
||||
horizontal
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={16}
|
||||
padding={12}
|
||||
>
|
||||
{children}
|
||||
</Flexbox>
|
||||
```
|
||||
|
||||
### 错误 3: 手动实现居中布局
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import { View } from 'react-native';
|
||||
|
||||
<View style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
// ✅ 修复
|
||||
import Center from '@/components/Center';
|
||||
|
||||
<Center flex={1}>
|
||||
{children}
|
||||
</Center>
|
||||
```
|
||||
|
||||
### 错误 4: 复杂的 Pressable 样式
|
||||
|
||||
```tsx
|
||||
// ❌ 错误
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
pressed && styles.cardPressed,
|
||||
]}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
cardPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ 修复
|
||||
import Block from '@/components/Block';
|
||||
|
||||
<Block
|
||||
variant="filled"
|
||||
shadow
|
||||
padding={16}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</Block>
|
||||
```
|
||||
|
||||
## 10. 总结
|
||||
|
||||
### 核心优势
|
||||
|
||||
使用自定义组件的核心优势:
|
||||
|
||||
1. **更少的代码**:友好的 API 减少样式代码
|
||||
2. **更好的可读性**:语义化的 props 和组件名
|
||||
3. **类型安全**:完整的 TypeScript 支持
|
||||
4. **主题集成**:自动适配明暗主题
|
||||
5. **统一风格**:使用预设样式保持 UI 一致性
|
||||
6. **易于维护**:集中管理样式逻辑
|
||||
|
||||
### 记住这个规则
|
||||
|
||||
**在 LobeChat Mobile 中,优先使用:**
|
||||
|
||||
- ✅ `Text` 而不是 `react-native` 的 `Text`
|
||||
- ✅ `Flexbox` 而不是 `react-native` 的 `View` (flex 布局)
|
||||
- ✅ `Center` 而不是 `react-native` 的 `View` (居中布局)
|
||||
- ✅ `Block` 而不是 `react-native` 的 `View` / `Pressable` (样式容器)
|
||||
|
||||
**这些组件提供了更友好的配置方式,让代码更简洁、更易维护!**
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Lobe Chat React Native - Project Overview
|
||||
|
||||
This is a React Native chat application built with Expo Router, featuring AI-powered conversations and modern UI components.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Entry Points
|
||||
- Main app entry: [src/app/_layout.tsx](mdc:src/app/_layout.tsx) - Root layout with providers
|
||||
- App router: [src/app/index.tsx](mdc:src/app/index.tsx) - Main routing logic
|
||||
- Package config: [package.json](mdc:package.json) - Dependencies and scripts
|
||||
|
||||
### Key Technologies
|
||||
- **Expo Router**: File-based routing system
|
||||
- **Zustand**: State management with persistence
|
||||
- **React Native Markdown**: Rich text rendering
|
||||
- **Shiki**: Syntax highlighting for code blocks
|
||||
- **MathJax**: Mathematical formula rendering
|
||||
|
||||
### State Management
|
||||
- OpenAI config: [store/openai.ts](mdc:store/openai.ts) - API key and proxy settings
|
||||
- Session management: [store/session/](mdc:store/session) - Chat sessions and groups
|
||||
- Chat state: [store/chat/](mdc:store/chat) - Active chat functionality
|
||||
|
||||
### Type Definitions
|
||||
- Session types: [types/session.ts](mdc:types/session.ts) - Chat session interfaces
|
||||
- Agent types: [types/agent.ts](mdc:types/agent.ts) - AI agent configurations
|
||||
- Message types: [types/message.ts](mdc:types/message.ts) - Chat message structures
|
||||
- Component types: [types/component.ts](mdc:types/component.ts) - UI component interfaces
|
||||
|
||||
### Constants and Configuration
|
||||
- Session constants: [const/session.ts](mdc:const/session.ts) - Default session configurations
|
||||
- Settings: [const/settings/](mdc:const/settings) - App configuration options
|
||||
- Branding: [const/branding.ts](mdc:const/branding.ts) - App branding constants
|
||||
|
||||
## Development Guidelines
|
||||
- Use pnpm as the package manager
|
||||
- Follow existing code patterns and component styles
|
||||
- Maintain TypeScript strict typing
|
||||
- Use Expo Router for navigation
|
||||
- Implement proper error handling and loading states
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Quick Reference Guide
|
||||
|
||||
## Essential Commands
|
||||
```bash
|
||||
pnpm start # Start development server
|
||||
pnpm ios # Run on iOS
|
||||
pnpm android # Run on Android
|
||||
pnpm web # Run in browser
|
||||
pnpm test # Run tests
|
||||
pnpm lint # Lint code
|
||||
pnpm i18n # Generate translations
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
### Quick Setup
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Usage
|
||||
<Text>{t('chat.history')}</Text>
|
||||
<Text>{t('settings.theme.title')}</Text>
|
||||
```
|
||||
|
||||
### Key Files
|
||||
- `i18n/default/common.ts` - Add new translations here
|
||||
- `locales/*/common.json` - Generated translations
|
||||
- `pnpm run i18n` - Generate all languages
|
||||
|
||||
### Common Keys
|
||||
```typescript
|
||||
// Chat
|
||||
t('chat.history') // 对话历史
|
||||
t('chat.copy') // 复制
|
||||
t('chat.delete') // 删除
|
||||
|
||||
// Settings
|
||||
t('settings.theme.title') // 主题设置
|
||||
t('settings.locale.title') // 语言设置
|
||||
|
||||
// Common
|
||||
t('common.cancel') // 取消
|
||||
t('common.confirm') // 确认
|
||||
t('common.save') // 保存
|
||||
```
|
||||
|
||||
See [internationalization.mdc](mdc:.cursor/rules/internationalization.mdc) for full guidelines.
|
||||
|
||||
## Key Files to Know
|
||||
|
||||
### Entry Points
|
||||
- [src/app/_layout.tsx](mdc:src/app/_layout.tsx) - Root layout with providers
|
||||
- [src/app/index.tsx](mdc:src/app/index.tsx) - Main routing logic
|
||||
- [package.json](mdc:package.json) - Dependencies and scripts
|
||||
|
||||
### State Management
|
||||
- [store/openai.ts](mdc:store/openai.ts) - OpenAI configuration
|
||||
- [store/session/index.ts](mdc:store/session/index.ts) - Session management
|
||||
- [store/chat/index.ts](mdc:store/chat/index.ts) - Chat state
|
||||
|
||||
### Type Definitions
|
||||
- [types/session.ts](mdc:types/session.ts) - Session interfaces
|
||||
- [types/agent.ts](mdc:types/agent.ts) - Agent configurations
|
||||
- [types/message.ts](mdc:types/message.ts) - Message structures
|
||||
|
||||
### Core Components
|
||||
- [components/Markdown/index.tsx](mdc:components/Markdown/index.tsx) - Markdown renderer
|
||||
- [components/Highlighter/index.tsx](mdc:components/Highlighter/index.tsx) - Code highlighting
|
||||
- [components/Toast/index.ts](mdc:components/Toast/index.ts) - Toast system
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Component Structure
|
||||
```typescript
|
||||
interface ComponentProps {
|
||||
// Props definition
|
||||
}
|
||||
|
||||
const Component: React.FC<ComponentProps> = ({ prop1, prop2 }) => {
|
||||
const { t } = useTranslation();
|
||||
const { colors, dark } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Styles
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>{t('component.title')}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Store Usage
|
||||
```typescript
|
||||
const { data, setData } = useStore();
|
||||
const derivedData = useStore(selector);
|
||||
```
|
||||
|
||||
### Theme Integration
|
||||
```typescript
|
||||
const { colors, dark } = useTheme();
|
||||
const backgroundColor = dark ? colors.background : '#fff';
|
||||
```
|
||||
|
||||
## Development Rules
|
||||
|
||||
### Code Style
|
||||
- Use pnpm for package management
|
||||
- Follow existing component patterns
|
||||
- Don't change styles without reason
|
||||
- Use TypeScript strict mode
|
||||
- Implement proper error handling
|
||||
- **Always use translation keys for user-facing text**
|
||||
|
||||
### File Organization
|
||||
- Components in `components/` directory
|
||||
- Types in `types/` directory
|
||||
- Constants in `const/` directory
|
||||
- Utilities in `utils/` directory
|
||||
- Store logic in `store/` directory
|
||||
- Translations in `i18n/default/common.ts`
|
||||
|
||||
### Import Patterns
|
||||
```typescript
|
||||
// React imports
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Internal imports (use @/ prefix)
|
||||
import { Component } from '@/components/Component';
|
||||
import { useStore } from '@/store/store';
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Metro bundler issues**: Clear cache with `pnpm start --clear`
|
||||
2. **TypeScript errors**: Check [tsconfig.json](mdc:tsconfig.json) configuration
|
||||
3. **Theme issues**: Verify [constants/Colors.ts](mdc:constants/Colors.ts) setup
|
||||
4. **API errors**: Check [store/openai.ts](mdc:store/openai.ts) configuration
|
||||
5. **Translation issues**: Run `pnpm run i18n` to regenerate translations
|
||||
|
||||
### Debug Tools
|
||||
- [src/app/playground/](mdc:src/app/playground/) - Component testing playground
|
||||
- React Native Debugger for debugging
|
||||
- Expo DevTools for development
|
||||
|
||||
## Performance Tips
|
||||
- Use `useMemo` and `useCallback` for expensive operations
|
||||
- Implement proper list virtualization for large lists
|
||||
- Use selectors to prevent unnecessary re-renders
|
||||
- Optimize images and assets
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
1. 本项目使用 expo 框架,你是 expo 开发专家辅助我编程
|
||||
2. 该项目原有 web 端,你需要辅助我将其转化为 native 端
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# State Management with Zustand
|
||||
|
||||
## Store Architecture
|
||||
|
||||
The app uses Zustand for state management. Configuration data persists through **react-native-mmkv** (replacing AsyncStorage for better performance).
|
||||
|
||||
### Core Stores
|
||||
|
||||
#### Configuration Stores (with MMKV persistence)
|
||||
|
||||
**Settings**
|
||||
- [store/setting.ts](mdc:store/setting.ts) - App settings
|
||||
- Manages: Theme, font, language, developer mode
|
||||
- Persistence: **MMKV** with JSON serialization (appStorage instance)
|
||||
|
||||
**OpenAI Configuration**
|
||||
- [store/openai.ts](mdc:store/openai.ts) - OpenAI API configuration
|
||||
- Manages: API key, proxy settings
|
||||
- Persistence: **MMKV** with JSON serialization (appStorage instance)
|
||||
|
||||
**Agent Configuration**
|
||||
- [store/agent/store.ts](mdc:store/agent/store.ts) - AI Agent configuration
|
||||
- Manages: Agent config, initialization data
|
||||
- Persistence: **MMKV** with JSON serialization (appStorage instance)
|
||||
|
||||
#### Runtime Stores (no persistence)
|
||||
|
||||
**Session Management**
|
||||
- [store/session/store.ts](mdc:store/session/store.ts) - Main session store
|
||||
- Manages: Current session state, session list
|
||||
- Persistence: Runtime only (future: SWR + local database)
|
||||
|
||||
**Chat State**
|
||||
- [store/chat/store.ts](mdc:store/chat/store.ts) - Active chat functionality
|
||||
- Manages: Current chat state, messages, loading states
|
||||
- Persistence: Runtime only (future: SWR + local database)
|
||||
|
||||
**User State**
|
||||
- [store/user/index.ts](mdc:store/user/index.ts) - User authentication state
|
||||
- Manages: Authentication state, user info
|
||||
- Persistence: Runtime only (Token stored in expo-secure-store separately)
|
||||
|
||||
## State Patterns
|
||||
|
||||
### Store Structure
|
||||
```typescript
|
||||
interface StoreState {
|
||||
// State properties
|
||||
data: DataType;
|
||||
|
||||
// Actions
|
||||
setData: (data: DataType) => void;
|
||||
updateData: (updater: (data: DataType) => DataType) => void;
|
||||
resetData: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Persistence Pattern
|
||||
```typescript
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import { appStorage, createMMKVStorage } from '@/utils/storage';
|
||||
|
||||
export const useStore = create<StoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// State and actions
|
||||
}),
|
||||
{
|
||||
name: 'store-name',
|
||||
storage: createJSONStorage(() => createMMKVStorage(appStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Storage Instances
|
||||
|
||||
The app uses a single MMKV instance for configuration data:
|
||||
|
||||
- **appStorage**: Global app configuration (settings, openai, agent config)
|
||||
|
||||
For sensitive data (tokens, API keys), use **expo-secure-store** instead.
|
||||
|
||||
### Selector Pattern
|
||||
- Use selectors for derived state
|
||||
- Implement memoization for performance
|
||||
- Keep selectors in separate files for organization
|
||||
|
||||
### Type Safety
|
||||
- Define interfaces for all store states
|
||||
- Use TypeScript for compile-time type checking
|
||||
- Export types for use in components
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### In Components
|
||||
```typescript
|
||||
const { data, setData } = useStore();
|
||||
const derivedData = useStore(selector);
|
||||
```
|
||||
|
||||
### Store Updates
|
||||
- Use immutable updates
|
||||
- Implement proper error handling
|
||||
- Add loading states for async operations
|
||||
|
||||
### Performance
|
||||
- Use selectors to prevent unnecessary re-renders
|
||||
- Implement proper memoization
|
||||
- Avoid storing computed values in state
|
||||
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
steps:
|
||||
- eas/build
|
||||
- run:
|
||||
command: |
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content": "Build android development succeeded! URL: ${ eas.job.expoBuildUrl }"}' \
|
||||
$DISCORD_WEBHOOK_URL
|
||||
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
steps:
|
||||
- eas/build
|
||||
- run:
|
||||
command: |
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content": "Build ios development succeeded! URL: ${ eas.job.expoBuildUrl }"}' \
|
||||
$DISCORD_WEBHOOK_URL
|
||||
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
steps:
|
||||
- eas/build
|
||||
- run:
|
||||
command: |
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content": "Build android preview succeeded! URL: ${ eas.job.expoBuildUrl }"}' \
|
||||
$DISCORD_WEBHOOK_URL
|
||||
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
steps:
|
||||
- eas/build
|
||||
- run:
|
||||
command: |
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content": "Build ios preview succeeded! URL: ${ eas.job.expoBuildUrl }"}' \
|
||||
$DISCORD_WEBHOOK_URL
|
||||
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
steps:
|
||||
- eas/build
|
||||
- run:
|
||||
command: |
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content": "Build android production succeeded! URL: ${ eas.job.expoBuildUrl }"}' \
|
||||
$DISCORD_WEBHOOK_URL
|
||||
@@ -0,0 +1,9 @@
|
||||
build:
|
||||
steps:
|
||||
- eas/build
|
||||
- run:
|
||||
command: |
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content": "Build ios production succeeded! URL: ${ eas.job.expoBuildUrl }"}' \
|
||||
$DISCORD_WEBHOOK_URL
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Create development builds
|
||||
|
||||
jobs:
|
||||
android_development_build:
|
||||
name: Build Android
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: development
|
||||
ios_device_development_build:
|
||||
name: Build iOS device
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development
|
||||
ios_simulator_development_build:
|
||||
name: Build iOS simulator
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development-simulator
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Create Production Builds
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
type: build # This job type creates a production build for Android
|
||||
params:
|
||||
platform: android
|
||||
build_ios:
|
||||
type: build # This job type creates a production build for iOS
|
||||
params:
|
||||
platform: ios
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Deploy to production
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
fingerprint:
|
||||
name: Fingerprint
|
||||
type: fingerprint
|
||||
get_android_build:
|
||||
name: Check for existing android build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
|
||||
profile: production
|
||||
get_ios_build:
|
||||
name: Check for existing ios build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
|
||||
profile: production
|
||||
build_android:
|
||||
name: Build Android
|
||||
needs: [get_android_build]
|
||||
if: ${{ !needs.get_android_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
build_ios:
|
||||
name: Build iOS
|
||||
needs: [get_ios_build]
|
||||
if: ${{ !needs.get_ios_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
submit_android_build:
|
||||
name: Submit Android Build
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
submit_ios_build:
|
||||
name: Submit iOS Build
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
publish_android_update:
|
||||
name: Publish Android update
|
||||
needs: [get_android_build]
|
||||
if: ${{ needs.get_android_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: android
|
||||
publish_ios_update:
|
||||
name: Publish iOS update
|
||||
needs: [get_ios_build]
|
||||
if: ${{ needs.get_ios_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: ios
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Publish preview update
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['*']
|
||||
|
||||
jobs:
|
||||
publish_preview_update:
|
||||
name: Publish preview update
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
name: Build Android app
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
submit_android:
|
||||
name: Submit to Google Play Store
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
platform: android
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
@@ -0,0 +1,19 @@
|
||||
# on:
|
||||
# push:
|
||||
# branches: ['main']
|
||||
|
||||
jobs:
|
||||
build_ios:
|
||||
name: Build iOS app
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit_ios:
|
||||
name: Submit to Apple App Store
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
platform: ios
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
@@ -0,0 +1,9 @@
|
||||
# OAuth 2.0 OIDC 认证配置
|
||||
# 客户端 ID - 从认证服务器获取
|
||||
EXPO_PUBLIC_OAUTH_CLIENT_ID=lobehub-mobile
|
||||
|
||||
# 官方云服务器地址
|
||||
EXPO_PUBLIC_OFFICIAL_CLOUD_SERVER=https://lobechat.com
|
||||
|
||||
# 回调地址 - 必须与认证服务器配置一致
|
||||
EXPO_PUBLIC_OAUTH_REDIRECT_URI=com.lobehub.app://auth/callback
|
||||
@@ -0,0 +1,36 @@
|
||||
# Eslintignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
|
||||
# test
|
||||
jest*
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
|
||||
# umi
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
!.dumirc.ts
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
ios
|
||||
android
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
.expo
|
||||
|
||||
polyfills.ts
|
||||
temp
|
||||
@@ -0,0 +1,55 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
package-lock.json
|
||||
# pnpm-lock.yaml
|
||||
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
android
|
||||
ios
|
||||
yarn.lock
|
||||
repomix-output.xml
|
||||
lobe-ui-repomix-output.xml
|
||||
lobe-chat-repomix-output.xml
|
||||
repomix-output-ant-design-theme.xml
|
||||
credentials.json
|
||||
|
||||
build-*.aab
|
||||
build-*.apk
|
||||
build-*.ipa
|
||||
|
||||
coverage
|
||||
@@ -0,0 +1,50 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'locales/zh-CN',
|
||||
entryLocale: 'zh-CN',
|
||||
output: 'locales',
|
||||
outputLocales: [
|
||||
'ar',
|
||||
'bg-BG',
|
||||
'zh-TW',
|
||||
'en-US',
|
||||
'ru-RU',
|
||||
'ja-JP',
|
||||
'ko-KR',
|
||||
'fr-FR',
|
||||
'tr-TR',
|
||||
'es-ES',
|
||||
'pt-BR',
|
||||
'de-DE',
|
||||
'it-IT',
|
||||
'nl-NL',
|
||||
'pl-PL',
|
||||
'vi-VN',
|
||||
'fa-IR',
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
// chatgpt-4o-latest 和 gpt-chat 翻译效果更好
|
||||
modelName: 'chatgpt-4o-latest',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
markdown: {
|
||||
reference: '你需要保持 mdx 的组件格式,输出文本不需要在最外层包裹任何代码块语法',
|
||||
entry: ['./README.zh-CN.md'],
|
||||
entryLocale: 'zh-CN',
|
||||
outputLocales: ['en-US'],
|
||||
includeMatter: true,
|
||||
exclude: ['./src/**/*'],
|
||||
outputExtensions: (locale, { filePath }) => {
|
||||
if (filePath.includes('.mdx')) {
|
||||
if (locale === 'en-US') return '.mdx';
|
||||
return `.${locale}.mdx`;
|
||||
} else {
|
||||
if (locale === 'en-US') return '.md';
|
||||
return `.${locale}.md`;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
lockfile=true
|
||||
node-linker=hoisted
|
||||
ignore-workspace-root-check=true
|
||||
|
||||
enable-pre-post-scripts=true
|
||||
|
||||
# Load dotenv files for all the npm scripts
|
||||
node-options="--require dotenv-expand/config"
|
||||
|
||||
public-hoist-pattern[]=*@umijs/lint*
|
||||
public-hoist-pattern[]=*changelog*
|
||||
public-hoist-pattern[]=*commitlint*
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=*postcss*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
public-hoist-pattern[]=*remark*
|
||||
public-hoist-pattern[]=*semantic-release*
|
||||
public-hoist-pattern[]=*stylelint*
|
||||
|
||||
public-hoist-pattern[]=@auth/core
|
||||
public-hoist-pattern[]=@clerk/backend
|
||||
public-hoist-pattern[]=@clerk/types
|
||||
public-hoist-pattern[]=pdfjs-dist
|
||||
@@ -0,0 +1,67 @@
|
||||
# Prettierignore for LobeHub
|
||||
################################################################
|
||||
|
||||
# general
|
||||
.DS_Store
|
||||
.editorconfig
|
||||
.idea
|
||||
.vscode
|
||||
.history
|
||||
.temp
|
||||
.env.local
|
||||
.husky
|
||||
.npmrc
|
||||
.gitkeep
|
||||
venv
|
||||
temp
|
||||
tmp
|
||||
LICENSE
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
*.log
|
||||
*.lock
|
||||
package-lock.json
|
||||
|
||||
# ci
|
||||
coverage
|
||||
.coverage
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
test-output
|
||||
__snapshots__
|
||||
*.snap
|
||||
|
||||
# production
|
||||
dist
|
||||
es
|
||||
lib
|
||||
logs
|
||||
|
||||
# umi
|
||||
.umi
|
||||
.umi-production
|
||||
.umi-test
|
||||
.dumi/tmp*
|
||||
|
||||
# ignore files
|
||||
.*ignore
|
||||
|
||||
# docker
|
||||
docker
|
||||
Dockerfile*
|
||||
|
||||
# image
|
||||
*.webp
|
||||
*.gif
|
||||
*.png
|
||||
*.jpg
|
||||
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
./src/icons/MaterialFileTypeIcon/icon-map.json
|
||||
*.ico
|
||||
*.backup
|
||||
*.mdc
|
||||
*.ttf
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').prettier;
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@lobehub/lint').remarklint;
|
||||
@@ -0,0 +1,10 @@
|
||||
const config = require('@lobehub/lint').stylelint;
|
||||
|
||||
module.exports = {
|
||||
...config,
|
||||
rules: {
|
||||
'custom-property-pattern': null,
|
||||
'no-descending-specificity': null,
|
||||
...config.rules,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
# LobeChat Mobile Agent Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
- React Native + Expo app delivering the LobeChat AI chat experience for iOS and Android from a shared codebase.
|
||||
- Core entry points: `src/app/_layout.tsx` wires global providers, while `src/app/index.tsx` (and nested routes under `src/app/(main)`) drive navigation via Expo Router.
|
||||
- State flows through colocated Zustand stores such as `store/chat`, `store/session`, and `store/openai`; selectors in `store/session/selectors` keep components efficient.
|
||||
- Key platform features include Markdown + math rendering (React Native Markdown, Shiki, MathJax), streaming chat transport via `utils/fetchSSE`, and tRPC clients configured in `utils/trpc`.
|
||||
- Follow the domain guides in `rules/` (e.g., `project-overview.mdc`, `state-management.mdc`, `api-integration.mdc`) for deeper architecture context before implementing changes.
|
||||
|
||||
## Build and Test Commands
|
||||
|
||||
| Task | Command | Notes |
|
||||
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Install dependencies | `pnpm install` | Requires Node 18+ and pnpm 8+. |
|
||||
| Start Expo dev server | `pnpm start` | Opens the Metro bundler with QR code pairing. |
|
||||
| Run on iOS simulator/device | `pnpm ios` / `pnpm device:ios` | Uses `expo run:ios`; ensure Xcode tooling is installed. |
|
||||
| Run on Android emulator/device | `pnpm android` / `pnpm device:android` | Uses `expo run:android`; start an emulator first. |
|
||||
| Web preview | `pnpm web` | Launches the web bundle for quick UI checks. |
|
||||
| Jest unit tests | `pnpm test` | Runs through `jest-expo` with coverage enabled. |
|
||||
| Lint TypeScript/JS | `pnpm lint` | Delegates to Expo + ESLint rules defined in repo. |
|
||||
| Format sources | `pnpm prettier` | Applies Prettier across the workspace. |
|
||||
| Generate translations | `pnpm i18n` | Executes scripted workflow from `rules/internationalization.mdc`. |
|
||||
| Production builds | `pnpm production:ios` / `pnpm production:android` | Wraps EAS build profiles from `eas.json`. |
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- TypeScript runs in strict mode; define interfaces/types for component props and store state, avoiding `any`. Reference `rules/app-structure.mdc` and `rules/state-management.mdc` for patterns.
|
||||
- File naming: Components in PascalCase, hooks in camelCase prefixed with `use`, utilities in camelCase, constants in UPPER_SNAKE_CASE (see `rules/development-workflow.mdc`).
|
||||
- Use absolute imports with the `@/` alias; group imports by React, third-party, then internal modules.
|
||||
- Keep UI logic declarative; colocate styles in `styles.ts` companions and respect theming conventions from `rules/color-system.mdc`.
|
||||
- Run `pnpm lint` and `pnpm prettier` before committing. Git hooks enforce Conventional Commits and formatting, so align commit messages with `feat|fix|chore` etc.
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
- Place tests under `test/` or alongside components as `*.test.ts(x)` per `rules/react-native.mdc` guidance.
|
||||
- Use `@testing-library/react-native` for rendering and interaction assertions; rely on user-centered queries rather than implementation details.
|
||||
- Mock async integrations (SecureStore, **MMKV**, network services) using Jest mocks; reference `rules/debug-usage.mdc` for logging utilities during tests.
|
||||
- MMKV is mocked in `test/utils.tsx` with an in-memory Map for test isolation.
|
||||
- Prefer explicit assertions over brittle snapshots; when snapshots are required, keep them under `__snapshots__` directories.
|
||||
- Run `pnpm test --watch` during iteration and ensure `pnpm test` + `pnpm lint` pass before opening a PR.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Keep provider keys in `.env.local` (copied from `.env.example`) and load them through the secure configuration flows in `store/openai`; never commit secrets.
|
||||
- Persist sensitive tokens with `expo-secure-store`; configuration data uses MMKV as outlined in `rules/state-management.mdc`.
|
||||
- Validate API responses and sanitize Markdown before render—Shiki/remark plugins are already configured, but keep dependencies patched.
|
||||
- Enforce HTTPS endpoints for remote calls; review EAS credentials and signing configs before production builds.
|
||||
- Monitor dependency upgrades touching auth libraries (`expo-auth-session`, `jose`, `jwt-decode`) and align with guidance in `rules/development-workflow.mdc`.
|
||||
|
||||
## Additional References
|
||||
|
||||
- `rules/quick-reference.mdc` for command cheatsheets.
|
||||
- `rules/internationalization.mdc` to extend locale coverage.
|
||||
- `rules/debug-usage.mdc` for logging and diagnostics standards.
|
||||
@@ -0,0 +1,407 @@
|
||||
Attribution-NonCommercial 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public
|
||||
License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
d. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
f. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
g. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
i. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
j. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
k. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
l. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used other than for NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the Adapted
|
||||
Material from complying with this Public License.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the "Licensor." The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
@@ -0,0 +1,558 @@
|
||||
<div align="center"><a name="readme-top"></a>
|
||||
|
||||
<img width="128" height="128" alt="appicon" src="https://github.com/user-attachments/assets/13c89551-5dd5-4dfb-9397-1aaf467c841f" />
|
||||
|
||||
# LobeHub Mobile
|
||||
|
||||
Your Workspace, Anywhere
|
||||
|
||||
The LobeHub application for iOS, and Android
|
||||
|
||||
[Parent Project][parent-project] · [Changelog][changelog] · [Report Bug][issues-link] · [Request Feature][issues-link]
|
||||
|
||||
<!-- SHIELD GROUP -->
|
||||
|
||||
[![][expo-sdk-shield]][expo-link]
|
||||
[![][react-native-shield]][react-native-link]
|
||||
[![][typescript-shield]][typescript-link]<br/>
|
||||
[![][license-shield]][license-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
[![][github-forks-shield]][github-forks-link]
|
||||
[![][github-issues-shield]][github-issues-link]
|
||||
|
||||
**Share LobeHub Mobile**
|
||||
|
||||
[![][share-x-shield]][share-x-link]
|
||||
[![][share-telegram-shield]][share-telegram-link]
|
||||
[![][share-whatsapp-shield]][share-whatsapp-link]
|
||||
[![][share-reddit-shield]][share-reddit-link]
|
||||
|
||||
<sup>Experience AI conversations on your mobile device. Built for you, the Super Individual.</sup>
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> **📱 iOS Open Beta Now Available!**
|
||||
>
|
||||
> Join our TestFlight beta program and be among the first to experience LobeHub Mobile on your iPhone or iPad!
|
||||
>
|
||||
> 🔗 **[Join TestFlight Beta](https://testflight.apple.com/join/2ZbjX4Qp)**
|
||||
>
|
||||
> We'd love to hear your feedback! Share your experience with us on [Discord][discord-link] or [GitHub Issues][issues-link]. 🫰
|
||||
|
||||
<details>
|
||||
<summary><kbd>Table of contents</kbd></summary>
|
||||
|
||||
#### TOC
|
||||
|
||||
- [👋🏻 Getting Started](#-getting-started)
|
||||
- [✨ Features](#-features)
|
||||
- [📱 Cross-Platform Native Experience](#-cross-platform-native-experience)
|
||||
- [🎨 Modern UI Design](#-modern-ui-design)
|
||||
- [🤖 Multi-Model AI Provider Support](#-multi-model-ai-provider-support)
|
||||
- [💬 Rich Conversation Features](#-rich-conversation-features)
|
||||
- [🔒 Privacy & Security First](#-privacy--security-first)
|
||||
- [`*` What's more](#-whats-more)
|
||||
- [📱 Platform Support](#-platform-support)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
- [🛠️ Tech Stack](#️-tech-stack)
|
||||
- [⌨️ Local Development](#️-local-development)
|
||||
- [Available Scripts](#available-scripts)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [Contribution Workflow](#contribution-workflow)
|
||||
- [Development Standards](#development-standards)
|
||||
- [📦 Ecosystem](#-ecosystem)
|
||||
- [❤️ Community](#️-community)
|
||||
|
||||
####
|
||||
|
||||
<br/>
|
||||
|
||||
</details>
|
||||
|
||||
## 👋🏻 Getting Started
|
||||
|
||||
We are bringing the powerful LobeHub experience to your mobile devices! Whether you're an iOS or Android user, LobeHub Mobile provides a seamless, native AI chat experience on the go.
|
||||
|
||||
| [![][testflight-shield]][testflight-link] | Download the iOS beta now! Join our TestFlight program to experience LobeHub Mobile. |
|
||||
| :---------------------------------------: | :----------------------------------------------------------------------------------- |
|
||||
| [![][discord-shield-badge]][discord-link] | Join our Discord community! Connect with other users and share your feedback. |
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay \~ ⭐️
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📱 Cross-Platform Native Experience
|
||||
|
||||
**True Native Performance on Both Platforms**
|
||||
|
||||
Built with React Native and Expo SDK 54, LobeHub Mobile delivers genuine native performance across iOS and Android. Enjoy smooth 60fps animations, instant touch feedback, and platform-specific UI patterns that feel right at home on your device.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### 🎨 Modern UI Design
|
||||
|
||||
**Beautifully Crafted for Mobile**
|
||||
|
||||
- 💎 **Refined Interface**: Carefully crafted UI with elegant visuals and smooth interactions
|
||||
- 🌗 **Adaptive Themes**: Seamless dark/light mode switching that follows system preferences
|
||||
- 📱 **Mobile-First**: Optimized touch interactions and gestures for the best mobile experience
|
||||
- ✨ **Fluid Animations**: Powered by React Native Reanimated for buttery-smooth 60fps animations
|
||||
- 🎯 **Native Patterns**: Platform-specific UI components following iOS and Android design guidelines
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### 🤖 Multi-Model AI Provider Support
|
||||
|
||||
**60+ AI Service Providers at Your Fingertips**
|
||||
|
||||
LobeHub Mobile supports an extensive range of AI service providers, giving you unparalleled flexibility to choose the best models for your needs:
|
||||
|
||||
**Major Providers:**
|
||||
|
||||
- **OpenAI**: GPT-4o, GPT-4 Turbo, GPT-3.5, and more
|
||||
- **Anthropic**: Claude 3.5 Sonnet, Claude 3 Opus/Sonnet/Haiku
|
||||
- **Google**: Gemini 2.0 Flash, Gemini Pro, and Vision models
|
||||
- **Microsoft**: Azure OpenAI, Azure AI services
|
||||
- **xAI**: Grok models
|
||||
|
||||
**Local & Self-Hosted:**
|
||||
|
||||
- Ollama, LM Studio, vLLM, Xinference
|
||||
|
||||
**Chinese Providers:**
|
||||
|
||||
- DeepSeek, Moonshot, Qwen, ZhiPu, Baichuan, Minimax
|
||||
- Hunyuan, Spark, SenseNova, Wenxin, and more
|
||||
|
||||
**Additional Providers:**
|
||||
|
||||
- Groq, Perplexity, Mistral, Together AI, Fireworks AI
|
||||
- OpenRouter, HuggingFace, Cloudflare Workers AI
|
||||
- Bedrock, Vertex AI, and 40+ more providers
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> Seamlessly switch between providers and models. All API keys are stored securely using Expo SecureStore (iOS Keychain / Android Keystore).
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### 💬 Rich Conversation Features
|
||||
|
||||
**Everything You Need for Powerful AI Conversations**
|
||||
|
||||
- 🗣️ **Streaming Responses**: Real-time AI replies with smooth streaming animation
|
||||
- 📝 **Rich Markdown**: Full Markdown support with tables, lists, GFM, and alerts
|
||||
- 🎨 **Code Highlighting**: Professional syntax highlighting powered by Shiki (100+ languages)
|
||||
- 📐 **Math Rendering**: Beautiful LaTeX formula rendering with KaTeX
|
||||
- 🎙️ **Voice Interaction**: Built-in TTS (Text-to-Speech) and STT (Speech-to-Text) support
|
||||
- 🖼️ **Vision Models**: Upload images and chat with vision-enabled AI models
|
||||
- 🎨 **Image Generation**: Create images with DALL·E, Midjourney, and more
|
||||
- 💾 **Lightning Fast Storage**: MMKV-powered local storage for instant access
|
||||
- 📤 **Export & Share**: Export conversations in multiple formats
|
||||
- 🔄 **Multi-Session**: Manage unlimited conversations with smart organization
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### 🔒 Privacy & Security First
|
||||
|
||||
**Your Data, Your Control**
|
||||
|
||||
- 🔐 **Secure Storage**: API keys protected with Expo SecureStore (iOS Keychain / Android Keystore)
|
||||
- 💾 **Local First**: All data stored locally on your device using MMKV
|
||||
- 🚫 **No Tracking**: Zero analytics or tracking - your conversations stay private
|
||||
- 🔓 **Open Source**: Fully transparent codebase you can audit and trust
|
||||
- 📴 **Offline Access**: View your chat history even without internet connection
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### `*` What's more
|
||||
|
||||
Beyond these features, LobeHub Mobile also offers:
|
||||
|
||||
- [x] 🌐 **i18n Support**: Built-in support for 18 languages with auto-detection
|
||||
- [x] 🎯 **Context Menu**: Long-press for quick actions (copy, delete, retry, regenerate)
|
||||
- [x] 📋 **Smart Copy**: Intelligent content detection for code blocks, text, or entire messages
|
||||
- [x] 🔍 **Global Search**: Quickly find messages across all conversations with full-text search
|
||||
- [x] 🏷️ **Session Groups**: Organize conversations with custom groups, folders, and tags
|
||||
- [x] 🗂️ **Topic Management**: Auto-create topics and organize conversations by context
|
||||
- [x] ⚙️ **Advanced Customization**: Fine-tune model parameters (temperature, top-p, frequency penalty, etc.)
|
||||
- [x] 📱 **Haptic Feedback**: Native haptic feedback for enhanced touch experience
|
||||
- [x] 🎨 **Theme System**: Dynamic theming with dark/light modes and system preferences
|
||||
- [x] 🔔 **Push Notifications**: Stay updated with conversation notifications
|
||||
- [x] 📊 **Token Usage Tracking**: Monitor your API usage and costs
|
||||
- [x] 🔄 **Pull to Refresh**: Natural gesture-based UI updates
|
||||
- [x] ⌨️ **Keyboard Shortcuts**: Enhanced productivity with keyboard controls
|
||||
- [x] 📤 **Import/Export**: Backup and restore your conversations
|
||||
|
||||
> ✨ More features will be added as LobeHub Mobile evolves.
|
||||
|
||||
---
|
||||
|
||||
> \[!NOTE]
|
||||
>
|
||||
> Check out our [Roadmap](https://github.com/lobehub/lobe-chat/projects) to see what's coming next!
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 📱 Platform Support
|
||||
|
||||
| Platform | Status | Recommended |
|
||||
| -------- | --------------- | --------------------- |
|
||||
| iOS | ✅ Fully Tested | iOS 18.0+ |
|
||||
| Android | ✅ Fully Tested | Android 15.0 (API 35) |
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lobehub/lobe-chat.git
|
||||
cd lobe-chat/apps/mobile
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Copy the environment template:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
2. Configure your API keys in `.env.local`:
|
||||
|
||||
```bash
|
||||
# OpenAI API Key (Required)
|
||||
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Optional: Custom API Base URL
|
||||
# OPENAI_PROXY_URL=https://api.openai.com/v1
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
# Start Expo development server
|
||||
pnpm start
|
||||
|
||||
# Or run directly on a specific platform
|
||||
pnpm ios # iOS simulator (macOS only)
|
||||
pnpm android # Android emulator
|
||||
pnpm web # Web browser preview
|
||||
```
|
||||
|
||||
**Testing on Physical Devices:**
|
||||
|
||||
1. Install [Expo Go](https://expo.dev/client) on your device:
|
||||
- [📱 iOS App Store](https://apps.apple.com/app/expo-go/id982107779)
|
||||
- [🤖 Google Play Store](https://play.google.com/store/apps/details?id=host.exp.exponent)
|
||||
|
||||
2. Scan the QR code in the terminal with Expo Go to launch the app
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> For a faster development experience on physical devices, consider using [EAS Build](https://docs.expo.dev/build/introduction/) to create custom development builds.
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
Our technology choices focus on **performance**, **developer experience**, and **maintainability**:
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
| -------------------------------- | -------- | --------------------------------------- |
|
||||
| **React Native** | 0.81.5 | Core framework for cross-platform apps |
|
||||
| **Expo SDK** | \~54.0.0 | Development platform and native modules |
|
||||
| **TypeScript** | ^5.8.2 | Type safety and better DX |
|
||||
| **Expo Router** | \~4.0.17 | File-based navigation |
|
||||
| **Zustand** | ^5.0.3 | Lightweight state management |
|
||||
| **MMKV** | ^3.1.0 | Lightning-fast local storage |
|
||||
| **React Native Reanimated** | \~3.16.7 | 60fps animations on native thread |
|
||||
| **React Native Gesture Handler** | \~2.20.2 | Native touch gestures |
|
||||
| **Shiki** | ^3.1.0 | Beautiful code syntax highlighting |
|
||||
| **React Native Markdown** | Latest | Rich markdown rendering |
|
||||
| **React i18next** | ^15.2.0 | Internationalization (18 languages) |
|
||||
| **Jest** | Latest | Testing framework |
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## ⌨️ Local Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm start # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
pnpm android # Run on Android emulator
|
||||
pnpm web # Run in web browser
|
||||
|
||||
# Testing & Quality
|
||||
pnpm test # Run Jest tests
|
||||
pnpm test:watch # Run tests in watch mode
|
||||
pnpm lint # Run ESLint
|
||||
pnpm type-check # Run TypeScript compiler check
|
||||
|
||||
# Internationalization
|
||||
pnpm i18n # Generate translations for all languages
|
||||
|
||||
# Production
|
||||
pnpm build # Create production build
|
||||
pnpm production:ios # Build iOS production app
|
||||
pnpm production:android # Build Android production app
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```bash
|
||||
apps/mobile/
|
||||
├── app/ # Expo Router pages
|
||||
│ ├── (main)/ # Main app routes
|
||||
│ ├── (setting)/ # Settings routes
|
||||
│ └── playground/ # Component playground
|
||||
├── src/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── features/ # Feature-based modules
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── types/ # TypeScript types
|
||||
│ ├── utils/ # Utility functions
|
||||
│ └── locales/ # i18n source files
|
||||
├── assets/ # Images, fonts, etc.
|
||||
├── locales/ # Generated translations
|
||||
└── test/ # Test utilities
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Feature Development**
|
||||
- Create feature branch from `feat/mobile-app`
|
||||
- Follow [Development Guidelines](./docs/DEVELOPMENT.md)
|
||||
- Write tests for new features
|
||||
|
||||
2. **Code Quality**
|
||||
- Run `pnpm lint` before committing
|
||||
- Use TypeScript strictly (no `any`)
|
||||
- Follow existing code patterns
|
||||
|
||||
3. **Internationalization**
|
||||
- Add translations to `src/locales/default/common.ts`
|
||||
- Run `pnpm i18n` to generate all languages
|
||||
- Test with different locales in app settings
|
||||
|
||||
4. **Testing**
|
||||
- Write unit tests for utilities and hooks
|
||||
- Write component tests with React Native Testing Library
|
||||
- Ensure `pnpm test` passes before PR
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions of all types are more than welcome! If you are interested in contributing code, feel free to check out our GitHub [Issues](https://github.com/lobehub/lobe-chat/issues) and [Projects](https://github.com/lobehub/lobe-chat/projects).
|
||||
|
||||
> \[!TIP]
|
||||
>
|
||||
> We are building a modern mobile AI chat application. Join us in creating an amazing user experience!
|
||||
>
|
||||
> Whether it's **reporting bugs**, **requesting features**, **improving documentation**, or **contributing code** - we appreciate it all.
|
||||
|
||||
[![][pr-welcome-shield]][pr-welcome-link]
|
||||
|
||||
### Contribution Workflow
|
||||
|
||||
1. **Fork** the repository
|
||||
2. **Clone** your fork: `git clone https://github.com/your-username/lobe-chat.git`
|
||||
3. **Create** a feature branch: `git checkout -b feature/amazing-feature`
|
||||
4. **Make** your changes
|
||||
5. **Test** your changes: `pnpm test && pnpm lint`
|
||||
6. **Commit** with conventional commit messages: `git commit -m 'feat: add amazing feature'`
|
||||
7. **Push** to your fork: `git push origin feature/amazing-feature`
|
||||
8. **Open** a Pull Request
|
||||
|
||||
### Development Standards
|
||||
|
||||
- 📝 **Commit Messages**: Follow [Conventional Commits](https://conventionalcommits.org/)
|
||||
- `feat:` New features
|
||||
- `fix:` Bug fixes
|
||||
- `docs:` Documentation changes
|
||||
- `style:` Code style changes (formatting, etc.)
|
||||
- `refactor:` Code refactoring
|
||||
- `test:` Adding or updating tests
|
||||
- `chore:` Build process or auxiliary tool changes
|
||||
|
||||
- 💻 **Code Style**
|
||||
- Use TypeScript strictly
|
||||
- Follow ESLint and Prettier rules
|
||||
- Write meaningful variable and function names
|
||||
- Add comments for complex logic
|
||||
|
||||
- 🧪 **Testing**
|
||||
- Write tests for new features
|
||||
- Ensure all tests pass
|
||||
- Maintain or improve code coverage
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## 📦 Ecosystem
|
||||
|
||||
| NPM | Repository | Description | Version |
|
||||
| --------------------------------- | --------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| [@lobehub/ui][lobe-ui-link] | [lobehub/lobe-ui][lobe-ui-github] | Open-source UI component library for building AIGC web applications | [![][lobe-ui-shield]][lobe-ui-link] |
|
||||
| [@lobehub/icons][lobe-icons-link] | [lobehub/lobe-icons][lobe-icons-github] | Popular AI / LLM Model Brand SVG Logo and Icon Collection | [![][lobe-icons-shield]][lobe-icons-link] |
|
||||
| [@lobehub/tts][lobe-tts-link] | [lobehub/lobe-tts][lobe-tts-github] | High-quality & reliable TTS/STT React Hooks library | [![][lobe-tts-shield]][lobe-tts-link] |
|
||||
| [@lobehub/lint][lobe-lint-link] | [lobehub/lobe-lint][lobe-lint-github] | ESlint, Stylelint, Commitlint, Prettier configurations for LobeHub | [![][lobe-lint-shield]][lobe-lint-link] |
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## ❤️ Community
|
||||
|
||||
We are a group of e/acc design-engineers, hoping to provide modern design components and tools for AIGC. By adopting the Bootstrapping approach, we aim to provide developers and users with a more open, transparent, and user-friendly product ecosystem.
|
||||
|
||||
| [![][parent-shield]][parent-project] | No installation or registration necessary! Visit our website to experience the web version firsthand. |
|
||||
| :---------------------------------------: | :---------------------------------------------------------------------------------------------------- |
|
||||
| [![][discord-shield-badge]][discord-link] | Join our Discord community! Connect with developers and other enthusiastic users of LobeHub. |
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay \~ ⭐️
|
||||
|
||||
<div align="right">
|
||||
|
||||
[![][back-to-top]](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
#### 📝 License
|
||||
|
||||
[![][license-image]][license-link]
|
||||
|
||||
Copyright © 2025 [LobeHub][profile-link]. <br />
|
||||
This project is licensed under a [Creative Commons Attribution-NonCommercial 4.0 International License][license-link].
|
||||
|
||||
<!-- LINK GROUP -->
|
||||
|
||||
[back-to-top]: https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square
|
||||
[changelog]: https://github.com/lobehub/lobe-chat/blob/main/CHANGELOG.md
|
||||
[discord-link]: https://discord.gg/AYFPHvv2jT
|
||||
[discord-shield-badge]: https://img.shields.io/discord/1127171173982154893?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=for-the-badge
|
||||
[expo-link]: https://expo.dev
|
||||
[expo-sdk-shield]: https://img.shields.io/badge/Expo-54.0.0-000020?labelColor=black&logo=expo&style=flat-square
|
||||
[github-forks-link]: https://github.com/lobehub/lobe-chat/network/members
|
||||
[github-forks-shield]: https://img.shields.io/github/forks/lobehub/lobe-chat?color=8ae8ff&labelColor=black&style=flat-square
|
||||
[github-issues-link]: https://github.com/lobehub/lobe-chat/issues
|
||||
[github-issues-shield]: https://img.shields.io/github/issues/lobehub/lobe-chat?color=ff80eb&labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/network/stargazers
|
||||
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobe-chat?color=ffcb47&labelColor=black&style=flat-square
|
||||
[issues-link]: https://github.com/lobehub/lobe-chat/issues/new/choose
|
||||
[license-image]: https://licensebuttons.net/l/by-nc/4.0/88x31.png
|
||||
[license-link]: https://creativecommons.org/licenses/by-nc/4.0/
|
||||
[license-shield]: https://img.shields.io/badge/License-CC%20BY--NC%204.0-lightgrey?labelColor=black&style=flat-square
|
||||
[lobe-icons-github]: https://github.com/lobehub/lobe-icons
|
||||
[lobe-icons-link]: https://www.npmjs.com/package/@lobehub/icons
|
||||
[lobe-icons-shield]: https://img.shields.io/npm/v/@lobehub/icons?color=369eff&labelColor=black&logo=npm&logoColor=white&style=flat-square
|
||||
[lobe-lint-github]: https://github.com/lobehub/lobe-lint
|
||||
[lobe-lint-link]: https://www.npmjs.com/package/@lobehub/lint
|
||||
[lobe-lint-shield]: https://img.shields.io/npm/v/@lobehub/lint?color=369eff&labelColor=black&logo=npm&logoColor=white&style=flat-square
|
||||
[lobe-tts-github]: https://github.com/lobehub/lobe-tts
|
||||
[lobe-tts-link]: https://www.npmjs.com/package/@lobehub/tts
|
||||
[lobe-tts-shield]: https://img.shields.io/npm/v/@lobehub/tts?color=369eff&labelColor=black&logo=npm&logoColor=white&style=flat-square
|
||||
[lobe-ui-github]: https://github.com/lobehub/lobe-ui
|
||||
[lobe-ui-link]: https://www.npmjs.com/package/@lobehub/ui
|
||||
[lobe-ui-shield]: https://img.shields.io/npm/v/@lobehub/ui?color=369eff&labelColor=black&logo=npm&logoColor=white&style=flat-square
|
||||
[parent-project]: https://github.com/lobehub/lobe-chat
|
||||
[parent-shield]: https://img.shields.io/badge/🤯_Lobe_Chat-Web_Version-55b467?labelColor=black&style=for-the-badge
|
||||
[pr-welcome-link]: https://github.com/lobehub/lobe-chat/pulls
|
||||
[pr-welcome-shield]: https://img.shields.io/badge/🤯_PR_WELCOME-%E2%86%92-ffcb47?labelColor=black&style=for-the-badge
|
||||
[profile-link]: https://github.com/lobehub
|
||||
[react-native-link]: https://reactnative.dev
|
||||
[react-native-shield]: https://img.shields.io/badge/React%20Native-0.81.5-61dafb?labelColor=black&logo=react&style=flat-square
|
||||
[share-reddit-link]: https://www.reddit.com/submit?title=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20Mobile%20-%20An%20open-source%2C%20modern-design%20AI%20chat%20mobile%20application.%20One-click%20FREE%20deployment%20of%20your%20private%20ChatGPT%2FClaude%2FGemini%20mobile%20app.&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat
|
||||
[share-reddit-shield]: https://img.shields.io/badge/-share%20on%20reddit-black?labelColor=black&logo=reddit&logoColor=white&style=flat-square
|
||||
[share-telegram-link]: https://t.me/share/url?text=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20Mobile%20-%20An%20open-source%2C%20modern-design%20AI%20chat%20mobile%20application.%20One-click%20FREE%20deployment%20of%20your%20private%20ChatGPT%2FClaude%2FGemini%20mobile%20app.&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat
|
||||
[share-telegram-shield]: https://img.shields.io/badge/-share%20on%20telegram-black?labelColor=black&logo=telegram&logoColor=white&style=flat-square
|
||||
[share-whatsapp-link]: https://api.whatsapp.com/send?text=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20Mobile%20-%20An%20open-source%2C%20modern-design%20AI%20chat%20mobile%20application.%20One-click%20FREE%20deployment%20of%20your%20private%20ChatGPT%2FClaude%2FGemini%20mobile%20app.%20https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat
|
||||
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
|
||||
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2Cmobile%2Creactnative&text=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20Mobile%20-%20An%20open-source%2C%20modern-design%20AI%20chat%20mobile%20application.%20One-click%20FREE%20deployment%20of%20your%20private%20ChatGPT%2FClaude%2FGemini%20mobile%20app.&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat
|
||||
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
|
||||
[testflight-link]: https://testflight.apple.com/join/2ZbjX4Qp
|
||||
[testflight-shield]: https://img.shields.io/badge/TestFlight-iOS_Beta-0D96F6?labelColor=black&logo=apple&style=for-the-badge
|
||||
[typescript-link]: https://www.typescriptlang.org
|
||||
[typescript-shield]: https://img.shields.io/badge/TypeScript-5.8.2-3178c6?labelColor=black&logo=typescript&style=flat-square
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
import 'tsx/cjs';
|
||||
|
||||
import { version } from './package.json';
|
||||
|
||||
/**
|
||||
* Expo 配置
|
||||
* 使用 TypeScript 提供类型安全和自动补全
|
||||
* @see https://docs.expo.dev/workflow/configuration/
|
||||
*/
|
||||
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
...config,
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: './assets/images/icon-android-background.png',
|
||||
foregroundImage: './assets/images/icon-android-foreground.png',
|
||||
monochromeImage: './assets/images/icon-android-foreground.png',
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
icon: './assets/images/icon-android.png',
|
||||
package: 'com.lobehub.app',
|
||||
permissions: [
|
||||
'android.permission.READ_EXTERNAL_STORAGE',
|
||||
'android.permission.WRITE_EXTERNAL_STORAGE',
|
||||
'android.permission.READ_MEDIA_IMAGES',
|
||||
],
|
||||
},
|
||||
androidNavigationBar: {
|
||||
barStyle: 'light-content',
|
||||
},
|
||||
androidStatusBar: {
|
||||
barStyle: 'light-content',
|
||||
},
|
||||
experiments: {
|
||||
// @ts-ignore
|
||||
appDir: './src/app',
|
||||
typedRoutes: true,
|
||||
},
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: 'f02d6f4f-e042-4c95-ba0d-ac06bb474ef0',
|
||||
},
|
||||
router: {
|
||||
origin: false,
|
||||
},
|
||||
},
|
||||
icon: './assets/images/icon.png',
|
||||
|
||||
ios: {
|
||||
appleTeamId: '4684H589ZU',
|
||||
bundleIdentifier: 'com.lobehub.app',
|
||||
icon: './assets/images/ios.icon',
|
||||
infoPlist: {
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
NSAppTransportSecurity: {
|
||||
NSAllowsArbitraryLoads: true,
|
||||
},
|
||||
NSPhotoLibraryAddUsageDescription: '需要保存图片到相册',
|
||||
NSPhotoLibraryUsageDescription: '需要访问相册以保存图片',
|
||||
// 仅支持 iPhone
|
||||
UIDeviceFamily: [1],
|
||||
},
|
||||
// 声明不支持平板
|
||||
supportsTablet: false,
|
||||
userInterfaceStyle: 'automatic',
|
||||
},
|
||||
name: 'LobeHub',
|
||||
newArchEnabled: true,
|
||||
orientation: 'portrait',
|
||||
owner: 'lobehub',
|
||||
plugins: [
|
||||
['expo-build-properties', { ios: { deploymentTarget: '16.4' } }],
|
||||
'expo-router',
|
||||
'expo-video',
|
||||
['react-native-edge-to-edge', { android: { enforceNavigationBarContrast: false } }],
|
||||
[
|
||||
'expo-notifications',
|
||||
{
|
||||
color: '#000',
|
||||
icon: './assets/images/icon-android-notification.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-splash-screen',
|
||||
{
|
||||
android: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
dark: {
|
||||
backgroundColor: '#000',
|
||||
image: './assets/images/splash-icon.png',
|
||||
imageWidth: 150,
|
||||
},
|
||||
image: './assets/images/splash-icon.png',
|
||||
imageWidth: 150,
|
||||
},
|
||||
ios: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
dark: {
|
||||
backgroundColor: '#000',
|
||||
enableFullScreenImage_legacy: true,
|
||||
image: './assets/images/splash-dark.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
enableFullScreenImage_legacy: true,
|
||||
image: './assets/images/splash.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-font',
|
||||
{
|
||||
android: {
|
||||
fonts: [
|
||||
{
|
||||
fontDefinitions: [
|
||||
{
|
||||
path: './assets/fonts/Hack-Regular.ttf',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
path: './assets/fonts/Hack-Bold.ttf',
|
||||
weight: 700,
|
||||
},
|
||||
{
|
||||
path: './assets/fonts/Hack-Italic.ttf',
|
||||
style: 'italic',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
path: './assets/fonts/Hack-BoldItalic.ttf',
|
||||
style: 'italic',
|
||||
weight: 700,
|
||||
},
|
||||
],
|
||||
fontFamily: 'Hack',
|
||||
},
|
||||
{
|
||||
fontDefinitions: [
|
||||
{
|
||||
path: './assets/fonts/HarmonyOS_Sans_SC_Regular.ttf',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
path: './assets/fonts/HarmonyOS_Sans_SC_Medium.ttf',
|
||||
weight: 500,
|
||||
},
|
||||
{
|
||||
path: './assets/fonts/HarmonyOS_Sans_SC_Bold.ttf',
|
||||
weight: 700,
|
||||
},
|
||||
],
|
||||
fontFamily: 'HarmonyOS-Sans-SC',
|
||||
},
|
||||
],
|
||||
},
|
||||
fonts: [
|
||||
'./assets/fonts/Hack-Regular.ttf',
|
||||
'./assets/fonts/Hack-Bold.ttf',
|
||||
'./assets/fonts/Hack-Italic.ttf',
|
||||
'./assets/fonts/Hack-BoldItalic.ttf',
|
||||
'./assets/fonts/HarmonyOS_Sans_SC_Regular.ttf',
|
||||
'./assets/fonts/HarmonyOS_Sans_SC_Medium.ttf',
|
||||
'./assets/fonts/HarmonyOS_Sans_SC_Bold.ttf',
|
||||
],
|
||||
ios: {
|
||||
fonts: [
|
||||
'./assets/fonts/Hack-Regular.ttf',
|
||||
'./assets/fonts/Hack-Bold.ttf',
|
||||
'./assets/fonts/Hack-Italic.ttf',
|
||||
'./assets/fonts/Hack-BoldItalic.ttf',
|
||||
'./assets/fonts/HarmonyOS_Sans_SC_Regular.ttf',
|
||||
'./assets/fonts/HarmonyOS_Sans_SC_Medium.ttf',
|
||||
'./assets/fonts/HarmonyOS_Sans_SC_Bold.ttf',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
'expo-secure-store',
|
||||
'expo-localization',
|
||||
'./plugins/withFbjniFix.ts',
|
||||
'./plugins/withAndroidTransparentNavigation.ts',
|
||||
],
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
scheme: 'com.lobehub.app',
|
||||
slug: 'lobe-chat-react-native',
|
||||
updates: {
|
||||
url: 'https://u.expo.dev/f02d6f4f-e042-4c95-ba0d-ac06bb474ef0',
|
||||
},
|
||||
userInterfaceStyle: 'automatic',
|
||||
version: version,
|
||||
web: {
|
||||
bundler: 'metro',
|
||||
favicon: './assets/images/favicon.ico',
|
||||
output: 'static',
|
||||
},
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user