mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 11:40:07 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eaf28e498 | |||
| 5bfb876340 |
@@ -1,38 +0,0 @@
|
||||
---
|
||||
allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh api:*), Bash(gh issue comment:*)
|
||||
description: Find duplicate GitHub issues
|
||||
---
|
||||
|
||||
Find up to 3 likely duplicate issues for a given GitHub issue.
|
||||
|
||||
To do this, follow these steps precisely:
|
||||
|
||||
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
|
||||
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
|
||||
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
|
||||
|
||||
Notes (be sure to tell this to your agents, too):
|
||||
|
||||
- Use `gh` to interact with Github, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
|
||||
|
||||
---
|
||||
|
||||
Found 3 possible duplicate issues:
|
||||
|
||||
1. <link to issue>
|
||||
2. <link to issue>
|
||||
3. <link to issue>
|
||||
|
||||
This issue will be automatically closed as a duplicate in 3 days.
|
||||
|
||||
- If your issue is a duplicate, please close it and 👍 the existing issue instead
|
||||
- To prevent auto-closure, add a comment or 👎 this comment
|
||||
|
||||
> 🤖 Generated with Claude Code
|
||||
|
||||
---
|
||||
@@ -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,253 +0,0 @@
|
||||
# Issue Triage Guide
|
||||
|
||||
This guide is used for batch triaging GitHub issues - analyzing issues and applying appropriate labels.
|
||||
|
||||
## Workflow
|
||||
|
||||
For EACH issue, follow these steps:
|
||||
|
||||
### Step 1: Get Available Labels (run once per batch)
|
||||
|
||||
```bash
|
||||
gh label list --json name,description --limit 300
|
||||
```
|
||||
|
||||
### Step 2: Get Issue Details
|
||||
|
||||
For each issue number, run:
|
||||
|
||||
```bash
|
||||
gh issue view [ISSUE_NUMBER] --json number,title,body,labels,comments
|
||||
```
|
||||
|
||||
### Step 3: Analyze and Select Labels
|
||||
|
||||
Extract information from the issue template and content:
|
||||
|
||||
#### Template Fields Mapping
|
||||
|
||||
- 📦 Platform field → `platform:web/desktop/mobile`
|
||||
- 💻 Operating System → `os:windows/macos/linux/ios`
|
||||
- 🌐 Browser → `device:pc/mobile`
|
||||
- 📦 Deployment mode → `deployment:server/client/pglite`
|
||||
- Platform (hosting) → `hosting:cloud/self-host/vercel/zeabur/railway`
|
||||
|
||||
#### Provider Detection
|
||||
|
||||
**IMPORTANT**: Always check issue title and body for provider mentions!
|
||||
|
||||
**Official Providers** (check for these keywords in title/body):
|
||||
|
||||
- `openai`, `gpt` → `provider:openai`
|
||||
- `gemini` → `provider:gemini`
|
||||
- `claude`, `anthropic` → `provider:claude`
|
||||
- `deepseek` → `provider:deepseek`
|
||||
- `google` → `provider:google`
|
||||
- `ollama` → `provider:ollama`
|
||||
- `azure` → `provider:azure`
|
||||
- `bedrock` → `provider:bedrock`
|
||||
- `vertex` → `provider:vertex`
|
||||
- `groq`, `grok` → `provider:groq`
|
||||
- `mistral` → `provider:mistral`
|
||||
- `moonshot` → `provider:moonshot`
|
||||
- `zhipu` → `provider:zhipu`
|
||||
- `minimax` → `provider:minimax`
|
||||
- `doubao` → `provider:doubao`
|
||||
|
||||
**Third-party Aggregation Providers**:
|
||||
|
||||
- `aihubmix`, `AIHubMix`, `AIHUBMIX` → `provider:aihubmix`
|
||||
- Check environment variables like `AIHUBMIX_*` in issue body
|
||||
|
||||
**Multiple Providers**: If issue mentions multiple providers, add ALL applicable provider labels.
|
||||
|
||||
### Label Categories
|
||||
|
||||
#### a) Issue Type (select ONE if applicable)
|
||||
|
||||
- `💄 Design` - UI/UX design issues
|
||||
- `📝 Documentation` - Documentation improvements
|
||||
- `⚡️ Performance` - Performance optimization
|
||||
|
||||
#### b) Priority (select ONE if applicable)
|
||||
|
||||
- `priority:high` - Critical issues, data loss, security, maintainer mentions "urgent"/"serious"/"critical"
|
||||
- `priority:medium` - Important issues affecting multiple users, significant functionality impact
|
||||
- `priority:low` - Nice to have, minor issues, edge cases
|
||||
|
||||
**Priority Guidelines**:
|
||||
|
||||
- Set `priority:high` for: data loss, authentication failures, deployment blockers, critical bugs
|
||||
- Set `priority:medium` for: feature bugs affecting multiple users, workflow issues
|
||||
- Set `priority:low` for: cosmetic issues, feature requests, configuration questions
|
||||
|
||||
#### c) Platform (select ALL applicable)
|
||||
|
||||
- `platform:web`
|
||||
- `platform:desktop`
|
||||
- `platform:mobile`
|
||||
|
||||
#### d) Device (for platform:web, select ONE)
|
||||
|
||||
- `device:pc`
|
||||
- `device:mobile`
|
||||
|
||||
#### e) Operating System (select ALL applicable)
|
||||
|
||||
- `os:windows`
|
||||
- `os:macos`
|
||||
- `os:linux`
|
||||
- `os:ios`
|
||||
- `os:android`
|
||||
|
||||
#### f) Hosting Platform (select ONE)
|
||||
|
||||
- `hosting:cloud` - Official LobeHub Cloud
|
||||
- `hosting:self-host` - Self-hosted deployment
|
||||
- `hosting:vercel` - Vercel deployment
|
||||
- `hosting:zeabur` - Zeabur deployment
|
||||
- `hosting:railway` - Railway deployment
|
||||
|
||||
#### g) Deployment Mode (select ONE if mentioned)
|
||||
|
||||
- `deployment:server` - Server-side database mode
|
||||
- `deployment:client` - Client-side database mode
|
||||
- `deployment:pglite` - PGLite mode
|
||||
|
||||
**Additional deployment tags**:
|
||||
|
||||
- `docker` - If using Docker deployment
|
||||
- `electron` - If desktop/Electron specific
|
||||
|
||||
#### h) Model Provider (select ALL applicable)
|
||||
|
||||
See "Provider Detection" section above for complete list.
|
||||
|
||||
**IMPORTANT**: Always scan issue title and body for provider keywords!
|
||||
|
||||
#### i) Feature/Component (select ALL applicable)
|
||||
|
||||
Core Features:
|
||||
|
||||
- `feature:settings` - Settings and configuration
|
||||
- `feature:agent` - Agent/Assistant functionality
|
||||
- `feature:topic` - Topic/Conversation management
|
||||
- `feature:marketplace` - Agent marketplace
|
||||
|
||||
File & Knowledge:
|
||||
|
||||
- `feature:files` - File upload/management
|
||||
- `feature:knowledge-base` - Knowledge base and RAG
|
||||
- `feature:export` - Export functionality
|
||||
|
||||
Model Capabilities:
|
||||
|
||||
- `feature:streaming` - Streaming responses
|
||||
- `feature:tool` - Tool calling
|
||||
- `feature:vision` - Vision/multimodal capabilities
|
||||
- `feature:image` - AI image generation
|
||||
- `feature:dalle` - DALL-E specific
|
||||
- `feature:tts` - Text-to-speech
|
||||
|
||||
Technical:
|
||||
|
||||
- `feature:api` - Backend API
|
||||
- `feature:auth` - Authentication/authorization
|
||||
- `feature:sync` - Cloud sync functionality
|
||||
- `feature:search` - Search functionality
|
||||
- `feature:mcp` - MCP integration
|
||||
- `feature:editor` - Lobe Editor
|
||||
- `feature:markdown` - Markdown rendering
|
||||
- `feature:thread` - Thread/Subtopic functionality
|
||||
|
||||
Collaboration:
|
||||
|
||||
- `feature:group-chat` - Group chat functionality
|
||||
- `feature:memory` - Memory feature
|
||||
- `feature:team-workspace` - Team workspace
|
||||
|
||||
#### j) Workflow/Status
|
||||
|
||||
- `Duplicate` - Only if duplicate of an OPEN issue (mention issue number)
|
||||
- `needs-reproduction` - Cannot reproduce, needs more information
|
||||
- `good-first-issue` - Good for first-time contributors
|
||||
- `🤔 Need Reproduce` - Needs reproduction steps
|
||||
|
||||
### Step 4: Apply Labels
|
||||
|
||||
Add labels (comma-separated, no spaces after commas):
|
||||
|
||||
```bash
|
||||
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2,label3"
|
||||
```
|
||||
|
||||
Remove "unconfirm" label if adding other labels:
|
||||
|
||||
```bash
|
||||
gh issue edit [ISSUE_NUMBER] --remove-label "unconfirm"
|
||||
```
|
||||
|
||||
**Important**: Combine both commands when possible for efficiency.
|
||||
|
||||
### Step 5: Log Summary
|
||||
|
||||
For each issue, provide reasoning (2-4 sentences):
|
||||
|
||||
- Labels applied and why
|
||||
- Key factors from issue template/comments
|
||||
- Provider detection reasoning (if applicable)
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Read Carefully**: Read issue template fields AND issue body/title for complete context
|
||||
2. **Provider Detection**: ALWAYS check title and body for provider keywords (including aihubmix, etc.)
|
||||
3. **Multiple Categories**: Use ALL applicable labels from different categories
|
||||
4. **Label Prefixes**: Always use proper prefixes (`feature:`, `provider:`, `os:`, `platform:`, etc.)
|
||||
5. **Maintainer Comments**: Check maintainer comments for priority/status hints
|
||||
6. **No Comments**: Only apply labels, DO NOT post comments to issues
|
||||
7. **Batch Efficiency**: Process issues in parallel when possible
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Provider in Environment Variables
|
||||
|
||||
If issue body contains `AIHUBMIX_*`, add `provider:aihubmix`
|
||||
|
||||
### Multiple Provider Issues
|
||||
|
||||
If comparing providers (e.g., "works with OpenAI but not Gemini"), add both provider labels
|
||||
|
||||
### Desktop Issues
|
||||
|
||||
Desktop issues often need: `platform:desktop`, `electron`, specific `os:*`, and `deployment:client` or `deployment:server`
|
||||
|
||||
### Knowledge Base Issues
|
||||
|
||||
Usually need: `feature:knowledge-base`, often with `feature:files`, may need `provider:*` for embedding models
|
||||
|
||||
### Tool Calling Issues
|
||||
|
||||
Usually need: `feature:tool`, specific `provider:*`, may need `feature:mcp` if MCP-related
|
||||
|
||||
### Streaming Issues
|
||||
|
||||
Usually need: `feature:streaming`, specific `provider:*`, check for timeout/performance issues
|
||||
|
||||
## Example Triage
|
||||
|
||||
**Issue #8850**: "aihubmix 的优惠 app 没有生效"
|
||||
|
||||
**Analysis**:
|
||||
|
||||
- Title contains "aihubmix" → `provider:aihubmix`
|
||||
- Template shows: Windows, Chrome, Docker, Client mode
|
||||
- About API discount codes not working
|
||||
|
||||
**Labels Applied**:
|
||||
|
||||
```bash
|
||||
gh issue edit 8850 --add-label "provider:aihubmix,platform:web,os:windows,deployment:client,hosting:self-host,docker"
|
||||
gh issue edit 8850 --remove-label "unconfirm"
|
||||
```
|
||||
|
||||
**Reasoning**: AIHubMix provider discount feature not working. Client mode deployment on Windows with Docker. Provider detection from title keyword "aihubmix".
|
||||
@@ -1,135 +0,0 @@
|
||||
# Team Assignment Guide
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling , mcp
|
||||
- **@canisminor1990**: Design, UI components, editor
|
||||
- **@tjx666**: Image/video generation, vision, cloud, documentation, TTS
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
|
||||
- **@RiverTwilight**: Knowledge base, files (KB-related), group chat
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@cy948**: Auth Modules
|
||||
- **@rdmclin2**: Team workspace
|
||||
|
||||
Quick reference for assigning issues based on labels.
|
||||
|
||||
## Label to Team Member Mapping
|
||||
|
||||
### Provider Labels (provider:\*)
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ---------------- | ------- | -------------------------------------------- |
|
||||
| All `provider:*` | @sxjeru | Model configuration and provider integration |
|
||||
|
||||
### Platform Labels (platform:\*)
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | ----------- | -------------------------------------- |
|
||||
| `platform:mobile` | @sudongyuer | React Native mobile app |
|
||||
| `platform:desktop` | @ONLY-yours | Electron desktop client (general) |
|
||||
| `platform:web` | @ONLY-yours | Web platform (unless specific feature) |
|
||||
|
||||
### Feature Labels (feature:\*)
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ------------------------ | --------------- | ----------------------------------------------------------------------- |
|
||||
| `feature:image` | @tjx666 | AI image generation |
|
||||
| `feature:dalle` | @tjx666 | DALL-E related |
|
||||
| `feature:vision` | @tjx666 | Vision/multimodal generation |
|
||||
| `feature:knowledge-base` | @RiverTwilight | Knowledge base and RAG |
|
||||
| `feature:files` | @RiverTwilight | File upload/management (when KB-related)<br>@ONLY-yours (general files) |
|
||||
| `feature:editor` | @canisminor1990 | Lobe Editor |
|
||||
| `feature:auth` | @cy948 | Authentication/authorization |
|
||||
| `feature:api` | @nekomeowww | Backend API |
|
||||
| `feature:streaming` | @arvinxx | Streaming response |
|
||||
| `feature:settings` | @ONLY-yours | Settings and configuration |
|
||||
| `feature:agent` | @ONLY-yours | Agent/Assistant |
|
||||
| `feature:topic` | @ONLY-yours | Topic/Conversation management |
|
||||
| `feature:thread` | @arvinxx | Thread/Subtopic |
|
||||
| `feature:marketplace` | @ONLY-yours | Agent marketplace |
|
||||
| `feature:tool` | @arvinxx | Tool calling |
|
||||
| `feature:mcp` | @arvinxx | MCP integration |
|
||||
| `feature:search` | @ONLY-yours | Search functionality |
|
||||
| `feature:tts` | @tjx666 | Text-to-speech |
|
||||
| `feature:export` | @ONLY-yours | Export functionality |
|
||||
| `feature:group-chat` | @RiverTwilight | Group chat functionality |
|
||||
| `feature:memory` | @nekomeowww | Memory feature |
|
||||
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
|
||||
|
||||
### Deployment Labels (deployment:\*)
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | ----------- | -------------------------- |
|
||||
| All `deployment:*` | @nekomeowww | Server/client/pglite modes |
|
||||
|
||||
### Hosting Labels (hosting:\*)
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ------------------- | ----------- | ---------------------- |
|
||||
| `hosting:cloud` | @tjx666 | Official LobeHub Cloud |
|
||||
| `hosting:self-host` | @nekomeowww | Self-hosting issues |
|
||||
| `hosting:vercel` | @nekomeowww | Vercel deployment |
|
||||
| `hosting:zeabur` | @nekomeowww | Zeabur deployment |
|
||||
| `hosting:railway` | @nekomeowww | Railway deployment |
|
||||
|
||||
### Issue Type Labels
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | -------------------- | ---------------------------- |
|
||||
| 💄 Design | @canisminor1990 | Design and styling |
|
||||
| 📝 Documentation | @tjx666 | Documentation |
|
||||
| ⚡️ Performance | @ONLY-yours | Performance optimization |
|
||||
| 🐛 Bug | (depends on feature) | Assign based on other labels |
|
||||
| 🌠 Feature Request | (depends on feature) | Assign based on other labels |
|
||||
|
||||
## Assignment Rules
|
||||
|
||||
### Priority Order (apply in order)
|
||||
|
||||
1. **Specific feature owner** - e.g., `feature:knowledge-base` → @RiverTwilight
|
||||
2. **Platform owner** - e.g., `platform:mobile` → @sudongyuer
|
||||
3. **Provider owner** - e.g., `provider:*` → @sxjeru
|
||||
4. **Component owner** - e.g., 💄 Design → @canisminor1990
|
||||
5. **Infrastructure owner** - e.g., `deployment:*` → @nekomeowww
|
||||
6. **General maintainer** - @ONLY-yours for general bugs/issues
|
||||
7. **Last resort** - @arvinxx (only if no clear owner)
|
||||
|
||||
### Special Cases
|
||||
|
||||
**Multiple labels with different owners:**
|
||||
|
||||
- Mention the **most specific** feature owner first
|
||||
- Mention secondary owners if their input is valuable
|
||||
- Example: `feature:knowledge-base` + `deployment:server` → @RiverTwilight (primary), @nekomeowww (secondary)
|
||||
|
||||
**Priority:high issues:**
|
||||
|
||||
- Mention feature owner + @arvinxx
|
||||
- Example: `priority:high` + `feature:image` → @tjx666 @arvinxx
|
||||
|
||||
**No clear owner:**
|
||||
|
||||
- Assign to @ONLY-yours for general issues
|
||||
- Only mention @arvinxx if critical and truly unclear
|
||||
|
||||
## Comment Templates
|
||||
|
||||
**Single owner:**
|
||||
|
||||
```
|
||||
@username - This is a [feature/component] issue. Please take a look.
|
||||
```
|
||||
|
||||
**Multiple owners:**
|
||||
|
||||
```
|
||||
@primary @secondary - This involves [features]. Please coordinate.
|
||||
```
|
||||
|
||||
**High priority:**
|
||||
|
||||
```
|
||||
@owner @arvinxx - High priority [feature] issue.
|
||||
```
|
||||
@@ -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
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
description: Guide for adding environment variables to configure user settings
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Adding Environment Variable for User Settings
|
||||
|
||||
Add server-side environment variables to configure default values for user settings.
|
||||
|
||||
**Priority**: User Custom > Server Env Var > Hardcoded Default
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Define Environment Variable
|
||||
|
||||
Create `src/envs/<domain>.ts`:
|
||||
|
||||
```typescript
|
||||
import { createEnv } from '@t3-oss/env-nextjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const get<Domain>Config = () => {
|
||||
return createEnv({
|
||||
server: {
|
||||
YOUR_ENV_VAR: z.coerce.number().min(MIN).max(MAX).optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
YOUR_ENV_VAR: process.env.YOUR_ENV_VAR,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const <domain>Env = get<Domain>Config();
|
||||
```
|
||||
|
||||
### 2. Update Type (Optional)
|
||||
|
||||
**Skip this step if the domain field already exists in `GlobalServerConfig`.**
|
||||
|
||||
Add to `packages/types/src/serverConfig.ts`:
|
||||
|
||||
```typescript
|
||||
export interface GlobalServerConfig {
|
||||
<domain>?: {
|
||||
<settingName>?: <type>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Prefer reusing existing types** from `packages/types/src/user/settings` with `PartialDeep`:
|
||||
|
||||
```typescript
|
||||
import { User<Domain>Config } from './user/settings';
|
||||
|
||||
export interface GlobalServerConfig {
|
||||
<domain>?: PartialDeep<User<Domain>Config>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Assemble Server Config (Optional)
|
||||
|
||||
**Skip this step if the domain field already exists in server config.**
|
||||
|
||||
In `src/server/globalConfig/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { <domain>Env } from '@/envs/<domain>';
|
||||
import { cleanObject } from '@/utils/object';
|
||||
|
||||
export const getServerGlobalConfig = async () => {
|
||||
const config: GlobalServerConfig = {
|
||||
// ...
|
||||
<domain>: cleanObject({
|
||||
<settingName>: <domain>Env.YOUR_ENV_VAR,
|
||||
}),
|
||||
};
|
||||
return config;
|
||||
};
|
||||
```
|
||||
|
||||
If the domain already exists, just add the new field to the existing `cleanObject()`:
|
||||
|
||||
```typescript
|
||||
<domain>: cleanObject({
|
||||
existingField: <domain>Env.EXISTING_VAR,
|
||||
<settingName>: <domain>Env.YOUR_ENV_VAR, // Add this line
|
||||
}),
|
||||
```
|
||||
|
||||
### 4. Merge to User Store (Optional)
|
||||
|
||||
**Skip this step if the domain field already exists in `serverSettings`.**
|
||||
|
||||
In `src/store/user/slices/common/action.ts`, add to `serverSettings`:
|
||||
|
||||
```typescript
|
||||
const serverSettings: PartialDeep<UserSettings> = {
|
||||
defaultAgent: serverConfig.defaultAgent,
|
||||
<domain>: serverConfig.<domain>, // Add this line
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Update .env.example
|
||||
|
||||
```bash
|
||||
# <Description> (range/options, default: X)
|
||||
# YOUR_ENV_VAR=<example>
|
||||
```
|
||||
|
||||
### 6. Update Documentation
|
||||
|
||||
Update both English and Chinese documentation:
|
||||
- `docs/self-hosting/environment-variables/basic.mdx`
|
||||
- `docs/self-hosting/environment-variables/basic.zh-CN.mdx`
|
||||
|
||||
Add new section or subsection with environment variable details (type, description, default, example, range/constraints).
|
||||
|
||||
## Type Reuse
|
||||
|
||||
**Prefer reusing existing types** from `packages/types/src/user/settings` instead of defining inline types in `serverConfig.ts`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - reuse existing type
|
||||
import { UserImageConfig } from './user/settings';
|
||||
|
||||
export interface GlobalServerConfig {
|
||||
image?: PartialDeep<UserImageConfig>;
|
||||
}
|
||||
|
||||
// ❌ Bad - inline type definition
|
||||
export interface GlobalServerConfig {
|
||||
image?: {
|
||||
defaultImageNum?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Example: AI_IMAGE_DEFAULT_IMAGE_NUM
|
||||
|
||||
```typescript
|
||||
// src/envs/image.ts
|
||||
export const getImageConfig = () => {
|
||||
return createEnv({
|
||||
server: {
|
||||
AI_IMAGE_DEFAULT_IMAGE_NUM: z.coerce.number().min(1).max(20).optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
AI_IMAGE_DEFAULT_IMAGE_NUM: process.env.AI_IMAGE_DEFAULT_IMAGE_NUM,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// packages/types/src/serverConfig.ts
|
||||
import { UserImageConfig } from './user/settings';
|
||||
|
||||
export interface GlobalServerConfig {
|
||||
image?: PartialDeep<UserImageConfig>;
|
||||
}
|
||||
|
||||
// src/server/globalConfig/index.ts
|
||||
image: cleanObject({
|
||||
defaultImageNum: imageEnv.AI_IMAGE_DEFAULT_IMAGE_NUM,
|
||||
}),
|
||||
|
||||
// src/store/user/slices/common/action.ts
|
||||
const serverSettings: PartialDeep<UserSettings> = {
|
||||
image: serverConfig.image,
|
||||
// ...
|
||||
};
|
||||
|
||||
// .env.example
|
||||
# AI_IMAGE_DEFAULT_IMAGE_NUM=4
|
||||
```
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
description:
|
||||
globs: src/services/**/*,src/database/**/*,src/server/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# LobeChat 后端技术架构指南
|
||||
|
||||
本指南旨在阐述 LobeChat 项目的后端分层架构,重点介绍各核心目录的职责以及它们之间的协作方式。
|
||||
|
||||
## 目录结构映射
|
||||
|
||||
```
|
||||
src/
|
||||
├── server/
|
||||
│ ├── routers/ # tRPC API 路由定义
|
||||
│ └── services/ # 业务逻辑服务层
|
||||
│ └── */impls/ # 平台特定实现
|
||||
├── database/
|
||||
│ ├── models/ # 数据模型 (单表 CRUD)
|
||||
│ ├── repositories/ # 仓库层 (复杂查询/聚合)
|
||||
│ └── schemas/ # Drizzle ORM 表定义
|
||||
└── services/ # 客户端服务 (调用 tRPC 或直接访问 Model)
|
||||
```
|
||||
|
||||
## 核心架构分层
|
||||
|
||||
LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应不同的运行环境(如浏览器端 PGLite、服务端远程 PostgreSQL 以及 Electron 桌面应用)。
|
||||
|
||||
其主要分层如下:
|
||||
|
||||
1. 客户端服务层 (`src/services`):
|
||||
- 位于 src/services/。
|
||||
- 这是客户端业务逻辑的核心层,负责封装各种业务操作和数据处理逻辑。
|
||||
- 环境适配: 根据不同的运行环境,服务层会选择合适的数据访问方式:
|
||||
- 本地数据库模式: 直接调用 `Model` 层进行数据操作,适用于浏览器 PGLite 和本地 Electron 应用。
|
||||
- 远程数据库模式: 通过 `tRPC` 客户端调用服务端 API,适用于需要云同步的场景。
|
||||
- 类型转换: 对于简单的数据类型转换,直接在此层进行类型断言,如 `this.pluginModel.query() as Promise<LobeTool[]>`
|
||||
- 每个服务模块通常包含 `client.ts`(本地模式)、`server.ts`(远程模式)和 `type.ts`(接口定义)文件,在实现时应该确保本地模式和远程模式业务逻辑实现一致,只是数据库不同。
|
||||
|
||||
2. API 接口层 (`TRPC`):
|
||||
- 位于 src/server/routers/
|
||||
- 使用 `tRPC` 构建类型安全的 API。Router 根据运行时环境(如 Edge Functions, Node.js Lambda)进行组织。
|
||||
- 负责接收客户端请求,并将其路由到相应的 `Service` 层进行处理。
|
||||
- 新建 lambda 端点时可以参考 src/server/routers/lambda/\_template.ts
|
||||
|
||||
3. 仓库层 (`Repositories`):
|
||||
- 位于 src/database/repositories/。
|
||||
- 主要处理复杂的跨表查询和数据聚合逻辑,特别是当需要从多个 `Model` 获取数据并进行组合时。
|
||||
- 与 `Model` 层不同,`Repository` 层专注于复杂的业务查询场景,而不涉及简单的领域模型转换。
|
||||
- 当业务逻辑涉及多表关联、复杂的数据统计或需要事务处理时,会使用 `Repository` 层。
|
||||
- 如果数据操作简单(仅涉及单个 `Model`),则通常直接在 `src/services` 层调用 `Model` 并进行简单的类型断言。
|
||||
|
||||
4. 模型层 (`Models`):
|
||||
- 位于 src/database/models/ (例如 src/database/models/plugin.ts 和 src/database/models/document.ts)。
|
||||
- 提供对数据库中各个表(由 src/database/schemas/ 中的 Drizzle ORM schema 定义)的基本 CRUD (创建、读取、更新、删除) 操作和简单的查询能力。
|
||||
- `Model` 类专注于单个数据表的直接操作,不涉及复杂的领域模型转换,这些转换通常在上层的 `src/services` 中通过类型断言完成。
|
||||
- model(例如 Topic) 层接口经常需要从对应的 schema 层导入 NewTopic 和 TopicItem
|
||||
- 创建新的 model 时可以参考 src/database/models/\_template.ts
|
||||
|
||||
5. 数据库 (`Database`):
|
||||
- 客户端模式 (浏览器/PWA): 使用 PGLite (基于 WASM 的 PostgreSQL),数据存储在用户浏览器本地。
|
||||
- 服务端模式 (云部署): 使用远程 PostgreSQL 数据库。
|
||||
- Electron 桌面应用:
|
||||
- Electron 客户端会启动一个本地 Node.js 服务。
|
||||
- 本地服务通过 `tRPC` 与 Electron 的渲染进程通信。
|
||||
- 数据库选择依赖于是否开启云同步功能:
|
||||
- 云同步开启: 连接到远程 PostgreSQL 数据库。
|
||||
- 云同步关闭: 使用 PGLite (通过 Node.js 的 WASM 实现) 在本地存储数据。
|
||||
|
||||
## 数据流向说明
|
||||
|
||||
### 浏览器/PWA 模式
|
||||
|
||||
```
|
||||
UI (React) → Zustand action -> Client Service → Model Layer → PGLite (本地数据库)
|
||||
```
|
||||
|
||||
### 服务端模式
|
||||
|
||||
```
|
||||
UI (React) → Zustand action → Client Service -> TRPC Client → TRPC Routers → Repositories/Models → Remote PostgreSQL
|
||||
```
|
||||
|
||||
### Electron 桌面应用模式
|
||||
|
||||
```
|
||||
UI (Electron Renderer) → Zustand action → Client Service -> TRPC Client → 本地 Node.js 服务 → TRPC Routers → Repositories/Models → PGLite/Remote PostgreSQL (取决于云同步设置)
|
||||
```
|
||||
|
||||
## 服务层 (Server Services)
|
||||
|
||||
- 位于 src/server/services/。
|
||||
- 核心职责是封装独立的、可复用的业务逻辑单元。这些服务应易于测试。
|
||||
- 平台差异抽象: 一个关键特性是通过其内部的 `impls` 子目录(例如 src/server/services/file/impls 包含 s3.ts 和 local.ts)来抹平不同运行环境带来的差异(例如云端使用 S3 存储,桌面版使用本地文件系统)。这使得上层(如 `tRPC` routers)无需关心底层具体实现。
|
||||
- 目标是使 `tRPC` router 层的逻辑尽可能纯粹,专注于请求处理和业务流程编排。
|
||||
- 服务可能会调用 `Repository` 层或直接调用 `Model` 层进行数据持久化和检索,也可能调用其他服务。
|
||||
|
||||
## 最佳实践 (Best Practices)
|
||||
|
||||
### 数据库操作封装原则
|
||||
|
||||
**连续的数据库操作应该封装到 Model 层**
|
||||
|
||||
当业务逻辑涉及多个相关的数据库操作时,建议将这些操作封装到 Model 层中,而不是在上层(Service 或 Router 层)中进行多次数据库调用。
|
||||
|
||||
**优势:**
|
||||
|
||||
- **代码复用**: Client DB 环境的 service 实现和 Server DB 的 lambda 层实现可以复用相同的 Model 方法
|
||||
- **事务一致性**: 相关的数据库操作可以在同一个方法中管理,便于维护数据一致性
|
||||
- **性能优化**: 减少数据库连接次数,提高查询效率
|
||||
- **职责清晰**: Model 层专注数据访问,上层专注业务协调
|
||||
|
||||
**示例:**
|
||||
|
||||
```typescript
|
||||
// ✅ 推荐:在 Model 层封装连续的数据库操作
|
||||
class GenerationBatchModel {
|
||||
async delete(id: string): Promise<{ deletedBatch: BatchItem; thumbnailUrls: string[] }> {
|
||||
// 1. 查询相关数据
|
||||
const batchWithGenerations = await this.db.query.generationBatches.findFirst({...});
|
||||
|
||||
// 2. 收集需要处理的数据
|
||||
const thumbnailUrls = [...];
|
||||
|
||||
// 3. 执行删除操作
|
||||
const [deletedBatch] = await this.db.delete(generationBatches)...;
|
||||
|
||||
return { deletedBatch, thumbnailUrls };
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 上层使用简洁
|
||||
const { thumbnailUrls } = await model.delete(id);
|
||||
await fileService.deleteFiles(thumbnailUrls);
|
||||
```
|
||||
|
||||
### 文件操作与数据库操作的执行顺序
|
||||
|
||||
**删除操作原则:数据库删除在前,文件删除在后**
|
||||
|
||||
当业务逻辑同时涉及数据库记录和文件系统操作时,应该遵循"数据库优先"的原则。
|
||||
|
||||
**原因:**
|
||||
|
||||
- **用户体验优先**: 如果先删除文件再删除数据库记录,可能出现文件已删除但数据库记录仍存在的情况,用户访问时会遇到文件不存在的错误
|
||||
- **影响程度较小**: 如果先删除数据库记录再删除文件,即使文件删除失败,用户也看不到这个记录,只是造成一些存储空间浪费,对用户体验影响更小
|
||||
- **数据一致性**: 数据库记录是业务逻辑的核心,应该优先保证其一致性
|
||||
|
||||
**示例:**
|
||||
|
||||
```typescript
|
||||
// ✅ 推荐:先删除数据库记录,再删除文件
|
||||
async deleteGeneration(id: string) {
|
||||
// 1. 先删除数据库记录
|
||||
const deletedGeneration = await generationModel.delete(id);
|
||||
|
||||
// 2. 再删除相关文件
|
||||
if (deletedGeneration.asset?.thumbnailUrl) {
|
||||
await fileService.deleteFile(deletedGeneration.asset.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 不推荐:先删除文件
|
||||
async deleteGeneration(id: string) {
|
||||
const generation = await generationModel.findById(id);
|
||||
|
||||
// 如果这里删除成功,但后面数据库删除失败,用户会遇到访问错误
|
||||
await fileService.deleteFile(generation.asset.thumbnailUrl);
|
||||
await generationModel.delete(id); // 可能失败
|
||||
}
|
||||
```
|
||||
|
||||
**创建操作原则:数据库创建在前,文件操作在后**
|
||||
|
||||
创建操作同样应该优先处理数据库记录,确保数据的一致性和完整性。
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: How to code review
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Role Description
|
||||
|
||||
- You are a senior full-stack engineer skilled in performance optimization, security, and design systems.
|
||||
- You excel at reviewing code and providing constructive feedback.
|
||||
- Your task is to review submitted Git diffs **in Chinese** and return a structured review report.
|
||||
- Review style: concise, direct, focused on what matters most, with actionable suggestions.
|
||||
|
||||
## Before the Review
|
||||
|
||||
Gather the modified code and context. Please strictly follow the process below:
|
||||
|
||||
1. Use `read_file` to read [package.json](mdc:package.json)
|
||||
2. Use terminal to run command `git diff HEAD | cat` to obtain the diff and list the changed files. If you recieived empty result, run the same command once more.
|
||||
3. Use `read_file` to open each changed file.
|
||||
4. Use `read_file` to read [rules-attach.mdc](mdc:.cursor/rules/rules-attach.mdc). Even if you think it's unnecessary, you must read it.
|
||||
5. combine changed files, step3 and `agent_requestable_workspace_rules`, list the rules which need to read
|
||||
6. Use `read_file` to read the rules list in step 5
|
||||
|
||||
## Review
|
||||
|
||||
### Code Style
|
||||
|
||||
read [typescript.mdc](mdc:.cursor/rules/typescript.mdc) for the consolidated project code style and optimization rules.
|
||||
|
||||
### Code Optimization
|
||||
|
||||
The optimization checklist has been consolidated into [typescript.mdc](mdc:.cursor/rules/typescript.mdc): loops, debouncing/throttling, design system components, theming tokens, concurrency with `Promise.*`, minimal DB column selection, and package reuse.
|
||||
|
||||
### Obvious Bugs
|
||||
|
||||
- Do not silently swallow errors in `catch` blocks; at minimum, log them.
|
||||
- Revert temporary code used only for testing (e.g., debug logs, temporary configs).
|
||||
- Remove empty handlers (e.g., an empty `onClick`).
|
||||
- Confirm the UI degrades gracefully for unauthenticated users.
|
||||
- Don't leave any debug logs in the code (except when using the `debug` module properly).
|
||||
- When using the `debug` module, avoid `import { log } from 'debug'` as it logs directly to console. Use proper debug namespaces instead.
|
||||
- Check logs for sensitive information like api key, etc
|
||||
|
||||
## After the Review: output
|
||||
|
||||
1. Summary
|
||||
- Start with a brief explanation of what the change set does.
|
||||
- Summarize the changes for each modified file (or logical group).
|
||||
2. Comments Issues
|
||||
- List the most critical issues first.
|
||||
- Use an ordered list, which will be convenient for me to reference later.
|
||||
- For each issue:
|
||||
- Mark severity tag (`❌ Must fix`, `⚠️ Should fix`, `💅 Nitpick`)
|
||||
- Provode file path to the relevant file.
|
||||
- Provide recommended fix
|
||||
- End with a **git commit** command, instruct the author to run it.
|
||||
- We use gitmoji to label commit messages, format: [emoji] <type>(<scope>): <subject>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Guide to Optimize Output(Response) Rendering
|
||||
|
||||
## File Path and Code Symbol Rendering
|
||||
|
||||
- When rendering file paths, use backtick wrapping instead of markdown links so they can be parsed as clickable links in Cursor IDE.
|
||||
- Good: `src/components/Button.tsx`
|
||||
- Bad: [src/components/Button.tsx](src/components/Button.tsx)
|
||||
|
||||
- Don't use line and column number in file path, this will make file path not clickable in Cursor IDE.
|
||||
- Good: `src/components/Button.tsx` `10:20` (add a space between the file path and the line and column number)
|
||||
- Bad: `src/components/Button.tsx:10:20`
|
||||
|
||||
- When rendering functions, variables, or other code symbols, use backtick wrapping so they can be parsed as navigable links in Cursor IDE
|
||||
- Good: The `useState` hook in `MyComponent`
|
||||
- Bad: The useState hook in MyComponent
|
||||
|
||||
## Markdown Render
|
||||
|
||||
- don't use br tag to wrap in table cell
|
||||
|
||||
## Terminal Command Output
|
||||
|
||||
- If terminal commands don't produce output, it's likely due to paging issues. Try piping the command to `cat` to ensure full output is displayed.
|
||||
- Good: `git show commit_hash -- file.txt | cat`
|
||||
- Good: `git log --oneline | cat`
|
||||
- Reason: Some git commands use pagers by default, which may prevent output from being captured properly
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
globs: packages/database/migrations/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Database Migrations Guide
|
||||
|
||||
## Defensive Programming - Use Idempotent Clauses
|
||||
|
||||
Always use defensive clauses to make migrations idempotent:
|
||||
|
||||
```sql
|
||||
-- ✅ Good: Idempotent operations
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "avatar" text;
|
||||
DROP TABLE IF EXISTS "old_table";
|
||||
CREATE INDEX IF NOT EXISTS "users_email_idx" ON "users" ("email");
|
||||
ALTER TABLE "posts" DROP COLUMN IF EXISTS "deprecated_field";
|
||||
|
||||
-- ❌ Bad: Non-idempotent operations
|
||||
ALTER TABLE "users" ADD COLUMN "avatar" text;
|
||||
DROP TABLE "old_table";
|
||||
CREATE INDEX "users_email_idx" ON "users" ("email");
|
||||
```
|
||||
|
||||
**Important**: After modifying migration SQL (e.g., adding `IF NOT EXISTS` clauses), run `bun run db:generate-client` to update the hash in `packages/database/src/core/migrations.json`.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs: src/database/models/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
1. first read [lobe-chat-backend-architecture.mdc](mdc:.cursor/rules/lobe-chat-backend-architecture.mdc)
|
||||
2. refer to the [_template.ts](mdc:src/database/models/_template.ts) to create new model
|
||||
3. if an operation involves multiple models or complex queries, consider defining it in the `repositories` layer under `src/database/repositories/`
|
||||
@@ -4,33 +4,41 @@ alwaysApply: true
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI chat framework: lobehub(previous lobe-chat).
|
||||
You are developing an open-source, modern-design AI chat framework: lobe chat.
|
||||
|
||||
Supported platforms:
|
||||
|
||||
- web desktop/mobile
|
||||
- desktop(electron)
|
||||
- mobile app(react native), coming soon
|
||||
|
||||
logo emoji: 🤯
|
||||
Emoji logo: 🤯
|
||||
|
||||
## Project Technologies Stack
|
||||
|
||||
- Next.js 15
|
||||
- react 19
|
||||
- TypeScript
|
||||
- `@lobehub/ui`, antd for component framework
|
||||
read [package.json](mdc:package.json) to know all npm packages you can use.
|
||||
|
||||
The project uses the following technologies:
|
||||
|
||||
- pnpm as package manager
|
||||
- Next.js 15 for frontend and backend, using app router instead of pages router
|
||||
- react 19, using hooks, functional components, react server components
|
||||
- TypeScript programming language
|
||||
- antd, `@lobehub/ui` for component framework
|
||||
- antd-style for css-in-js framework
|
||||
- lucide-react, `@ant-design/icons` for icons
|
||||
- react-layout-kit for flex layout component
|
||||
- react-layout-kit for flex layout
|
||||
- react-i18next for i18n
|
||||
- zustand for state management
|
||||
- nuqs for search params management
|
||||
- SWR for data fetch
|
||||
- lucide-react, `@ant-design/icons` for icons
|
||||
- `@lobehub/icons` for AI provider/model logo icon
|
||||
- `@formkit/auto-animate` for react list animation
|
||||
- zustand for global state management
|
||||
- nuqs for type-safe search params state manager
|
||||
- SWR for react data fetch
|
||||
- aHooks for react hooks library
|
||||
- dayjs for time library
|
||||
- dayjs for date and time library
|
||||
- lodash-es for utility library
|
||||
- fast-deep-equal for deep comparison of JavaScript objects
|
||||
- zod for data validation
|
||||
- TRPC for type safe backend
|
||||
- PGLite for client DB and Neon PostgreSQL for backend DB
|
||||
- PGLite for client DB and PostgreSQL for backend DB
|
||||
- Drizzle ORM
|
||||
- Vitest for testing
|
||||
- Vitest for testing, testing-library for react component test
|
||||
- Prettier for code formatting
|
||||
- ESLint for code linting
|
||||
- Cursor AI for code editing and AI coding assistance
|
||||
|
||||
Note: All tools and libraries used are the latest versions. The application only needs to be compatible with the latest browsers;
|
||||
|
||||
+221
-104
@@ -5,118 +5,235 @@ alwaysApply: false
|
||||
|
||||
# LobeChat Project Structure
|
||||
|
||||
## Complete Project Structure
|
||||
## Directory Structure
|
||||
|
||||
This project uses common monorepo structure. The workspace packages name use `@lobechat/` namespace.
|
||||
|
||||
**note**: some not very important files are not shown for simplicity.
|
||||
note: some files are not shown for simplicity.
|
||||
|
||||
```plaintext
|
||||
lobe-chat/
|
||||
├── apps/
|
||||
│ └── desktop/
|
||||
├── docs/
|
||||
├── locales/
|
||||
│ ├── en-US/
|
||||
│ └── zh-CN/
|
||||
├── packages/
|
||||
│ ├── const/
|
||||
│ ├── context-engine/
|
||||
│ ├── database/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ └── repositories/
|
||||
│ ├── model-bank/
|
||||
│ │ └── src/
|
||||
│ │ └── aiModels/
|
||||
│ ├── model-runtime/
|
||||
│ │ └── src/
|
||||
│ │ ├── core/
|
||||
│ │ └── providers/
|
||||
│ ├── types/
|
||||
│ │ └── src/
|
||||
│ │ ├── message/
|
||||
│ │ └── user/
|
||||
│ └── utils/
|
||||
├── public/
|
||||
├── scripts/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── (backend)/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ └── webhooks/
|
||||
│ │ │ ├── middleware/
|
||||
│ │ │ ├── oidc/
|
||||
│ │ │ ├── trpc/
|
||||
│ │ │ └── webapi/
|
||||
│ │ │ ├── chat/
|
||||
│ │ │ └── tts/
|
||||
│ │ ├── [variants]/
|
||||
│ │ │ ├── (main)/
|
||||
│ │ │ │ ├── chat/
|
||||
│ │ │ │ └── settings/
|
||||
│ │ │ └── @modal/
|
||||
│ │ └── manifest.ts
|
||||
│ ├── components/
|
||||
│ ├── config/
|
||||
│ ├── features/
|
||||
│ │ └── ChatInput/
|
||||
│ ├── hooks/
|
||||
│ ├── layout/
|
||||
│ │ ├── AuthProvider/
|
||||
│ │ └── GlobalProvider/
|
||||
│ ├── libs/
|
||||
│ │ └── oidc-provider/
|
||||
│ ├── locales/
|
||||
│ │ └── default/
|
||||
│ ├── server/
|
||||
│ │ ├── modules/
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── async/
|
||||
│ │ │ ├── desktop/
|
||||
│ │ │ ├── edge/
|
||||
│ │ │ └── lambda/
|
||||
│ │ └── services/
|
||||
│ ├── services/
|
||||
│ │ ├── user/
|
||||
│ │ │ ├── client.ts
|
||||
│ │ │ └── server.ts
|
||||
│ │ └── message/
|
||||
│ ├── store/
|
||||
│ │ ├── agent/
|
||||
│ │ ├── chat/
|
||||
│ │ └── user/
|
||||
│ ├── styles/
|
||||
│ └── utils/
|
||||
└── package.json
|
||||
├── apps/ # Applications directory
|
||||
│ └── desktop/ # Electron desktop application
|
||||
│ ├── src/ # Desktop app source code
|
||||
│ └── resources/ # Desktop app resources
|
||||
├── docs/ # Project documentation
|
||||
│ ├── development/ # Development docs
|
||||
│ ├── self-hosting/ # Self-hosting docs
|
||||
│ └── usage/ # Usage guides
|
||||
├── locales/ # Internationalization files (multiple locales)
|
||||
│ ├── en-US/ # English (example)
|
||||
│ └── zh-CN/ # Simplified Chinese (example)
|
||||
├── packages/ # Monorepo packages directory
|
||||
│ ├── const/ # Constants definition package
|
||||
│ ├── database/ # Database related package
|
||||
│ ├── electron-client-ipc/ # Electron renderer ↔ main IPC client
|
||||
│ ├── electron-server-ipc/ # Electron main process IPC server
|
||||
│ ├── model-bank/ # Built-in model presets/catalog exports
|
||||
│ ├── model-runtime/ # AI model runtime package
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── utils/ # Utility functions package
|
||||
│ ├── file-loaders/ # File processing packages
|
||||
│ ├── prompts/ # AI prompt management
|
||||
│ └── web-crawler/ # Web crawling functionality
|
||||
├── public/ # Static assets
|
||||
│ ├── icons/ # Application icons
|
||||
│ ├── images/ # Image resources
|
||||
│ └── screenshots/ # Application screenshots
|
||||
├── scripts/ # Build and tool scripts
|
||||
├── src/ # Main application source code (see below)
|
||||
├── .cursor/ # Cursor AI configuration
|
||||
├── docker-compose/ # Docker configuration
|
||||
├── package.json # Project dependencies
|
||||
├── pnpm-workspace.yaml # pnpm monorepo configuration
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── drizzle.config.ts # Drizzle ORM configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## Core Source Directory (`src/`)
|
||||
|
||||
```plaintext
|
||||
src/
|
||||
├── app/ # Next.js App Router routes
|
||||
│ ├── (backend)/ # Backend API routes
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ │ ├── auth/ # Authentication endpoints
|
||||
│ │ │ └── webhooks/ # Webhook handlers for various auth providers
|
||||
│ │ ├── middleware/ # Request middleware
|
||||
│ │ ├── oidc/ # OpenID Connect endpoints
|
||||
│ │ ├── trpc/ # tRPC API routes
|
||||
│ │ │ ├── async/ # Async tRPC endpoints
|
||||
│ │ │ ├── desktop/ # Desktop runtime endpoints
|
||||
│ │ │ ├── edge/ # Edge runtime endpoints
|
||||
│ │ │ ├── lambda/ # Lambda runtime endpoints
|
||||
│ │ │ └── tools/ # Tools-specific endpoints
|
||||
│ │ └── webapi/ # Web API endpoints
|
||||
│ │ ├── chat/ # Chat-related APIs for various providers
|
||||
│ │ ├── models/ # Model management APIs
|
||||
│ │ ├── plugin/ # Plugin system APIs
|
||||
│ │ ├── stt/ # Speech-to-text APIs
|
||||
│ │ ├── text-to-image/ # Image generation APIs
|
||||
│ │ └── tts/ # Text-to-speech APIs
|
||||
│ ├── [variants]/ # Page route variants
|
||||
│ │ ├── (main)/ # Main application routes
|
||||
│ │ │ ├── chat/ # Chat interface and workspace
|
||||
│ │ │ ├── discover/ # Discover page (assistants, models, providers)
|
||||
│ │ │ ├── files/ # File management interface
|
||||
│ │ │ ├── image/ # Image generation interface
|
||||
│ │ │ ├── profile/ # User profile and stats
|
||||
│ │ │ ├── repos/ # Knowledge base repositories
|
||||
│ │ │ └── settings/ # Application settings
|
||||
│ │ └── @modal/ # Modal routes
|
||||
│ └── manifest.ts # PWA configuration
|
||||
├── components/ # Global shared components
|
||||
│ ├── Analytics/ # Analytics tracking components
|
||||
│ ├── Error/ # Error handling components
|
||||
│ └── Loading/ # Loading state components
|
||||
├── config/ # Application configuration
|
||||
│ ├── featureFlags/ # Feature flags & experiments
|
||||
│ └── modelProviders/ # Model provider configurations
|
||||
├── features/ # Feature components (UI Layer)
|
||||
│ ├── AgentSetting/ # Agent configuration and management
|
||||
│ ├── ChatInput/ # Chat input with file upload and tools
|
||||
│ ├── Conversation/ # Message display and interaction
|
||||
│ ├── FileManager/ # File upload and knowledge base
|
||||
│ └── PluginStore/ # Plugin marketplace and management
|
||||
├── hooks/ # Custom React hooks
|
||||
├── layout/ # Global layout components
|
||||
│ ├── AuthProvider/ # Authentication provider
|
||||
│ └── GlobalProvider/ # Global state provider
|
||||
├── libs/ # External library integrations
|
||||
│ ├── analytics/ # Analytics services integration
|
||||
│ ├── next-auth/ # NextAuth.js configuration
|
||||
│ └── oidc-provider/ # OIDC provider implementation
|
||||
├── locales/ # Internationalization resources
|
||||
│ └── default/ # Default language definitions
|
||||
├── migrations/ # Client-side data migrations
|
||||
├── server/ # Server-side code
|
||||
│ ├── modules/ # Server modules
|
||||
│ ├── routers/ # tRPC routers
|
||||
│ └── services/ # Server services
|
||||
├── services/ # Service layer (per-domain, client/server split)
|
||||
│ ├── user/ # User services
|
||||
│ │ ├── client.ts # Client DB (PGLite) implementation
|
||||
│ │ └── server.ts # Server DB implementation (via tRPC)
|
||||
│ ├── aiModel/ # AI model services
|
||||
│ ├── session/ # Session services
|
||||
│ └── message/ # Message services
|
||||
├── store/ # Zustand state management
|
||||
│ ├── agent/ # Agent state
|
||||
│ ├── chat/ # Chat state
|
||||
│ └── user/ # User state
|
||||
├── styles/ # Global styles
|
||||
├── tools/ # Built-in tool system
|
||||
│ ├── artifacts/ # Code artifacts and preview
|
||||
│ └── web-browsing/ # Web search and browsing
|
||||
├── types/ # TypeScript type definitions
|
||||
└── utils/ # Utility functions
|
||||
├── client/ # Client-side utilities
|
||||
└── server/ # Server-side utilities
|
||||
```
|
||||
|
||||
## Key Monorepo Packages
|
||||
|
||||
```plaintext
|
||||
packages/
|
||||
├── const/ # Global constants and configurations
|
||||
├── database/ # Database schemas and models
|
||||
│ ├── src/models/ # Data models (CRUD operations)
|
||||
│ ├── src/schemas/ # Drizzle database schemas
|
||||
│ ├── src/repositories/ # Complex query layer
|
||||
│ └── migrations/ # Database migration files
|
||||
├── model-runtime/ # AI model runtime
|
||||
│ └── src/
|
||||
│ ├── openai/ # OpenAI provider integration
|
||||
│ ├── anthropic/ # Anthropic provider integration
|
||||
│ ├── google/ # Google AI provider integration
|
||||
│ ├── ollama/ # Ollama local model integration
|
||||
│ ├── types/ # Runtime type definitions
|
||||
│ └── utils/ # Runtime utilities
|
||||
├── types/ # Shared TypeScript type definitions
|
||||
│ └── src/
|
||||
│ ├── agent/ # Agent-related types
|
||||
│ ├── message/ # Message and chat types
|
||||
│ ├── user/ # User and session types
|
||||
│ └── tool/ # Tool and plugin types
|
||||
├── utils/ # Shared utility functions
|
||||
│ └── src/
|
||||
│ ├── client/ # Client-side utilities
|
||||
│ ├── server/ # Server-side utilities
|
||||
│ ├── fetch/ # HTTP request utilities
|
||||
│ └── tokenizer/ # Token counting utilities
|
||||
├── file-loaders/ # File loaders (PDF, DOCX, etc.)
|
||||
├── prompts/ # AI prompt management
|
||||
└── web-crawler/ # Web crawling functionality
|
||||
```
|
||||
|
||||
## Architecture Map
|
||||
|
||||
- UI Components: `src/components`, `src/features`
|
||||
- Global providers: `src/layout`
|
||||
- Zustand stores: `src/store`
|
||||
- Client Services: `src/services/` cross-platform services
|
||||
- clientDB: `src/services/<domain>/client.ts`
|
||||
- serverDB: `src/services/<domain>/server.ts`
|
||||
- API Routers:
|
||||
- `src/app/(backend)/webapi` (REST)
|
||||
- `src/server/routers/{edge|lambda|async|desktop|tools}` (tRPC)
|
||||
- Server:
|
||||
- Services(can access serverDB): `src/server/services` server-used-only services
|
||||
- Modules(can't access db): `src/server/modules` (Server only Third-party Service Module)
|
||||
- Database:
|
||||
- Schema (Drizzle): `packages/database/src/schemas`
|
||||
- Model (CRUD): `packages/database/src/models`
|
||||
- Repository (bff-queries): `packages/database/src/repositories`
|
||||
- Third-party Integrations: `src/libs` — analytics, oidc etc.
|
||||
- Presentation: `src/features`, `src/components`, `src/layout` — UI composition, global providers
|
||||
- State: `src/store` — Zustand slices, selectors, middleware
|
||||
- Client Services: `src/services/<domain>/{client|server}.ts` — client: PGLite; server: tRPC bridge
|
||||
- API Routers: `src/app/(backend)/webapi` (REST), `src/app/(backend)/trpc/{edge|lambda|async|desktop|tools}`; Lambda router triggers Async router for long-running tasks (e.g., image)
|
||||
- Server Services: `src/server/services` (business logic), `src/server/modules` (infra adapters)
|
||||
- Data Access: `packages/database/src/{schemas,models,repositories}` — Schema (Drizzle), Model (CRUD), Repository (complex queries)
|
||||
- Integrations: `src/libs` — analytics, auth, trpc, logging, runtime helpers
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
- **Web with ClientDB**: React UI → Client Service → Direct Model Access → PGLite (Web WASM)
|
||||
- **Web with ServerDB**: React UI → Client Service → tRPC Lambda → Server Services → PostgreSQL (Remote)
|
||||
- **Desktop**:
|
||||
- Cloud sync disabled: Electron UI → Client Service → tRPC Lambda → Local Server Services → PGLite (Node WASM)
|
||||
- Cloud sync enabled: Electron UI → Client Service → tRPC Lambda → Cloud Server Services → PostgreSQL (Remote)
|
||||
### Unified Flow Pattern
|
||||
|
||||
```
|
||||
UI Layer → State Management → Client Service → [Environment Branch] → Database
|
||||
↓ ↓ ↓ ↓ ↓
|
||||
React Zustand Environment Local/Remote PGLite/
|
||||
Components Store Adaptation Routing PostgreSQL
|
||||
```
|
||||
|
||||
### Environment-Specific Routing
|
||||
|
||||
| Mode | UI | Service Route | Database |
|
||||
| --------------- | -------- | ---------------------- | ------------------- |
|
||||
| **Browser/PWA** | React | Direct Model Access | PGLite (Local) |
|
||||
| **Server** | React | tRPC → Server Services | PostgreSQL (Remote) |
|
||||
| **Desktop** | Electron | tRPC → Local Node.js | PGLite/PostgreSQL\* |
|
||||
|
||||
_\*Depends on cloud sync configuration_
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
- **Type Safety**: End-to-end type safety via tRPC and Drizzle ORM
|
||||
- **Local/Remote Dual Mode**: PGLite enables user data ownership and local control
|
||||
|
||||
## Quick Map
|
||||
|
||||
- App Routes: `src/app` — UI routes (App Router) and backend routes under `(backend)`
|
||||
- Web API: `src/app/(backend)/webapi` — REST-like endpoints
|
||||
- tRPC Routers: `src/server/routers` — typed RPC endpoints by runtime
|
||||
- Client Services: `src/services` — environment-adaptive client-side business logic
|
||||
- Server Services: `src/server/services` — platform-agnostic business logic
|
||||
- Database: `packages/database` — schemas/models/repositories/migrations
|
||||
- State: `src/store` — Zustand stores and slices
|
||||
- Integrations: `src/libs` — analytics/auth/trpc/logging/runtime helpers
|
||||
- Tools: `src/tools` — built-in tool system
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- Add Web API route: `src/app/(backend)/webapi/<module>/route.ts`
|
||||
- Add tRPC endpoint: `src/server/routers/{edge|lambda|desktop}/...`
|
||||
- Add client/server service: `src/services/<domain>/{client|server}.ts` (client: PGLite; server: tRPC)
|
||||
- Add server service: `src/server/services/<domain>`
|
||||
- Add a new model/provider: `src/config/modelProviders/<provider>.ts` + `packages/model-bank/src/aiModels/<provider>.ts` + `packages/model-runtime/src/<provider>/index.ts`
|
||||
- Add DB schema/model/repository: `packages/database/src/{schemas|models|repositories}`
|
||||
- Add Zustand slice: `src/store/<domain>/slices`
|
||||
|
||||
## Env Modes
|
||||
|
||||
- `NEXT_PUBLIC_CLIENT_DB`: selects client DB mode (e.g., `pglite`) vs server-backed
|
||||
- `NEXT_PUBLIC_IS_DESKTOP_APP`: enables desktop-specific routes and behavior
|
||||
- `NEXT_PUBLIC_SERVICE_MODE`: controls service routing preference (client/server)
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Keep client logic in `src/services`; server-only logic stays in `src/server/services`
|
||||
- Don’t mix Web API (`webapi/`) with tRPC (`src/server/routers/`)
|
||||
- Place business UI under `src/features`, global reusable UI under `src/components`
|
||||
|
||||
@@ -4,12 +4,20 @@ globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Available project rules index
|
||||
# 📋 Available Rules Index
|
||||
|
||||
All following rules are saved under `.cursor/rules/` directory:
|
||||
The following rules are available via `read_file` from the `.cursor/rules/` directory:
|
||||
|
||||
## General
|
||||
|
||||
- `project-introduce.mdc` – Project description and tech stack
|
||||
- `cursor-rules.mdc` – Cursor rules authoring and optimization guide
|
||||
- `code-review.mdc` – How to code review
|
||||
|
||||
## Backend
|
||||
|
||||
- `backend-architecture.mdc` – Backend layer architecture and design guidelines
|
||||
- `define-database-model.mdc` – Database model definition guidelines
|
||||
- `drizzle-schema-style-guide.mdc` – Style guide for defining Drizzle ORM schemas
|
||||
|
||||
## Frontend
|
||||
@@ -34,6 +42,7 @@ All following rules are saved under `.cursor/rules/` directory:
|
||||
|
||||
## Debugging
|
||||
|
||||
- `debug.mdc` – General debugging guide
|
||||
- `debug-usage.mdc` – Using the debug package and namespace conventions
|
||||
|
||||
## Testing
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## System Role
|
||||
|
||||
You are an expert in full-stack Web development, proficient in JavaScript, TypeScript, CSS, React, Node.js, Next.js, Postgresql, Redis, S3, all kinds of network protocols.
|
||||
|
||||
You are an LLM expert, you are familiar with all kinds of LLM models, ai agents, ai workflow, prompt engineering and context engineering.
|
||||
|
||||
You are an expert in Ai art. In Ai image generation, you are proficient in Stable Diffusion and ComfyUI's architectural principles, workflows, model structures, parameter configurations, training methods, and inference optimization.
|
||||
|
||||
You are an expert in UI/UX design, proficient in web interaction patterns, responsive design, accessibility, and user behavior optimization. You excel at improving user retention and paid conversion rates through various interaction details.
|
||||
|
||||
## Problem Solving
|
||||
|
||||
- When modifying existing code, clearly describe the differences and reasons for the changes
|
||||
- Provide alternative solutions that may be better overall or superior in specific aspects
|
||||
- Provide optimization suggestions for deprecated API usage
|
||||
- Cite sources whenever possible at the end, not inline
|
||||
- When you provide multiple solutions, provide the recommended solution first, and note it as `Recommended`
|
||||
- Express uncertainty when there might not be a correct answer, instead of take action by guessing and assuming
|
||||
|
||||
## Code Implementation
|
||||
|
||||
- Focus on maintainable over being performant
|
||||
- Be sure to reference file path
|
||||
- If doc links or required files are missing, ask for them before proceeding with the task rather than making assumptions
|
||||
- If you're unable to get valid result when using tools, please clearly state in the output
|
||||
@@ -1,579 +0,0 @@
|
||||
---
|
||||
description: Best practices for testing Zustand store actions
|
||||
globs: "src/store/**/*.test.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Zustand Store Action Testing Guide
|
||||
|
||||
This guide provides best practices for testing Zustand store actions, based on our proven testing patterns.
|
||||
|
||||
## Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { messageService } from '@/services/message';
|
||||
import { useChatStore } from '../../store';
|
||||
|
||||
// Keep zustand mock as it's needed globally
|
||||
vi.mock('zustand/traditional');
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store state
|
||||
vi.clearAllMocks();
|
||||
useChatStore.setState(
|
||||
{
|
||||
activeId: 'test-session-id',
|
||||
messagesMap: {},
|
||||
loadingIds: [],
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// ✅ Setup only spies that MOST tests need
|
||||
vi.spyOn(messageService, 'createMessage').mockResolvedValue('new-message-id');
|
||||
// ❌ Don't setup spies that only few tests need - spy only when needed
|
||||
|
||||
// Setup common mock methods
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
refreshMessages: vi.fn(),
|
||||
internal_coreProcessMessage: vi.fn(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('action name', () => {
|
||||
describe('validation', () => {
|
||||
// Validation tests
|
||||
});
|
||||
|
||||
describe('normal flow', () => {
|
||||
// Happy path tests
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
// Error case tests
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### 1. Test Layering - Spy Direct Dependencies Only
|
||||
|
||||
✅ **Good**: Spy on the direct dependency
|
||||
|
||||
```typescript
|
||||
// When testing internal_coreProcessMessage, spy its direct dependency
|
||||
const fetchAIChatSpy = vi
|
||||
.spyOn(result.current, 'internal_fetchAIChatMessage')
|
||||
.mockResolvedValue({ isFunctionCall: false, content: 'AI response' });
|
||||
```
|
||||
|
||||
❌ **Bad**: Spy on lower-level implementation details
|
||||
|
||||
```typescript
|
||||
// Don't spy on services that internal_fetchAIChatMessage uses
|
||||
const streamSpy = vi
|
||||
.spyOn(chatService, 'createAssistantMessageStream')
|
||||
.mockImplementation(...);
|
||||
```
|
||||
|
||||
**Why**: Each test should only mock its direct dependencies, not the entire call chain. This makes tests more maintainable and less brittle.
|
||||
|
||||
### 2. Mock Management - Minimize Global Spies
|
||||
|
||||
✅ **Good**: Spy only when needed
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
// ✅ Only spy services that most tests need
|
||||
vi.spyOn(messageService, 'createMessage').mockResolvedValue('new-message-id');
|
||||
// ✅ Don't spy chatService globally
|
||||
});
|
||||
|
||||
it('should process message', async () => {
|
||||
// ✅ Spy chatService only in tests that need it
|
||||
const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream')
|
||||
.mockImplementation(...);
|
||||
|
||||
// test logic
|
||||
|
||||
streamSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
❌ **Bad**: Setup all spies globally
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.spyOn(messageService, 'createMessage').mockResolvedValue('id');
|
||||
vi.spyOn(chatService, 'createAssistantMessageStream').mockResolvedValue({}); // ❌ Not all tests need this
|
||||
vi.spyOn(fileService, 'uploadFile').mockResolvedValue({}); // ❌ Creates implicit coupling
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Service Mocking - Mock the Correct Layer
|
||||
|
||||
✅ **Good**: Mock the service method
|
||||
|
||||
```typescript
|
||||
it('should fetch AI chat response', async () => {
|
||||
const streamSpy = vi
|
||||
.spyOn(chatService, 'createAssistantMessageStream')
|
||||
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
||||
await onMessageHandle?.({ type: 'text', text: 'Hello' } as any);
|
||||
await onFinish?.('Hello', {});
|
||||
});
|
||||
|
||||
// test logic
|
||||
});
|
||||
```
|
||||
|
||||
❌ **Bad**: Mock global fetch
|
||||
|
||||
```typescript
|
||||
it('should fetch AI chat response', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(...); // ❌ Too low level
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Test Organization - Use Descriptive Nesting
|
||||
|
||||
✅ **Good**: Clear nested structure
|
||||
|
||||
```typescript
|
||||
describe('sendMessage', () => {
|
||||
describe('validation', () => {
|
||||
it('should not send when session is inactive', async () => {});
|
||||
it('should not send when message is empty', async () => {});
|
||||
});
|
||||
|
||||
describe('message creation', () => {
|
||||
it('should create user message and trigger AI processing', async () => {});
|
||||
it('should send message with files attached', async () => {});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle message creation errors gracefully', async () => {});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
❌ **Bad**: Flat structure
|
||||
|
||||
```typescript
|
||||
describe('sendMessage', () => {
|
||||
it('test 1', async () => {});
|
||||
it('test 2', async () => {});
|
||||
it('test 3', async () => {});
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Testing Async Actions
|
||||
|
||||
Always wrap async operations in `act()`:
|
||||
|
||||
```typescript
|
||||
it('should send message', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage({ message: 'Hello' });
|
||||
});
|
||||
|
||||
expect(messageService.createMessage).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
### 6. State Setup - Use act() for setState
|
||||
|
||||
```typescript
|
||||
it('should handle disabled state', async () => {
|
||||
act(() => {
|
||||
useChatStore.setState({ activeId: undefined });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
// test logic
|
||||
});
|
||||
```
|
||||
|
||||
### 7. Testing Complex Flows
|
||||
|
||||
For complex flows with multiple steps, use clear spy setup:
|
||||
|
||||
```typescript
|
||||
it('should handle topic creation flow', async () => {
|
||||
// Setup store state
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeTopicId: undefined,
|
||||
messagesMap: {
|
||||
'test-session-id': [
|
||||
{ id: 'msg-1', role: 'user', content: 'Message 1' },
|
||||
{ id: 'msg-2', role: 'assistant', content: 'Response 1' },
|
||||
{ id: 'msg-3', role: 'user', content: 'Message 2' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
// Spy on action dependencies
|
||||
const createTopicSpy = vi.spyOn(result.current, 'createTopic')
|
||||
.mockResolvedValue('new-topic-id');
|
||||
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleMessageLoading');
|
||||
|
||||
// Execute
|
||||
await act(async () => {
|
||||
await result.current.sendMessage({ message: 'Test message' });
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(createTopicSpy).toHaveBeenCalled();
|
||||
expect(toggleLoadingSpy).toHaveBeenCalledWith(true, expect.any(String));
|
||||
});
|
||||
```
|
||||
|
||||
### 8. Streaming Response Mocking
|
||||
|
||||
When testing streaming responses, simulate the flow properly:
|
||||
|
||||
```typescript
|
||||
it('should handle streaming chunks', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const messages = [
|
||||
{ id: 'msg-1', role: 'user', content: 'Hello', sessionId: 'test-session' },
|
||||
];
|
||||
|
||||
const streamSpy = vi
|
||||
.spyOn(chatService, 'createAssistantMessageStream')
|
||||
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
||||
// Simulate streaming chunks
|
||||
await onMessageHandle?.({ type: 'text', text: 'Hello' } as any);
|
||||
await onMessageHandle?.({ type: 'text', text: ' World' } as any);
|
||||
await onFinish?.('Hello World', {});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.internal_fetchAIChatMessage({
|
||||
messages,
|
||||
messageId: 'test-message-id',
|
||||
model: 'gpt-4o-mini',
|
||||
provider: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.internal_dispatchMessage).toHaveBeenCalled();
|
||||
|
||||
streamSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
### 9. Error Handling Tests
|
||||
|
||||
Always test error scenarios:
|
||||
|
||||
```typescript
|
||||
it('should handle errors gracefully', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
vi.spyOn(messageService, 'createMessage').mockRejectedValue(
|
||||
new Error('create message error'),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.sendMessage({ message: 'Test message' });
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
### 10. Cleanup After Tests
|
||||
|
||||
Always restore mocks after each test:
|
||||
|
||||
```typescript
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// For individual test cleanup:
|
||||
it('should test something', async () => {
|
||||
const spy = vi.spyOn(service, 'method').mockImplementation(...);
|
||||
|
||||
// test logic
|
||||
|
||||
spy.mockRestore(); // Optional: cleanup immediately after test
|
||||
});
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Testing Store Methods That Call Other Store Methods
|
||||
|
||||
```typescript
|
||||
it('should call internal methods', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
const internalMethodSpy = vi.spyOn(result.current, 'internal_method')
|
||||
.mockResolvedValue();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.publicMethod();
|
||||
});
|
||||
|
||||
expect(internalMethodSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ key: 'value' }),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Conditional Logic
|
||||
|
||||
```typescript
|
||||
describe('conditional behavior', () => {
|
||||
it('should execute when condition is true', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage({ message: 'test' });
|
||||
});
|
||||
|
||||
expect(result.current.internal_retrieveChunks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not execute when condition is false', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage({ message: 'test' });
|
||||
});
|
||||
|
||||
expect(result.current.internal_retrieveChunks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing AbortController
|
||||
|
||||
```typescript
|
||||
it('should abort generation and clear loading state', () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({ chatLoadingIdsAbortController: abortController });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
|
||||
|
||||
act(() => {
|
||||
result.current.stopGenerateMessage();
|
||||
});
|
||||
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String));
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
❌ **Don't**: Mock the entire store
|
||||
|
||||
```typescript
|
||||
vi.mock('../../store', () => ({
|
||||
useChatStore: vi.fn(() => ({
|
||||
sendMessage: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
```
|
||||
|
||||
❌ **Don't**: Test implementation details
|
||||
|
||||
```typescript
|
||||
// Bad: testing internal state structure
|
||||
expect(result.current.messagesMap).toHaveProperty('test-session');
|
||||
|
||||
// Good: testing behavior
|
||||
expect(result.current.refreshMessages).toHaveBeenCalled();
|
||||
```
|
||||
|
||||
❌ **Don't**: Create tight coupling between tests
|
||||
|
||||
```typescript
|
||||
// Bad: Tests depend on order
|
||||
let messageId: string;
|
||||
|
||||
it('test 1', () => {
|
||||
messageId = 'some-id'; // Side effect
|
||||
});
|
||||
|
||||
it('test 2', () => {
|
||||
expect(messageId).toBeDefined(); // Depends on test 1
|
||||
});
|
||||
```
|
||||
|
||||
❌ **Don't**: Over-mock services
|
||||
|
||||
```typescript
|
||||
// Bad: Mocking everything
|
||||
beforeEach(() => {
|
||||
vi.mock('@/services/chat');
|
||||
vi.mock('@/services/message');
|
||||
vi.mock('@/services/file');
|
||||
vi.mock('@/services/agent');
|
||||
// ... too many global mocks
|
||||
});
|
||||
```
|
||||
|
||||
## Testing SWR Hooks in Zustand Stores
|
||||
|
||||
Some Zustand store slices use SWR hooks for data fetching. These require a different testing approach.
|
||||
|
||||
### Basic SWR Hook Test Structure
|
||||
|
||||
```typescript
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { discoverService } from '@/services/discover';
|
||||
import { globalHelpers } from '@/store/global/helpers';
|
||||
import { useDiscoverStore as useStore } from '../../store';
|
||||
|
||||
vi.mock('zustand/traditional');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SWR Hook Actions', () => {
|
||||
it('should fetch data and return correct response', async () => {
|
||||
const mockData = [{ id: '1', name: 'Item 1' }];
|
||||
|
||||
// Mock the service call (the fetcher)
|
||||
vi.spyOn(discoverService, 'getPluginCategories').mockResolvedValue(mockData as any);
|
||||
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('en-US');
|
||||
|
||||
const params = {} as any;
|
||||
const { result } = renderHook(() => useStore.getState().usePluginCategories(params));
|
||||
|
||||
// Use waitFor to wait for async data loading
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
});
|
||||
|
||||
expect(discoverService.getPluginCategories).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key points**:
|
||||
- **DO NOT mock useSWR** - let it use the real implementation
|
||||
- Only mock the **service methods** (fetchers)
|
||||
- Use `waitFor` from `@testing-library/react` to wait for async operations
|
||||
- Check `result.current.data` directly after waitFor completes
|
||||
|
||||
### Testing SWR Key Generation
|
||||
|
||||
```typescript
|
||||
it('should generate correct SWR key with locale and params', () => {
|
||||
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('zh-CN');
|
||||
|
||||
const useSWRMock = vi.mocked(useSWR);
|
||||
let capturedKey: string | null = null;
|
||||
useSWRMock.mockImplementation(((key: string) => {
|
||||
capturedKey = key;
|
||||
return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() };
|
||||
}) as any);
|
||||
|
||||
const params = { page: 2, category: 'tools' } as any;
|
||||
renderHook(() => useStore.getState().usePluginList(params));
|
||||
|
||||
expect(capturedKey).toBe('plugin-list-zh-CN-2-tools');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing SWR Configuration
|
||||
|
||||
```typescript
|
||||
it('should have correct SWR configuration', () => {
|
||||
const useSWRMock = vi.mocked(useSWR);
|
||||
let capturedOptions: any = null;
|
||||
useSWRMock.mockImplementation(((key: string, fetcher: any, options: any) => {
|
||||
capturedOptions = options;
|
||||
return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() };
|
||||
}) as any);
|
||||
|
||||
renderHook(() => useStore.getState().usePluginIdentifiers());
|
||||
|
||||
expect(capturedOptions).toMatchObject({ revalidateOnFocus: false });
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Conditional Fetching
|
||||
|
||||
```typescript
|
||||
it('should not fetch when required parameter is missing', () => {
|
||||
const useSWRMock = vi.mocked(useSWR);
|
||||
let capturedKey: string | null = null;
|
||||
useSWRMock.mockImplementation(((key: string | null) => {
|
||||
capturedKey = key;
|
||||
return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() };
|
||||
}) as any);
|
||||
|
||||
// When identifier is undefined, SWR key should be null
|
||||
renderHook(() => useStore.getState().usePluginDetail({ identifier: undefined }));
|
||||
|
||||
expect(capturedKey).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
### Key Differences from Regular Action Tests
|
||||
|
||||
1. **Mock useSWR globally**: Use `vi.mock('swr')` at the top level
|
||||
2. **Mock the fetcher, not the result**:
|
||||
- ✅ **Correct**: `const data = fetcher?.()` - call fetcher and return its Promise
|
||||
- ❌ **Wrong**: `return { data: mockData }` - hardcode the result
|
||||
3. **Await Promise results**: The `data` field is a Promise, use `await result.current.data`
|
||||
4. **No act() wrapper needed**: SWR hooks don't trigger React state updates in these tests
|
||||
5. **Test SWR key generation**: Verify keys include locale and parameters
|
||||
6. **Test configuration**: Verify revalidation and other SWR options
|
||||
7. **Type assertions**: Use `as any` for test mock data where type definitions are strict
|
||||
|
||||
**Why this matters**:
|
||||
- The fetcher (service method) is what we're testing - it must be called
|
||||
- Hardcoding the return value bypasses the actual fetcher logic
|
||||
- SWR returns Promises in real usage, tests should mirror this behavior
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
✅ **Clear test layers** - Each test only spies on direct dependencies
|
||||
✅ **Correct mocks** - Mocks match actual implementation
|
||||
✅ **Better maintainability** - Changes to implementation require fewer test updates
|
||||
✅ **Improved coverage** - Structured approach ensures all branches are tested
|
||||
✅ **Reduced coupling** - Tests are independent and can run in any order
|
||||
|
||||
## Reference
|
||||
|
||||
See example implementation in:
|
||||
- `src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts` (Regular actions)
|
||||
- `src/store/discover/slices/plugin/action.test.ts` (SWR hooks)
|
||||
- `src/store/discover/slices/mcp/action.test.ts` (SWR hooks)
|
||||
@@ -10,11 +10,61 @@ alwaysApply: false
|
||||
|
||||
- avoid explicit type annotations when TypeScript can infer types.
|
||||
- avoid implicitly `any` variables; explicitly type when necessary (e.g., `let a: number` instead of `let a`).
|
||||
- use the most accurate type possible (e.g., prefer `Record<PropertyKey, unknown>` over `object` and `any`).
|
||||
- use the most accurate type possible (e.g., prefer `Record<PropertyKey, unknown>` over `object`).
|
||||
- prefer `interface` over `type` for object shapes (e.g., React component props). Keep `type` for unions, intersections, and utility types.
|
||||
- prefer `as const satisfies XyzInterface` over plain `as const` when suitable.
|
||||
- prefer `@ts-expect-error` over `@ts-ignore` over `as any`
|
||||
- Avoid meaningless null/undefined parameters; design strict function contracts.
|
||||
- prefer `@ts-expect-error` over `@ts-ignore`
|
||||
- prefer `Record<string, any>` over `any`
|
||||
|
||||
- **Avoid unnecessary null checks**: Before adding `xxx !== null`, `?.`, `??`, or `!.`, read the type definition to confirm the necessary. **Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: budget.spend and budget.maxBudget is number, not number | null
|
||||
if (budget.spend !== null && budget.maxBudget !== null && budget.spend >= budget.maxBudget) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ Right
|
||||
if (budget.spend >= budget.maxBudget) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- **Avoid redundant runtime checks**: Don't add runtime validation for conditions already guaranteed by types or previous checks. Trust the type system and calling contract. **Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: Adding impossible-to-fail checks
|
||||
const due = await db.query.budgets.findMany({
|
||||
where: and(isNotNull(budgets.budgetDuration)), // Already filtered non-null
|
||||
});
|
||||
const result = due.map(b => {
|
||||
const nextReset = computeNextResetAt(b.budgetResetAt!, b.budgetDuration!);
|
||||
if (!nextReset) { // This check is impossible to fail
|
||||
throw new Error(`Unexpected null nextResetAt`);
|
||||
}
|
||||
return nextReset;
|
||||
});
|
||||
|
||||
// ✅ Right: Trust the contract
|
||||
const due = await db.query.budgets.findMany({
|
||||
where: and(isNotNull(budgets.budgetDuration)),
|
||||
});
|
||||
const result = due.map(b => computeNextResetAt(b.budgetResetAt!, b.budgetDuration!));
|
||||
```
|
||||
|
||||
- **Avoid meaningless null/undefined parameters**: Don't accept null/undefined for parameters that have no business meaning when null. Design strict function contracts. **Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: Function accepts meaningless null input
|
||||
function computeNextResetAt(currentResetAt: Date, durationStr: string | null): Date | null {
|
||||
if (!durationStr) return null; // Why accept null if it just returns null?
|
||||
}
|
||||
|
||||
// ✅ Right: Strict contract, clear responsibility
|
||||
function computeNextResetAt(currentResetAt: Date, durationStr: string): Date {
|
||||
// Function has single clear purpose, caller ensures valid input
|
||||
}
|
||||
```
|
||||
|
||||
## Imports and Modules
|
||||
|
||||
@@ -29,11 +79,16 @@ alwaysApply: false
|
||||
|
||||
## Code Structure and Readability
|
||||
|
||||
- Refactor repeated logic into reusable functions.
|
||||
- Prefer object destructuring when accessing and using properties.
|
||||
- Use consistent, descriptive naming; avoid obscure abbreviations.
|
||||
- Use semantically meaningful variable, function, and class names.
|
||||
- Replace magic numbers or strings with well-named constants.
|
||||
- Keep meaningful code comments; do not remove them when applying edits. Update comments when behavior changes.
|
||||
- Ensure JSDoc comments accurately reflect the implementation.
|
||||
- Look for opportunities to simplify or modernize code with the latest JavaScript/TypeScript features where it improves clarity.
|
||||
- Defer formatting to tooling; ignore purely formatting-only issues and autofixable lint problems.
|
||||
- Respect project Prettier settings.
|
||||
|
||||
## UI and Theming
|
||||
|
||||
@@ -45,14 +100,15 @@ alwaysApply: false
|
||||
## Performance
|
||||
|
||||
- Prefer `for…of` loops to index-based `for` loops when feasible.
|
||||
- Reuse existing utils inside `packages/utils` or installed npm packages rather than reinventing the wheel.
|
||||
- Decide whether callbacks should be debounced or throttled based on UX and performance needs.
|
||||
- Reuse existing npm packages rather than reinventing the wheel (e.g., `lodash-es/omit`).
|
||||
- Query only the required columns from a database rather than selecting entire rows.
|
||||
|
||||
## Time and Consistency
|
||||
|
||||
- Instead of calling `Date.now()` multiple times, assign it to a constant once and reuse it to ensure consistency and improve readability.
|
||||
|
||||
## Logging
|
||||
## Some logging rules
|
||||
|
||||
- Never log user private information like api key, etc
|
||||
- Don't use `import { log } from 'debug'` to log messages, because it will directly log the message to the console.
|
||||
|
||||
+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
-21
@@ -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 #########
|
||||
########################################
|
||||
@@ -180,13 +169,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# FAL_API_KEY=fal-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
########################################
|
||||
######### AI Image Settings ############
|
||||
########################################
|
||||
|
||||
# Default image generation count (range: 1-20, default: 4)
|
||||
# AI_IMAGE_DEFAULT_IMAGE_NUM=4
|
||||
|
||||
### Nebius ###
|
||||
|
||||
# NEBIUS_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -267,9 +249,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# you need to config the clerk webhook secret key if you want to use the clerk with database
|
||||
#CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Clear allow origin https://clerk.com/docs/guides/dashboard/dns-domains/satellite-domains
|
||||
# Authentication across different domains , use,to splite different origin
|
||||
# NEXT_PUBLIC_CLERK_AUTH_ALLOW_ORIGINS='https://market.lobehub.com,https://lobehub.com'
|
||||
|
||||
# NextAuth related configurations
|
||||
# NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
|
||||
@@ -284,6 +263,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
|
||||
|
||||
@@ -5,17 +5,34 @@ type: Bug
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📱 Client Type'
|
||||
description: 'Select how you are accessing LobeChat'
|
||||
label: '📦 Platform'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Web (Desktop Browser)'
|
||||
- 'Web (Mobile Browser)'
|
||||
- 'Desktop App (Electron)'
|
||||
- 'Mobile App (React Native)'
|
||||
- 'Official Preview'
|
||||
- 'Official Cloud'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Self hosting Docker'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 Deploymenet mode'
|
||||
multiple: true
|
||||
options:
|
||||
- 'client db (lobe-chat image)'
|
||||
- 'client pgelite db (lobe-chat-pglite image)'
|
||||
- 'server db(lobe-chat-database image)'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 Version'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
@@ -31,39 +48,6 @@ body:
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 Deployment Platform'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Official Cloud'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Self hosting Docker'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🔧 Deployment Mode'
|
||||
multiple: true
|
||||
options:
|
||||
- 'client db (lobe-chat image)'
|
||||
- 'client pgelite db (lobe-chat-pglite image)'
|
||||
- 'server db (lobe-chat-database image)'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 Version'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🌐 Browser'
|
||||
@@ -76,49 +60,21 @@ body:
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🐛 Bug Description'
|
||||
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📷 Recurrence Steps'
|
||||
description: A clear and concise description of how to recurrence.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🚦 Expected Behavior'
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 Additional Information'
|
||||
description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here.
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🛠️ Willing to Submit a PR?'
|
||||
description: Would you be willing to submit a pull request to fix this bug?
|
||||
options:
|
||||
- 'Yes, I am willing to submit a PR'
|
||||
- 'No, but I am happy to help test the fix'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: '✅ Validations'
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Read the [docs](https://lobehub.com/zh/docs).
|
||||
required: true
|
||||
- label: Check that there isn't [already an issue](https://github.com/lobehub/lobe-chat/issues) that reports the same bug to avoid creating a duplicate.
|
||||
required: true
|
||||
- label: Make sure this is a LobeChat issue and not a third-party library or provider issue.
|
||||
required: true
|
||||
- label: Check that this is a concrete bug. For Q&A, please use [GitHub Discussions](https://github.com/lobehub/lobe-chat/discussions) or join our [Discord Server](https://discord.gg/rGHwKq4R).
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
name: '🐛 反馈缺陷'
|
||||
description: '反馈一个问题缺陷'
|
||||
labels: ['unconfirm']
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
在创建新的 Issue 之前,请先[搜索已有问题](https://github.com/lobehub/lobe-chat/issues),如果发现已有类似的问题,请给它 **👍 点赞**,这样可以帮助我们更快地解决问题。
|
||||
如果你在使用过程中遇到问题,可以尝试以下方式获取帮助:
|
||||
- 在 [GitHub Discussions](https://github.com/lobehub/lobe-chat/discussions) 的版块发起讨论。
|
||||
- 在 [LobeChat 社区](https://discord.gg/AYFPHvv2jT) 提问,与其他用户交流。
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 部署环境'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Official Preview'
|
||||
- 'Official Cloud'
|
||||
- 'Vercel'
|
||||
- 'Zeabur'
|
||||
- 'Sealos'
|
||||
- 'Netlify'
|
||||
- 'Docker'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '📦 部署模式'
|
||||
multiple: true
|
||||
options:
|
||||
- '客户端模式(lobe-chat 镜像)'
|
||||
- '客户端 Pglite 模式(lobe-chat-pglite 镜像)'
|
||||
- '服务端模式(lobe-chat-database 镜像)'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: '📌 软件版本'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '💻 系统环境'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Windows'
|
||||
- 'macOS'
|
||||
- 'Ubuntu'
|
||||
- 'Other Linux'
|
||||
- 'iOS'
|
||||
- 'Android'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: '🌐 浏览器'
|
||||
multiple: true
|
||||
options:
|
||||
- 'Chrome'
|
||||
- 'Edge'
|
||||
- 'Safari'
|
||||
- 'Firefox'
|
||||
- 'Other'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🐛 问题描述'
|
||||
description: 请提供一个清晰且简洁的问题描述,若上述选项为`Other`,也请详细说明。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📷 复现步骤'
|
||||
description: 请提供一个清晰且简洁的描述,说明如何复现问题。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🚦 期望结果'
|
||||
description: 请提供一个清晰且简洁的描述,说明您期望发生什么。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 补充信息'
|
||||
description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。
|
||||
@@ -0,0 +1,21 @@
|
||||
name: '🌠 功能需求'
|
||||
description: '提出需求或建议'
|
||||
title: '[Request] '
|
||||
type: Feature
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🥰 需求描述'
|
||||
description: 请添加一个清晰且简洁的问题描述,阐述您希望通过这个功能需求解决的问题。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🧐 解决方案'
|
||||
description: 请清晰且简洁地描述您想要的解决方案。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📝 补充信息'
|
||||
description: 在这里添加关于问题的任何其他背景信息。
|
||||
@@ -1,7 +1,7 @@
|
||||
contact_links:
|
||||
- name: Ask a question for self-hosting
|
||||
- name: Ask a question for self-hosting | 咨询自部署问题
|
||||
url: https://github.com/lobehub/lobe-chat/discussions/new?category=self-hosting-%E7%A7%81%E6%9C%89%E5%8C%96%E9%83%A8%E7%BD%B2
|
||||
about: Please post questions, and ideas in discussions.
|
||||
- name: Questions and ideas
|
||||
about: Please post questions, and ideas in discussions. | 请在讨论区发布问题和想法。
|
||||
- name: Questions and ideas | 其他问题和想法
|
||||
url: https://github.com/lobehub/lobe-chat/discussions/new/choose
|
||||
about: Please post questions, and ideas in discussions.
|
||||
about: Please post questions, and ideas in discussions. | 请在讨论区发布问题和想法。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#### 💻 Change Type
|
||||
#### 💻 变更类型 | Change Type
|
||||
|
||||
<!-- For change type, change [ ] to [x]. -->
|
||||
|
||||
@@ -12,36 +12,10 @@
|
||||
- [ ] 📝 docs
|
||||
- [ ] 🔨 chore
|
||||
|
||||
#### 🔗 Related Issue
|
||||
|
||||
<!-- Link to the issue that is fixed by this PR -->
|
||||
|
||||
<!-- Example: Fixes #123, Closes #456, Related to #789 -->
|
||||
|
||||
#### 🔀 Description of Change
|
||||
#### 🔀 变更说明 | Description of Change
|
||||
|
||||
<!-- Thank you for your Pull Request. Please provide a description above. -->
|
||||
|
||||
#### 🧪 How to Test
|
||||
|
||||
<!-- Please describe how you tested your changes -->
|
||||
|
||||
<!-- For AI features, please include test prompts or scenarios -->
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Added/updated tests
|
||||
- [ ] No tests needed
|
||||
|
||||
#### 📸 Screenshots / Videos
|
||||
|
||||
<!-- If this PR includes UI changes, please provide screenshots or videos -->
|
||||
|
||||
| Before | After |
|
||||
| ------ | ----- |
|
||||
| ... | ... |
|
||||
|
||||
#### 📝 Additional Information
|
||||
#### 📝 补充信息 | Additional Information
|
||||
|
||||
<!-- Add any other context about the Pull Request here. -->
|
||||
|
||||
<!-- Breaking changes? Migration guide? Performance impact? -->
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-var
|
||||
var process: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
interface GitHubIssue {
|
||||
created_at: string;
|
||||
number: number;
|
||||
title: string;
|
||||
user: { id: number };
|
||||
}
|
||||
|
||||
interface GitHubComment {
|
||||
body: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
user: { id: number; type: string };
|
||||
}
|
||||
|
||||
interface GitHubReaction {
|
||||
content: string;
|
||||
user: { id: number };
|
||||
}
|
||||
|
||||
async function githubRequest<T>(
|
||||
endpoint: string,
|
||||
token: string,
|
||||
method: string = 'GET',
|
||||
body?: any,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'auto-close-duplicates-script',
|
||||
...(body && { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
method,
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function extractDuplicateIssueNumber(commentBody: string): number | null {
|
||||
// Try to match #123 format first
|
||||
let match = commentBody.match(/#(\d+)/);
|
||||
if (match) {
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
// Try to match GitHub issue URL format: https://github.com/owner/repo/issues/123
|
||||
match = commentBody.match(/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/);
|
||||
if (match) {
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function closeIssueAsDuplicate(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
duplicateOfNumber: number,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await githubRequest(`/repos/${owner}/${repo}/issues/${issueNumber}`, token, 'PATCH', {
|
||||
labels: ['duplicate'],
|
||||
state: 'closed',
|
||||
state_reason: 'duplicate',
|
||||
});
|
||||
|
||||
await githubRequest(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, token, 'POST', {
|
||||
body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}.
|
||||
|
||||
If this is incorrect, please re-open this issue or create a new one.
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)`,
|
||||
});
|
||||
}
|
||||
|
||||
async function autoCloseDuplicates(): Promise<void> {
|
||||
console.log('[DEBUG] Starting auto-close duplicates script');
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error('GITHUB_TOKEN environment variable is required');
|
||||
}
|
||||
console.log('[DEBUG] GitHub token found');
|
||||
|
||||
const owner = process.env.GITHUB_REPOSITORY_OWNER || 'lobehub';
|
||||
const repo = process.env.GITHUB_REPOSITORY_NAME || 'lobe-chat';
|
||||
console.log(`[DEBUG] Repository: ${owner}/${repo}`);
|
||||
|
||||
const threeDaysAgo = new Date();
|
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
||||
console.log(`[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`);
|
||||
|
||||
console.log('[DEBUG] Fetching open issues created more than 3 days ago...');
|
||||
const allIssues: GitHubIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const pageIssues: GitHubIssue[] = await githubRequest(
|
||||
`/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`,
|
||||
token,
|
||||
);
|
||||
|
||||
if (pageIssues.length === 0) break;
|
||||
|
||||
// Filter for issues created more than 3 days ago
|
||||
const oldEnoughIssues = pageIssues.filter(
|
||||
(issue) => new Date(issue.created_at) <= threeDaysAgo,
|
||||
);
|
||||
|
||||
allIssues.push(...oldEnoughIssues);
|
||||
page++;
|
||||
|
||||
// Safety limit to avoid infinite loops
|
||||
if (page > 20) break;
|
||||
}
|
||||
|
||||
const issues = allIssues;
|
||||
console.log(`[DEBUG] Found ${issues.length} open issues`);
|
||||
|
||||
let processedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
processedCount++;
|
||||
console.log(
|
||||
`[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`,
|
||||
);
|
||||
|
||||
console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`);
|
||||
const comments: GitHubComment[] = await githubRequest(
|
||||
`/repos/${owner}/${repo}/issues/${issue.number}/comments`,
|
||||
token,
|
||||
);
|
||||
console.log(`[DEBUG] Issue #${issue.number} has ${comments.length} comments`);
|
||||
|
||||
const dupeComments = comments.filter(
|
||||
(comment) =>
|
||||
comment.body.includes('Found') &&
|
||||
comment.body.includes('possible duplicate') &&
|
||||
comment.user.type === 'Bot',
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`,
|
||||
);
|
||||
|
||||
if (dupeComments.length === 0) {
|
||||
console.log(`[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastDupeComment = dupeComments.at(-1);
|
||||
// @ts-ignore
|
||||
const dupeCommentDate = new Date(lastDupeComment.created_at);
|
||||
console.log(
|
||||
`[DEBUG] Issue #${
|
||||
issue.number
|
||||
} - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`,
|
||||
);
|
||||
|
||||
if (dupeCommentDate > threeDaysAgo) {
|
||||
console.log(`[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`);
|
||||
continue;
|
||||
}
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - duplicate comment is old enough (${Math.floor(
|
||||
(Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
)} days)`,
|
||||
);
|
||||
|
||||
const commentsAfterDupe = comments.filter(
|
||||
(comment) => new Date(comment.created_at) > dupeCommentDate,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`,
|
||||
);
|
||||
|
||||
if (commentsAfterDupe.length > 0) {
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`);
|
||||
const reactions: GitHubReaction[] = await githubRequest(
|
||||
// @ts-ignore
|
||||
`/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`,
|
||||
token,
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`,
|
||||
);
|
||||
|
||||
const authorThumbsDown = reactions.some(
|
||||
(reaction) => reaction.user.id === issue.user.id && reaction.content === '-1',
|
||||
);
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`,
|
||||
);
|
||||
|
||||
if (authorThumbsDown) {
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
|
||||
if (!duplicateIssueNumber) {
|
||||
console.log(
|
||||
`[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
candidateCount++;
|
||||
const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`,
|
||||
);
|
||||
await closeIssueAsDuplicate(owner, repo, issue.number, duplicateIssueNumber, token);
|
||||
console.log(
|
||||
`[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-top-level-await
|
||||
autoCloseDuplicates().catch(console.error);
|
||||
|
||||
// Make it a module
|
||||
export {};
|
||||
@@ -1,78 +0,0 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Lock closed issues after 7 days of inactivity
|
||||
* @param {object} github - GitHub API client
|
||||
* @param {object} context - GitHub Actions context
|
||||
*/
|
||||
module.exports = async ({ github, context }) => {
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const lockComment = `This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.`;
|
||||
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
let totalLocked = 0;
|
||||
|
||||
while (hasMore) {
|
||||
// Get closed issues (pagination)
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed',
|
||||
sort: 'updated',
|
||||
direction: 'asc',
|
||||
per_page: 100,
|
||||
page: page,
|
||||
});
|
||||
|
||||
if (issues.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
// Skip if already locked
|
||||
if (issue.locked) continue;
|
||||
|
||||
// Skip pull requests
|
||||
if (issue.pull_request) continue;
|
||||
|
||||
// Check if updated more than 7 days ago
|
||||
const updatedAt = new Date(issue.updated_at);
|
||||
if (updatedAt > sevenDaysAgo) {
|
||||
// Since issues are sorted by updated_at ascending,
|
||||
// once we hit a recent issue, all remaining will be recent too
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add comment before locking
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: lockComment,
|
||||
});
|
||||
|
||||
// Lock the issue
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: 'resolved',
|
||||
});
|
||||
|
||||
totalLocked++;
|
||||
console.log(`Locked issue #${issue.number}: ${issue.title}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to lock issue #${issue.number}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
console.log(`Total issues locked: ${totalLocked}`);
|
||||
};
|
||||
@@ -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,33 +0,0 @@
|
||||
name: Claude Issue Dedupe
|
||||
description: Automatically dedupe GitHub issues using Claude Code
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number to process for duplicate detection'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-dedupe-issues:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code slash command
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: '/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}'
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Claude Issue Triage
|
||||
description: Automatically triage GitHub issues using Claude Code
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
|
||||
jobs:
|
||||
triage-issue:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Only run on issue opened, or when "trigger:triage" label is added
|
||||
if: github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'trigger:triage')
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Copy triage prompts
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/team-assignment.md /tmp/claude-prompts/
|
||||
cp .claude/prompts/issue-triage.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for Issue Triage
|
||||
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(gh *),Read"
|
||||
prompt: |
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze issues, apply appropriate labels, and mention the responsible team member.
|
||||
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
## Instructions
|
||||
|
||||
Follow the complete triage guide located at:
|
||||
```bash
|
||||
cat /tmp/claude-prompts/issue-triage.md
|
||||
```
|
||||
|
||||
Read the team assignment guide for determining team members:
|
||||
```bash
|
||||
cat /tmp/claude-prompts/team-assignment.md
|
||||
```
|
||||
|
||||
**IMPORTANT**:
|
||||
- Follow ALL steps in the issue-triage.md guide
|
||||
- Apply labels according to the guide's rules
|
||||
- Post a mention comment to the appropriate team member(s) based on team-assignment.md
|
||||
- Replace [ISSUE_NUMBER] with: ${{ github.event.issue.number }}
|
||||
|
||||
**Start the triage process now.**
|
||||
|
||||
- name: Remove trigger label
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'trigger:triage'
|
||||
run: |
|
||||
gh issue edit ${{ github.event.issue.number }} --remove-label "trigger:triage"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
@@ -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.**
|
||||
@@ -1,121 +0,0 @@
|
||||
name: Claude Translator
|
||||
concurrency:
|
||||
group: translator-${{ github.event.comment.id || github.event.issue.number || github.event.review.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
if: |
|
||||
(github.event_name == 'issues') ||
|
||||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
|
||||
(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
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude for translation
|
||||
uses: anthropics/claude-code-action@main
|
||||
id: claude
|
||||
with:
|
||||
# Warning: Permissions should have been controlled by workflow permission.
|
||||
# Now `contents: read` is safe for files, but we could make a fine-grained token to control it.
|
||||
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
|
||||
prompt: |
|
||||
You are a multilingual translation assistant. You need to respond to the following four types of GitHub Webhook events:
|
||||
|
||||
- issues
|
||||
- issue_comment
|
||||
- pull_request_review
|
||||
- pull_request_review_comment
|
||||
|
||||
Please complete the following tasks:
|
||||
|
||||
1. Retrieve complete information for the current event.
|
||||
|
||||
- If the current event is 'issues', get the issue information.
|
||||
- If the current event is 'issue_comment', get the comment information.
|
||||
- If the current event is 'pull_request_review', get the review information.
|
||||
- If the current event is 'pull_request_review_comment', get the comment information.
|
||||
|
||||
2. Intelligently detect content.
|
||||
|
||||
- If the retrieved information is already translated content following the format requirements, check if the translation matches the original content. If not, retranslate to match and follow the format requirements.
|
||||
- If the retrieved information is untranslated content, check its language. If not in English, translate to English.
|
||||
- If the retrieved information is partially translated to English, translate it completely to English.
|
||||
- If the retrieved information contains references to already translated content, clean the referenced content to contain only English. Referenced content should not include "This xxx was translated by Claude" and "Original Content" etc.
|
||||
- If the retrieved information contains other types of references (i.e., references to non-Claude translated content), keep them as-is without translation.
|
||||
- If the retrieved information is email reply content, place email content references at the end during translation. Include only the reply content itself in both original and translated content, without email content references.
|
||||
- If the retrieved information doesn't need any processing, skip the task.
|
||||
|
||||
3. Format requirements:
|
||||
|
||||
- Title: English translation (if non-English)
|
||||
- Content format:
|
||||
[Translated content]
|
||||
|
||||
---
|
||||
> This issue/comment/review was translated by Claude.
|
||||
|
||||
<details>
|
||||
<summary>Original Content</summary>
|
||||
[Original content]
|
||||
</details>
|
||||
|
||||
4. CRITICAL RULES to prevent hallucination and ensure accuracy:
|
||||
|
||||
- The "Original Content" section MUST contain the EXACT, UNMODIFIED original text byte-for-byte. NEVER add, remove, modify, or hallucinate ANY content in this section.
|
||||
- Code blocks, error logs, JSON structures, and other technical content MUST appear in BOTH the translated section AND the original content section WITHOUT ANY MODIFICATION.
|
||||
- When translating content with code/logs/JSON:
|
||||
* Copy the code/logs/JSON blocks identically to both sections
|
||||
* Only translate the natural language text (e.g., Chinese, Japanese) surrounding the code blocks
|
||||
* Keep all technical content (URLs, variable names, error messages in English) unchanged
|
||||
- ALWAYS verify the "Original Content" section matches the source text exactly before updating
|
||||
- If you detect any discrepancy, retrieve the original content again to ensure accuracy
|
||||
- Pay special attention to the end of comments - do not drop or hallucinate the last sentences
|
||||
|
||||
5. Update using gh tool:
|
||||
|
||||
- Choose the correct command based on the Event type in environment information:
|
||||
- If Event is 'issues': gh issue edit [ISSUE_NUMBER] --title "[English title]" --body "[Translated content + Original content]"
|
||||
- If Event is 'issue_comment': gh api -X PATCH /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }} -f body="[Translated content + Original content]"
|
||||
- If Event is 'pull_request_review': gh api -X PUT /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews/${{ github.event.review.id }} -f body="[Translated content]"
|
||||
- If Event is 'pull_request_review_comment': gh api -X PATCH /repos/${{ github.repository }}/pulls/comments/${{ github.event.comment.id }} -f body="[Translated content + Original content]"
|
||||
|
||||
<environment_context>
|
||||
- Event: ${{ github.event_name }}
|
||||
- Issue Number: ${{ github.event.issue.number }}
|
||||
- Repository: ${{ github.repository }}
|
||||
- (Review) Comment ID: ${{ github.event.comment.id || 'N/A' }}
|
||||
- Pull Request Number: ${{ github.event.pull_request.number || 'N/A' }}
|
||||
- Review ID: ${{ github.event.review.id || 'N/A' }}
|
||||
</environment_context>
|
||||
|
||||
|
||||
Use the following command to get complete information:
|
||||
gh issue view ${{ github.event.issue.number }} --json title,body,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 构建版本的前缀标识
|
||||
@@ -20,8 +18,8 @@ env:
|
||||
jobs:
|
||||
test:
|
||||
name: Code quality check
|
||||
# 添加 PR label 触发条件,只有添加了 trigger:build-desktop 标签的 PR 才会触发构建
|
||||
if: contains(github.event.pull_request.labels.*.name, 'trigger:build-desktop')
|
||||
# 添加 PR label 触发条件,只有添加了 Build Desktop 标签的 PR 才会触发构建
|
||||
if: contains(github.event.pull_request.labels.*.name, 'Build Desktop')
|
||||
runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查
|
||||
steps:
|
||||
- name: Checkout base
|
||||
@@ -30,15 +28,15 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
@@ -53,7 +51,7 @@ jobs:
|
||||
version:
|
||||
name: Determine version
|
||||
# 与 test job 相同的触发条件
|
||||
if: contains(github.event.pull_request.labels.*.name, 'trigger:build-desktop')
|
||||
if: contains(github.event.pull_request.labels.*.name, 'Build Desktop')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# 输出版本信息,供后续 job 使用
|
||||
@@ -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
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -97,7 +95,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel, windows-2025, ubuntu-latest]
|
||||
os: [macos-latest, macos-13, windows-2025, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -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 压缩可用
|
||||
@@ -131,11 +129,11 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: "nightly"
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
# 默认添加一个加密 SECRET
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
# macOS 签名和公证配置
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -154,10 +152,10 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: "nightly"
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
# 将 TEMP 和 TMP 目录设置到 C 盘
|
||||
@@ -170,10 +168,10 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: "nightly"
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
|
||||
@@ -190,7 +188,7 @@ jobs:
|
||||
else
|
||||
ARCH_SUFFIX="x64"
|
||||
fi
|
||||
|
||||
|
||||
mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml"
|
||||
echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml (detected: $SYSTEM_ARCH)"
|
||||
ls -la latest-mac-*.yml
|
||||
@@ -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,19 +226,19 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v4
|
||||
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@v4
|
||||
with:
|
||||
name: merged-release-pr
|
||||
path: release
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
name: Publish Database Docker Image
|
||||
|
||||
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, 'Build Docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
os: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build ${{ matrix.platform }} Image
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
# PR 构建使用特殊的 tag
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
# release 构建使用版本号
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and export
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
context: .
|
||||
file: ./Dockerfile.database
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
rm -rf /tmp/digests
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Comment on PR with Docker build info
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prComment = require('${{ github.workspace }}/.github/scripts/docker-pr-comment.js');
|
||||
const result = await prComment({
|
||||
github,
|
||||
context,
|
||||
dockerMetaJson: ${{ toJSON(steps.meta.outputs.json) }},
|
||||
image: "${{ env.REGISTRY_IMAGE }}",
|
||||
version: "${{ steps.meta.outputs.version }}",
|
||||
dockerhubUrl: "https://hub.docker.com/r/${{ env.REGISTRY_IMAGE }}/tags",
|
||||
platforms: "linux/amd64, linux/arm64",
|
||||
});
|
||||
core.info(`Status: ${result.updated ? 'Updated' : 'Created'}, ID: ${result.id}`);
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Publish Docker Pglite Image
|
||||
|
||||
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, 'Build Docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
os: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Build ${{ matrix.platform }} Image
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 PR 生成特殊的 tag
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
# PR 构建使用特殊的 tag
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
# release 构建使用版本号
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and export
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
context: .
|
||||
file: ./Dockerfile.pglite
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
rm -rf /tmp/digests
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 为 merge job 添加 PR metadata 生成
|
||||
- name: Generate PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_meta
|
||||
run: |
|
||||
branch_name="${{ github.head_ref }}"
|
||||
sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g')
|
||||
echo "pr_tag=${sanitized_branch}-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.PR_TAG_PREFIX }}${{ steps.pr_meta.outputs.pr_tag }},enable=${{ github.event_name == 'pull_request' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name != 'pull_request' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
@@ -1,32 +1,27 @@
|
||||
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, 'Build Docker')) ||
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -53,12 +48,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 +62,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 +98,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 +116,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
@@ -133,12 +127,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 +140,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 +159,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}`);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
name: E2E CI
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
|
||||
- name: Install dependencies (bun)
|
||||
run: bun install
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: e2e/reports
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload screenshots (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: test-screenshots
|
||||
path: e2e/screenshots
|
||||
if-no-files-found: ignore
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Auto-close duplicate issues
|
||||
description: Auto-closes issues that are duplicates of existing issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
auto-close-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Auto-close duplicate issues
|
||||
run: bun run .github/scripts/auto-close-duplicates.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
|
||||
@@ -28,7 +28,8 @@ jobs:
|
||||
👀 @{{ author }}
|
||||
|
||||
Thank you for raising an issue. We will investigate into the matter and get back to you as soon as possible.
|
||||
Please make sure you have given us as much context as possible.
|
||||
Please make sure you have given us as much context as possible.\
|
||||
非常感谢您提交 issue。我们会尽快调查此事,并尽快回复您。 请确保您已经提供了尽可能多的背景信息。
|
||||
- name: Auto Comment on Issues Closed
|
||||
uses: wow-actions/auto-comment@v1
|
||||
with:
|
||||
@@ -36,7 +37,8 @@ jobs:
|
||||
issuesClosed: |
|
||||
✅ @{{ author }}
|
||||
|
||||
This issue is closed, If you have any questions, you can comment and reply.
|
||||
This issue is closed, If you have any questions, you can comment and reply.\
|
||||
此问题已经关闭。如果您有任何问题,可以留言并回复。
|
||||
- name: Auto Comment on Pull Request Opened
|
||||
uses: wow-actions/auto-comment@v1
|
||||
with:
|
||||
@@ -46,7 +48,9 @@ jobs:
|
||||
|
||||
Thank you for raising your pull request and contributing to our Community
|
||||
Please make sure you have followed our contributing guidelines. We will review it as soon as possible.
|
||||
If you encounter any problems, please feel free to connect with us.
|
||||
If you encounter any problems, please feel free to connect with us.\
|
||||
非常感谢您提出拉取请求并为我们的社区做出贡献,请确保您已经遵循了我们的贡献指南,我们会尽快审查它。
|
||||
如果您遇到任何问题,请随时与我们联系。
|
||||
- name: Auto Comment on Pull Request Merged
|
||||
uses: actions-cool/pr-welcome@main
|
||||
if: github.event.pull_request.merged == true
|
||||
@@ -55,7 +59,8 @@ jobs:
|
||||
comment: |
|
||||
❤️ Great PR @${{ github.event.pull_request.user.login }} ❤️
|
||||
|
||||
The growth of project is inseparable from user feedback and contribution, thanks for your contribution! If you are interesting with the lobehub developer community, please join our [discord](https://discord.com/invite/AYFPHvv2jT) and then dm @arvinxx or @canisminor1990. They will invite you to our private developer channel. We are talking about the lobe-chat development or sharing ai newsletter around the world.
|
||||
The growth of project is inseparable from user feedback and contribution, thanks for your contribution! If you are interesting with the lobehub developer community, please join our [discord](https://discord.com/invite/AYFPHvv2jT) and then dm @arvinxx or @canisminor1990. They will invite you to our private developer channel. We are talking about the lobe-chat development or sharing ai newsletter around the world.\
|
||||
项目的成长离不开用户反馈和贡献,感谢您的贡献! 如果您对 LobeHub 开发者社区感兴趣,请加入我们的 [discord](https://discord.com/invite/AYFPHvv2jT),然后私信 @arvinxx 或 @canisminor1990。他们会邀请您加入我们的私密开发者频道。我们将会讨论关于 Lobe Chat 的开发,分享和讨论全球范围内的 AI 消息。
|
||||
emoji: 'hooray'
|
||||
pr-emoji: '+1, heart'
|
||||
- name: Remove inactive
|
||||
|
||||
@@ -38,7 +38,8 @@ jobs:
|
||||
body: |
|
||||
👋 @{{ author }}
|
||||
<br/>
|
||||
Since the issue was labeled with `✅ Fixed`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
|
||||
Since the issue was labeled with `✅ Fixed`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.\
|
||||
由于该 issue 被标记为已修复,同时 3 天未收到回应。现关闭 issue,若有任何问题,可评论回复。
|
||||
- name: need reproduce
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
@@ -49,7 +50,8 @@ jobs:
|
||||
body: |
|
||||
👋 @{{ author }}
|
||||
<br/>
|
||||
Since the issue was labeled with `🤔 Need Reproduce`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
|
||||
Since the issue was labeled with `🤔 Need Reproduce`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.\
|
||||
由于该 issue 被标记为需要更多信息,却 3 天未收到回应。现关闭 issue,若有任何问题,可评论回复。
|
||||
- name: need reproduce
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
@@ -60,4 +62,5 @@ jobs:
|
||||
body: |
|
||||
👋 @{{ github.event.issue.user.login }}
|
||||
<br/>
|
||||
Since the issue was labeled with `🙅🏻♀️ WON'T DO`, and no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
|
||||
Since the issue was labeled with `🙅🏻♀️ WON'T DO`, and no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.\
|
||||
由于该 issue 被标记为暂不处理,同时 3 天未收到回应。现关闭 issue,若有任何问题,可评论回复。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Issue Translate
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: usthe/issues-translate-action@v2.7
|
||||
with:
|
||||
BOT_GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "Lock Stale Issues"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
|
||||
jobs:
|
||||
lock-closed-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Lock closed issues after 7 days of inactivity
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const lockScript = require('./.github/scripts/lock-closed-issues.js');
|
||||
await lockScript({ github, context });
|
||||
@@ -15,7 +15,7 @@ permissions: read-all
|
||||
jobs:
|
||||
test:
|
||||
name: Code quality check
|
||||
# 添加 PR label 触发条件,只有添加了 trigger:build-desktop 标签的 PR 才会触发构建
|
||||
# 添加 PR label 触发条件,只有添加了 Build Desktop 标签的 PR 才会触发构建
|
||||
runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查
|
||||
steps:
|
||||
- name: Checkout base
|
||||
@@ -24,15 +24,15 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
@@ -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
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel, windows-2025, ubuntu-latest]
|
||||
os: [macos-latest, macos-13, windows-2025, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -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 压缩可用
|
||||
@@ -116,9 +116,9 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
# 默认添加一个加密 SECRET
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
# macOS 签名和公证配置
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -137,8 +137,8 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
@@ -152,8 +152,8 @@ jobs:
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
else
|
||||
ARCH_SUFFIX="x64"
|
||||
fi
|
||||
|
||||
|
||||
mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml"
|
||||
echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml (detected: $SYSTEM_ARCH)"
|
||||
ls -la latest-mac-*.yml
|
||||
@@ -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,19 +208,19 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v4
|
||||
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@v4
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
name: Release CI
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -24,6 +17,7 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@@ -33,15 +27,15 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
@@ -54,7 +48,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
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
name: Database Schema Visualization CI
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -50,5 +50,5 @@ jobs:
|
||||

|
||||
|
||||
[lobechat]: https://github.com/lobehub/lobe-chat
|
||||
[tutorial-zh-CN]: https://lobehub.com/zh/docs/self-hosting/advanced/upstream-sync
|
||||
[tutorial-en-US]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
|
||||
[tutorial-zh-CN]: https://github.com/lobehub/lobe-chat/wiki/Upstream-Sync.zh-CN
|
||||
[tutorial-en-US]: https://github.com/lobehub/lobe-chat/wiki/Upstream-Sync
|
||||
|
||||
+26
-61
@@ -18,10 +18,8 @@ jobs:
|
||||
- web-crawler
|
||||
- electron-server-ipc
|
||||
- utils
|
||||
- python-interpreter
|
||||
- context-engine
|
||||
- agent-runtime
|
||||
- conversation-flow
|
||||
|
||||
name: Test package ${{ matrix.package }}
|
||||
|
||||
@@ -29,9 +27,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
|
||||
@@ -46,7 +44,7 @@ jobs:
|
||||
run: bun run --filter @lobechat/${{ matrix.package }} test:coverage
|
||||
|
||||
- name: Upload ${{ matrix.package }} coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/${{ matrix.package }}/coverage/lcov.info
|
||||
@@ -64,15 +62,15 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
@@ -81,7 +79,7 @@ jobs:
|
||||
run: bun run --filter ${{ matrix.package }} test:coverage
|
||||
|
||||
- name: Upload ${{ matrix.package }} coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/${{ matrix.package }}/coverage/lcov.info
|
||||
@@ -97,15 +95,15 @@ 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
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.23
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
@@ -114,49 +112,12 @@ jobs:
|
||||
run: bun run test-app:coverage
|
||||
|
||||
- name: Upload App Coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
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 +131,7 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@@ -177,38 +139,41 @@ 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
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm i
|
||||
run: bun i
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test Client DB
|
||||
run: pnpm --filter @lobechat/database test:client-db
|
||||
run: bun run --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
|
||||
|
||||
- name: Test Coverage
|
||||
run: pnpm --filter @lobechat/database test:coverage
|
||||
run: bun run --filter @lobechat/database test:coverage
|
||||
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
|
||||
|
||||
- name: Upload Database coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/database/coverage/lcov.info
|
||||
|
||||
@@ -117,4 +117,3 @@ CLAUDE.local.md
|
||||
|
||||
prd
|
||||
GEMINI.md
|
||||
e2e/reports
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'chatgpt-4o-latest',
|
||||
modelName: 'gpt-4.1-mini',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ resolution-mode=highest
|
||||
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*
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
const config = require('@lobehub/lint').semanticRelease;
|
||||
|
||||
config.branches = [
|
||||
'main',
|
||||
{
|
||||
name: 'next',
|
||||
prerelease: true,
|
||||
},
|
||||
];
|
||||
|
||||
config.plugins.push([
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
Vendored
+1
-2
@@ -11,7 +11,6 @@
|
||||
{ "rule": "prettier/prettier", "severity": "off" },
|
||||
{ "rule": "react/jsx-sort-props", "severity": "off" },
|
||||
{ "rule": "sort-keys-fix/sort-keys-fix", "severity": "off" },
|
||||
{ "rule": "simple-import-sort/exports", "severity": "off" },
|
||||
{ "rule": "typescript-sort-keys/interface", "severity": "off" }
|
||||
],
|
||||
"eslint.validate": [
|
||||
@@ -82,7 +81,7 @@
|
||||
|
||||
"**/src/config/modelProviders/*.ts": "${filename} • provider",
|
||||
"**/packages/model-bank/src/aiModels/*.ts": "${filename} • model",
|
||||
"**/packages/model-runtime/src/providers/*/index.ts": "${dirname} • runtime",
|
||||
"**/packages/model-runtime/src/*/index.ts": "${dirname} • runtime",
|
||||
|
||||
"**/src/server/services/*/index.ts": "${dirname} • server/service",
|
||||
"**/src/server/routers/lambda/*.ts": "${filename} • lambda",
|
||||
|
||||
@@ -44,7 +44,21 @@ The project follows a well-organized monorepo structure:
|
||||
|
||||
#### TypeScript
|
||||
|
||||
- Follow strict TypeScript practices for type safety and code quality
|
||||
- Use proper type annotations
|
||||
- Prefer interfaces over types for object shapes
|
||||
- Use generics for reusable components
|
||||
|
||||
#### React Components
|
||||
|
||||
- Use functional components with hooks
|
||||
|
||||
#### Database Schema
|
||||
|
||||
- Follow Drizzle ORM naming conventions
|
||||
- Use plural snake_case for table names
|
||||
- Implement proper foreign key relationships
|
||||
- Follow the schema style guide
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
@@ -53,57 +67,64 @@ The project follows a well-organized monorepo structure:
|
||||
**Commands**:
|
||||
|
||||
- Web: `bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
- Packages: `cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'` (each subpackage contains its own vitest.config.mts)
|
||||
- Packages: `cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- Wrap file paths in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` - this runs all tests and takes \~10 minutes
|
||||
- Never run `bun run test` - this runs all tests and takes ~10 minutes
|
||||
- If a test fails twice, stop and ask for help
|
||||
- Always add tests for new code
|
||||
|
||||
### Type Checking
|
||||
|
||||
- Use `bun run type-check` to check for type errors
|
||||
- Ensure all TypeScript errors are resolved before committing
|
||||
|
||||
### i18n
|
||||
### Internationalization
|
||||
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` locale file only for preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
- Add new keys to `src/locales/default/namespace.ts`
|
||||
- Translate at least `zh-CN` files for development preview
|
||||
- Use hierarchical nested objects, not flat keys
|
||||
- Don't run `pnpm i18n` manually (handled by CI)
|
||||
|
||||
## Project Rules Index
|
||||
## Available Development Rules
|
||||
|
||||
All following rules are saved under `.cursor/rules/` directory:
|
||||
The project provides comprehensive rules in `.cursor/rules/` directory:
|
||||
|
||||
### Backend
|
||||
### Core Development
|
||||
|
||||
- `drizzle-schema-style-guide.mdc` – Style guide for defining Drizzle ORM schemas
|
||||
- `backend-architecture.mdc` - Three-layer architecture and data flow
|
||||
- `react-component.mdc` - Component patterns and UI library usage
|
||||
- `drizzle-schema-style-guide.mdc` - Database schema conventions
|
||||
- `define-database-model.mdc` - Model templates and CRUD patterns
|
||||
- `i18n.mdc` - Internationalization workflow
|
||||
|
||||
### Frontend
|
||||
### State Management & UI
|
||||
|
||||
- `react-component.mdc` – React component style guide and conventions
|
||||
- `i18n.mdc` – Internationalization guide using react-i18next
|
||||
- `typescript.mdc` – TypeScript code style guide
|
||||
- `packages/react-layout-kit.mdc` – Usage guide for react-layout-kit
|
||||
- `zustand-slice-organization.mdc` - Store organization patterns
|
||||
- `zustand-action-patterns.mdc` - Action implementation patterns
|
||||
- `packages/react-layout-kit.mdc` - Flex layout component usage
|
||||
|
||||
### State Management
|
||||
### Testing & Quality
|
||||
|
||||
- `zustand-action-patterns.mdc` – Recommended patterns for organizing Zustand actions
|
||||
- `zustand-slice-organization.mdc` – Best practices for structuring Zustand slices
|
||||
- `testing-guide/testing-guide.mdc` - Comprehensive testing strategy
|
||||
- `code-review.mdc` - Code review process and standards
|
||||
|
||||
### Desktop (Electron)
|
||||
|
||||
- `desktop-feature-implementation.mdc` – Implementing new Electron desktop features
|
||||
- `desktop-controller-tests.mdc` – Desktop controller unit testing guide
|
||||
- `desktop-local-tools-implement.mdc` – Workflow to add new desktop local tools
|
||||
- `desktop-menu-configuration.mdc` – Desktop menu configuration guide
|
||||
- `desktop-window-management.mdc` – Desktop window management guide
|
||||
- `desktop-feature-implementation.mdc` - Main/renderer process patterns
|
||||
- `desktop-local-tools-implement.mdc` - Tool integration workflow
|
||||
- `desktop-menu-configuration.mdc` - Menu system configuration
|
||||
- `desktop-window-management.mdc` - Window management patterns
|
||||
- `desktop-controller-tests.mdc` - Controller testing guide
|
||||
|
||||
### Debugging
|
||||
## Best Practices
|
||||
|
||||
- `debug-usage.mdc` – Using the debug package and namespace conventions
|
||||
|
||||
### Testing
|
||||
|
||||
- `testing-guide/testing-guide.mdc` – Comprehensive testing guide for Vitest
|
||||
- `testing-guide/electron-ipc-test.mdc` – Electron IPC interface testing strategy
|
||||
- `testing-guide/db-model-test.mdc` – Database Model testing guide
|
||||
- **Conservative for existing code, modern approaches for new features**
|
||||
- **Code Language**: Use Chinese for files with existing Chinese comments, American English for new files
|
||||
- Always add tests for new functionality
|
||||
- Follow the established patterns in the codebase
|
||||
- Use proper error handling and logging
|
||||
- Implement proper accessibility features
|
||||
- Consider internationalization from the start
|
||||
|
||||
-3972
File diff suppressed because it is too large
Load Diff
@@ -31,18 +31,28 @@ This repository adopts a monorepo structure.
|
||||
|
||||
see @.cursor/rules/typescript.mdc
|
||||
|
||||
### Modify Code Rules
|
||||
|
||||
- **Code Language**:
|
||||
- For files with existing Chinese comments: Continue using Chinese to maintain consistency
|
||||
- For new files or files without Chinese comments: MUST use American English.
|
||||
- eg: new react tsx file and new test file
|
||||
- Conservative for existing code, modern approaches for new features
|
||||
|
||||
### Testing
|
||||
|
||||
- **Required Rule**: read `@.cursor/rules/testing-guide/testing-guide.mdc` before writing tests
|
||||
Testing work follows the Rule-Aware Task Execution system above.
|
||||
|
||||
- **Required Rule**: `testing-guide/testing-guide.mdc`
|
||||
- **Command**:
|
||||
- web: `bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
- packages(eg: database): `cd packages/database && bunx vitest run --silent='passed-only' '[file-path-pattern]'`
|
||||
|
||||
**Important**:
|
||||
|
||||
- wrap the file path in single quotes to avoid shell expansion
|
||||
- wrapped the file path in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` etc to run tests, this will run all tests and cost about 10mins
|
||||
- If trying to fix the same test twice, but still failed, stop and ask for help.
|
||||
- If try to fix the same test twice, but still failed, stop and ask for help.
|
||||
|
||||
### Typecheck
|
||||
|
||||
@@ -51,9 +61,40 @@ see @.cursor/rules/typescript.mdc
|
||||
### i18n
|
||||
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` and `locales/en-US/namespace.json` locales file only for dev preview
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` locale file only for preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
|
||||
## Rules Index
|
||||
|
||||
Some useful project rules are listed in @.cursor/rules/rules-index.mdc
|
||||
Some useful rules of this project. Read them when needed.
|
||||
|
||||
**IMPORTANT**: All rule files referenced in this document are located in the `.cursor/rules/` directory. Throughout this document, rule files are referenced by their filename only for brevity.
|
||||
|
||||
### 📋 Complete Rule Files
|
||||
|
||||
**Core Development**
|
||||
|
||||
- `backend-architecture.mdc` - Three-layer architecture, data flow
|
||||
- `react-component.mdc` - antd-style, Lobe UI usage
|
||||
- `drizzle-schema-style-guide.mdc` - Schema naming, patterns
|
||||
- `define-database-model.mdc` - Model templates, CRUD patterns
|
||||
- `i18n.mdc` - Internationalization workflow
|
||||
|
||||
**State & UI**
|
||||
|
||||
- `zustand-slice-organization.mdc` - Store organization
|
||||
- `zustand-action-patterns.mdc` - Action patterns
|
||||
- `packages/react-layout-kit.mdc` - flex layout components usage
|
||||
|
||||
**Testing & Quality**
|
||||
|
||||
- `testing-guide/testing-guide.mdc` - Test strategy, mock patterns
|
||||
- `code-review.mdc` - Review process and standards
|
||||
|
||||
**Desktop (Electron)**
|
||||
|
||||
- `desktop-feature-implementation.mdc` - Main/renderer process patterns
|
||||
- `desktop-local-tools-implement.mdc` - Tool integration workflow
|
||||
- `desktop-menu-configuration.mdc` - App menu, context menu, tray menu
|
||||
- `desktop-window-management.mdc` - Window creation, state management, multi-window
|
||||
- `desktop-controller-tests.mdc` - Controller unit testing guide
|
||||
|
||||
+8
-65
@@ -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,37 +138,11 @@ ENV HOSTNAME="0.0.0.0" \
|
||||
|
||||
# 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=""
|
||||
PROXY_URL=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
@@ -207,7 +155,7 @@ ENV \
|
||||
# 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="" \
|
||||
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
|
||||
@@ -216,9 +164,6 @@ ENV \
|
||||
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
|
||||
# Cohere
|
||||
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
|
||||
# ComfyUI
|
||||
ENABLED_COMFYUI="" COMFYUI_BASE_URL="" COMFYUI_AUTH_TYPE="" \
|
||||
COMFYUI_API_KEY="" COMFYUI_USERNAME="" COMFYUI_PASSWORD="" COMFYUI_CUSTOM_HEADERS="" \
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
|
||||
# Fireworks AI
|
||||
@@ -260,7 +205,7 @@ ENV \
|
||||
# Ollama
|
||||
ENABLED_OLLAMA="" OLLAMA_MODEL_LIST="" OLLAMA_PROXY_URL="" \
|
||||
# OpenAI
|
||||
ENABLED_OPENAI="" OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY="" OPENROUTER_MODEL_LIST="" \
|
||||
# Perplexity
|
||||
@@ -310,13 +255,11 @@ ENV \
|
||||
# 302.AI
|
||||
AI302_API_KEY="" AI302_MODEL_LIST="" \
|
||||
# FAL
|
||||
ENABLED_FAL="" FAL_API_KEY="" FAL_MODEL_LIST="" \
|
||||
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=""
|
||||
VERCELAIGATEWAY_API_KEY="" VERCELAIGATEWAY_MODEL_LIST=""
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
## 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_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}" \
|
||||
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=""
|
||||
|
||||
# Database
|
||||
ENV KEY_VAULTS_SECRET="" \
|
||||
DATABASE_DRIVER="node" \
|
||||
DATABASE_URL=""
|
||||
|
||||
# Next Auth
|
||||
ENV NEXT_AUTH_SECRET="" \
|
||||
NEXT_AUTH_SSO_PROVIDERS="" \
|
||||
NEXTAUTH_URL=""
|
||||
|
||||
# S3
|
||||
ENV NEXT_PUBLIC_S3_DOMAIN="" \
|
||||
S3_PUBLIC_DOMAIN="" \
|
||||
S3_ACCESS_KEY_ID="" \
|
||||
S3_BUCKET="" \
|
||||
S3_ENDPOINT="" \
|
||||
S3_SECRET_ACCESS_KEY=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
# AI21
|
||||
AI21_API_KEY="" AI21_MODEL_LIST="" \
|
||||
# Ai360
|
||||
AI360_API_KEY="" AI360_MODEL_LIST="" \
|
||||
# AiHubMix
|
||||
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
# Amazon Bedrock
|
||||
AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
# Azure OpenAI
|
||||
AZURE_API_KEY="" AZURE_API_VERSION="" AZURE_ENDPOINT="" AZURE_MODEL_LIST="" \
|
||||
# Baichuan
|
||||
BAICHUAN_API_KEY="" BAICHUAN_MODEL_LIST="" \
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
|
||||
# Cohere
|
||||
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
|
||||
# Fireworks AI
|
||||
FIREWORKSAI_API_KEY="" FIREWORKSAI_MODEL_LIST="" \
|
||||
# Gitee AI
|
||||
GITEE_AI_API_KEY="" GITEE_AI_MODEL_LIST="" \
|
||||
# GitHub
|
||||
GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
|
||||
# Google
|
||||
GOOGLE_API_KEY="" GOOGLE_MODEL_LIST="" GOOGLE_PROXY_URL="" \
|
||||
# Groq
|
||||
GROQ_API_KEY="" GROQ_MODEL_LIST="" GROQ_PROXY_URL="" \
|
||||
# Higress
|
||||
HIGRESS_API_KEY="" HIGRESS_MODEL_LIST="" HIGRESS_PROXY_URL="" \
|
||||
# HuggingFace
|
||||
HUGGINGFACE_API_KEY="" HUGGINGFACE_MODEL_LIST="" HUGGINGFACE_PROXY_URL="" \
|
||||
# Hunyuan
|
||||
HUNYUAN_API_KEY="" HUNYUAN_MODEL_LIST="" \
|
||||
# InternLM
|
||||
INTERNLM_API_KEY="" INTERNLM_MODEL_LIST="" \
|
||||
# Jina
|
||||
JINA_API_KEY="" JINA_MODEL_LIST="" JINA_PROXY_URL="" \
|
||||
# Minimax
|
||||
MINIMAX_API_KEY="" MINIMAX_MODEL_LIST="" \
|
||||
# Mistral
|
||||
MISTRAL_API_KEY="" MISTRAL_MODEL_LIST="" \
|
||||
# ModelScope
|
||||
MODELSCOPE_API_KEY="" MODELSCOPE_MODEL_LIST="" MODELSCOPE_PROXY_URL="" \
|
||||
# Moonshot
|
||||
MOONSHOT_API_KEY="" MOONSHOT_MODEL_LIST="" MOONSHOT_PROXY_URL="" \
|
||||
# Nebius
|
||||
NEBIUS_API_KEY="" NEBIUS_MODEL_LIST="" NEBIUS_PROXY_URL="" \
|
||||
# NewAPI
|
||||
NEWAPI_API_KEY="" NEWAPI_PROXY_URL="" \
|
||||
# Novita
|
||||
NOVITA_API_KEY="" NOVITA_MODEL_LIST="" \
|
||||
# Nvidia NIM
|
||||
NVIDIA_API_KEY="" NVIDIA_MODEL_LIST="" NVIDIA_PROXY_URL="" \
|
||||
# Ollama
|
||||
ENABLED_OLLAMA="" OLLAMA_MODEL_LIST="" OLLAMA_PROXY_URL="" \
|
||||
# OpenAI
|
||||
OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY="" OPENROUTER_MODEL_LIST="" \
|
||||
# Perplexity
|
||||
PERPLEXITY_API_KEY="" PERPLEXITY_MODEL_LIST="" PERPLEXITY_PROXY_URL="" \
|
||||
# PPIO
|
||||
PPIO_API_KEY="" PPIO_MODEL_LIST="" \
|
||||
# Qiniu
|
||||
QINIU_API_KEY="" QINIU_MODEL_LIST="" QINIU_PROXY_URL="" \
|
||||
# Qwen
|
||||
QWEN_API_KEY="" QWEN_MODEL_LIST="" QWEN_PROXY_URL="" \
|
||||
# SambaNova
|
||||
SAMBANOVA_API_KEY="" SAMBANOVA_MODEL_LIST="" \
|
||||
# Search1API
|
||||
SEARCH1API_API_KEY="" SEARCH1API_MODEL_LIST="" \
|
||||
# SenseNova
|
||||
SENSENOVA_API_KEY="" SENSENOVA_MODEL_LIST="" \
|
||||
# SiliconCloud
|
||||
SILICONCLOUD_API_KEY="" SILICONCLOUD_MODEL_LIST="" SILICONCLOUD_PROXY_URL="" \
|
||||
# Spark
|
||||
SPARK_API_KEY="" SPARK_MODEL_LIST="" SPARK_PROXY_URL="" SPARK_SEARCH_MODE="" \
|
||||
# Stepfun
|
||||
STEPFUN_API_KEY="" STEPFUN_MODEL_LIST="" \
|
||||
# Taichu
|
||||
TAICHU_API_KEY="" TAICHU_MODEL_LIST="" \
|
||||
# TogetherAI
|
||||
TOGETHERAI_API_KEY="" TOGETHERAI_MODEL_LIST="" \
|
||||
# Upstage
|
||||
UPSTAGE_API_KEY="" UPSTAGE_MODEL_LIST="" \
|
||||
# v0 (Vercel)
|
||||
V0_API_KEY="" V0_MODEL_LIST="" \
|
||||
# vLLM
|
||||
VLLM_API_KEY="" VLLM_MODEL_LIST="" VLLM_PROXY_URL="" \
|
||||
# Wenxin
|
||||
WENXIN_API_KEY="" WENXIN_MODEL_LIST="" \
|
||||
# xAI
|
||||
XAI_API_KEY="" XAI_MODEL_LIST="" XAI_PROXY_URL="" \
|
||||
# Xinference
|
||||
XINFERENCE_API_KEY="" XINFERENCE_MODEL_LIST="" XINFERENCE_PROXY_URL="" \
|
||||
# 01.AI
|
||||
ZEROONE_API_KEY="" ZEROONE_MODEL_LIST="" \
|
||||
# Zhipu
|
||||
ZHIPU_API_KEY="" ZHIPU_MODEL_LIST="" \
|
||||
# Tencent Cloud
|
||||
TENCENT_CLOUD_API_KEY="" TENCENT_CLOUD_MODEL_LIST="" \
|
||||
# Infini-AI
|
||||
INFINIAI_API_KEY="" INFINIAI_MODEL_LIST="" \
|
||||
# 302.AI
|
||||
AI302_API_KEY="" AI302_MODEL_LIST="" \
|
||||
# FAL
|
||||
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"]
|
||||
@@ -0,0 +1,268 @@
|
||||
## 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=""
|
||||
|
||||
# Model Variables
|
||||
ENV \
|
||||
# AI21
|
||||
AI21_API_KEY="" AI21_MODEL_LIST="" \
|
||||
# Ai360
|
||||
AI360_API_KEY="" AI360_MODEL_LIST="" \
|
||||
# AiHubMix
|
||||
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
|
||||
# Amazon Bedrock
|
||||
AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
|
||||
# Azure OpenAI
|
||||
AZURE_API_KEY="" AZURE_API_VERSION="" AZURE_ENDPOINT="" AZURE_MODEL_LIST="" \
|
||||
# Baichuan
|
||||
BAICHUAN_API_KEY="" BAICHUAN_MODEL_LIST="" \
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
|
||||
# Cohere
|
||||
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
|
||||
# Fireworks AI
|
||||
FIREWORKSAI_API_KEY="" FIREWORKSAI_MODEL_LIST="" \
|
||||
# Gitee AI
|
||||
GITEE_AI_API_KEY="" GITEE_AI_MODEL_LIST="" \
|
||||
# GitHub
|
||||
GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
|
||||
# Google
|
||||
GOOGLE_API_KEY="" GOOGLE_MODEL_LIST="" GOOGLE_PROXY_URL="" \
|
||||
# Groq
|
||||
GROQ_API_KEY="" GROQ_MODEL_LIST="" GROQ_PROXY_URL="" \
|
||||
# Higress
|
||||
HIGRESS_API_KEY="" HIGRESS_MODEL_LIST="" HIGRESS_PROXY_URL="" \
|
||||
# HuggingFace
|
||||
HUGGINGFACE_API_KEY="" HUGGINGFACE_MODEL_LIST="" HUGGINGFACE_PROXY_URL="" \
|
||||
# Hunyuan
|
||||
HUNYUAN_API_KEY="" HUNYUAN_MODEL_LIST="" \
|
||||
# InternLM
|
||||
INTERNLM_API_KEY="" INTERNLM_MODEL_LIST="" \
|
||||
# Jina
|
||||
JINA_API_KEY="" JINA_MODEL_LIST="" JINA_PROXY_URL="" \
|
||||
# Minimax
|
||||
MINIMAX_API_KEY="" MINIMAX_MODEL_LIST="" \
|
||||
# Mistral
|
||||
MISTRAL_API_KEY="" MISTRAL_MODEL_LIST="" \
|
||||
# ModelScope
|
||||
MODELSCOPE_API_KEY="" MODELSCOPE_MODEL_LIST="" MODELSCOPE_PROXY_URL="" \
|
||||
# Moonshot
|
||||
MOONSHOT_API_KEY="" MOONSHOT_MODEL_LIST="" MOONSHOT_PROXY_URL="" \
|
||||
# Nebius
|
||||
NEBIUS_API_KEY="" NEBIUS_MODEL_LIST="" NEBIUS_PROXY_URL="" \
|
||||
# NewAPI
|
||||
NEWAPI_API_KEY="" NEWAPI_PROXY_URL="" \
|
||||
# Novita
|
||||
NOVITA_API_KEY="" NOVITA_MODEL_LIST="" \
|
||||
# Nvidia NIM
|
||||
NVIDIA_API_KEY="" NVIDIA_MODEL_LIST="" NVIDIA_PROXY_URL="" \
|
||||
# Ollama
|
||||
ENABLED_OLLAMA="" OLLAMA_MODEL_LIST="" OLLAMA_PROXY_URL="" \
|
||||
# OpenAI
|
||||
OPENAI_API_KEY="" OPENAI_MODEL_LIST="" OPENAI_PROXY_URL="" \
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY="" OPENROUTER_MODEL_LIST="" \
|
||||
# Perplexity
|
||||
PERPLEXITY_API_KEY="" PERPLEXITY_MODEL_LIST="" PERPLEXITY_PROXY_URL="" \
|
||||
# Qiniu
|
||||
QINIU_API_KEY="" QINIU_MODEL_LIST="" QINIU_PROXY_URL="" \
|
||||
# Qwen
|
||||
QWEN_API_KEY="" QWEN_MODEL_LIST="" QWEN_PROXY_URL="" \
|
||||
# SambaNova
|
||||
SAMBANOVA_API_KEY="" SAMBANOVA_MODEL_LIST="" \
|
||||
# SenseNova
|
||||
SENSENOVA_API_KEY="" SENSENOVA_MODEL_LIST="" \
|
||||
# SiliconCloud
|
||||
SILICONCLOUD_API_KEY="" SILICONCLOUD_MODEL_LIST="" SILICONCLOUD_PROXY_URL="" \
|
||||
# Spark
|
||||
SPARK_API_KEY="" SPARK_MODEL_LIST="" SPARK_PROXY_URL="" SPARK_SEARCH_MODE="" \
|
||||
# Stepfun
|
||||
STEPFUN_API_KEY="" STEPFUN_MODEL_LIST="" \
|
||||
# Taichu
|
||||
TAICHU_API_KEY="" TAICHU_MODEL_LIST="" \
|
||||
# TogetherAI
|
||||
TOGETHERAI_API_KEY="" TOGETHERAI_MODEL_LIST="" \
|
||||
# Upstage
|
||||
UPSTAGE_API_KEY="" UPSTAGE_MODEL_LIST="" \
|
||||
# v0 (Vercel)
|
||||
V0_API_KEY="" V0_MODEL_LIST="" \
|
||||
# vLLM
|
||||
VLLM_API_KEY="" VLLM_MODEL_LIST="" VLLM_PROXY_URL="" \
|
||||
# Wenxin
|
||||
WENXIN_API_KEY="" WENXIN_MODEL_LIST="" \
|
||||
# xAI
|
||||
XAI_API_KEY="" XAI_MODEL_LIST="" XAI_PROXY_URL="" \
|
||||
# Xinference
|
||||
XINFERENCE_API_KEY="" XINFERENCE_MODEL_LIST="" XINFERENCE_PROXY_URL="" \
|
||||
# 01.AI
|
||||
ZEROONE_API_KEY="" ZEROONE_MODEL_LIST="" \
|
||||
# Zhipu
|
||||
ZHIPU_API_KEY="" ZHIPU_MODEL_LIST="" \
|
||||
# Tencent Cloud
|
||||
TENCENT_CLOUD_API_KEY="" TENCENT_CLOUD_MODEL_LIST="" \
|
||||
# Infini-AI
|
||||
INFINIAI_API_KEY="" INFINIAI_MODEL_LIST="" \
|
||||
# 302.AI
|
||||
AI302_API_KEY="" AI302_MODEL_LIST="" \
|
||||
# FAL
|
||||
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,55 @@ 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 (+32)</kbd></summary>
|
||||
|
||||
- **[GitHub](https://lobechat.com/discover/provider/github)**: With GitHub Models, developers can become AI engineers and leverage the industry's leading AI models.
|
||||
- **[Novita](https://lobechat.com/discover/provider/novita)**: Novita AI is a platform providing a variety of large language models and AI image generation API services, flexible, reliable, and cost-effective. It supports the latest open-source models like Llama3 and Mistral, offering a comprehensive, user-friendly, and auto-scaling API solution for generative AI application development, suitable for the rapid growth of AI startups.
|
||||
- **[PPIO](https://lobechat.com/discover/provider/ppio)**: PPIO supports stable and cost-efficient open-source LLM APIs, such as DeepSeek, Llama, Qwen etc.
|
||||
- **[302.AI](https://lobechat.com/discover/provider/ai302)**: 302.AI is an on-demand AI application platform offering the most comprehensive AI APIs and online AI applications available on the market.
|
||||
- **[Together AI](https://lobechat.com/discover/provider/togetherai)**: Together AI is dedicated to achieving leading performance through innovative AI models, offering extensive customization capabilities, including rapid scaling support and intuitive deployment processes to meet various enterprise needs.
|
||||
- **[Fireworks AI](https://lobechat.com/discover/provider/fireworksai)**: Fireworks AI is a leading provider of advanced language model services, focusing on functional calling and multimodal processing. Its latest model, Firefunction V2, is based on Llama-3, optimized for function calling, conversation, and instruction following. The visual language model FireLLaVA-13B supports mixed input of images and text. Other notable models include the Llama series and Mixtral series, providing efficient multilingual instruction following and generation support.
|
||||
- **[Groq](https://lobechat.com/discover/provider/groq)**: Groq's LPU inference engine has excelled in the latest independent large language model (LLM) benchmarks, redefining the standards for AI solutions with its remarkable speed and efficiency. Groq represents instant inference speed, demonstrating strong performance in cloud-based deployments.
|
||||
- **[Perplexity](https://lobechat.com/discover/provider/perplexity)**: Perplexity is a leading provider of conversational generation models, offering various advanced Llama 3.1 models that support both online and offline applications, particularly suited for complex natural language processing tasks.
|
||||
- **[Mistral](https://lobechat.com/discover/provider/mistral)**: Mistral provides advanced general, specialized, and research models widely used in complex reasoning, multilingual tasks, and code generation. Through functional calling interfaces, users can integrate custom functionalities for specific applications.
|
||||
- **[ModelScope](https://lobechat.com/discover/provider/modelscope)**: ModelScope is a model-as-a-service platform launched by Alibaba Cloud, offering a wide range of AI models and inference services.
|
||||
- **[Ai21Labs](https://lobechat.com/discover/provider/ai21)**: AI21 Labs builds foundational models and AI systems for enterprises, accelerating the application of generative AI in production.
|
||||
- **[Upstage](https://lobechat.com/discover/provider/upstage)**: Upstage focuses on developing AI models for various business needs, including Solar LLM and document AI, aiming to achieve artificial general intelligence (AGI) for work. It allows for the creation of simple conversational agents through Chat API and supports functional calling, translation, embedding, and domain-specific applications.
|
||||
- **[xAI (Grok)](https://lobechat.com/discover/provider/xai)**: xAI is a company dedicated to building artificial intelligence to accelerate human scientific discovery. Our mission is to advance our collective understanding of the universe.
|
||||
- **[Aliyun Bailian](https://lobechat.com/discover/provider/qwen)**: Tongyi Qianwen is a large-scale language model independently developed by Alibaba Cloud, featuring strong natural language understanding and generation capabilities. It can answer various questions, create written content, express opinions, and write code, playing a role in multiple fields.
|
||||
- **[Wenxin](https://lobechat.com/discover/provider/wenxin)**: An enterprise-level one-stop platform for large model and AI-native application development and services, providing the most comprehensive and user-friendly toolchain for the entire process of generative artificial intelligence model development and application development.
|
||||
- **[Hunyuan](https://lobechat.com/discover/provider/hunyuan)**: A large language model developed by Tencent, equipped with powerful Chinese creative capabilities, logical reasoning abilities in complex contexts, and reliable task execution skills.
|
||||
- **[ZhiPu](https://lobechat.com/discover/provider/zhipu)**: Zhipu AI offers an open platform for multimodal and language models, supporting a wide range of AI application scenarios, including text processing, image understanding, and programming assistance.
|
||||
- **[SiliconCloud](https://lobechat.com/discover/provider/siliconcloud)**: SiliconFlow is dedicated to accelerating AGI for the benefit of humanity, enhancing large-scale AI efficiency through an easy-to-use and cost-effective GenAI stack.
|
||||
- **[01.AI](https://lobechat.com/discover/provider/zeroone)**: 01.AI focuses on AI 2.0 era technologies, vigorously promoting the innovation and application of 'human + artificial intelligence', using powerful models and advanced AI technologies to enhance human productivity and achieve technological empowerment.
|
||||
- **[Spark](https://lobechat.com/discover/provider/spark)**: iFlytek's Spark model provides powerful AI capabilities across multiple domains and languages, utilizing advanced natural language processing technology to build innovative applications suitable for smart hardware, smart healthcare, smart finance, and other vertical scenarios.
|
||||
- **[SenseNova](https://lobechat.com/discover/provider/sensenova)**: SenseNova, backed by SenseTime's robust infrastructure, offers efficient and user-friendly full-stack large model services.
|
||||
- **[Stepfun](https://lobechat.com/discover/provider/stepfun)**: StepFun's large model possesses industry-leading multimodal and complex reasoning capabilities, supporting ultra-long text understanding and powerful autonomous scheduling search engine functions.
|
||||
- **[Baichuan](https://lobechat.com/discover/provider/baichuan)**: Baichuan Intelligence is a company focused on the research and development of large AI models, with its models excelling in domestic knowledge encyclopedias, long text processing, and generative creation tasks in Chinese, surpassing mainstream foreign models. Baichuan Intelligence also possesses industry-leading multimodal capabilities, performing excellently in multiple authoritative evaluations. Its models include Baichuan 4, Baichuan 3 Turbo, and Baichuan 3 Turbo 128k, each optimized for different application scenarios, providing cost-effective solutions.
|
||||
- **[Minimax](https://lobechat.com/discover/provider/minimax)**: MiniMax is a general artificial intelligence technology company established in 2021, dedicated to co-creating intelligence with users. MiniMax has independently developed general large models of different modalities, including trillion-parameter MoE text models, voice models, and image models, and has launched applications such as Conch AI.
|
||||
- **[InternLM](https://lobechat.com/discover/provider/internlm)**: An open-source organization dedicated to the research and development of large model toolchains. It provides an efficient and user-friendly open-source platform for all AI developers, making cutting-edge large models and algorithm technologies easily accessible.
|
||||
- **[Higress](https://lobechat.com/discover/provider/higress)**: Higress is a cloud-native API gateway that was developed internally at Alibaba to address the issues of Tengine reload affecting long-lived connections and the insufficient load balancing capabilities for gRPC/Dubbo.
|
||||
- **[Gitee AI](https://lobechat.com/discover/provider/giteeai)**: Gitee AI's Serverless API provides AI developers with an out of the box large model inference API service.
|
||||
- **[Taichu](https://lobechat.com/discover/provider/taichu)**: The Institute of Automation, Chinese Academy of Sciences, and Wuhan Artificial Intelligence Research Institute have launched a new generation of multimodal large models, supporting comprehensive question-answering tasks such as multi-turn Q\&A, text creation, image generation, 3D understanding, and signal analysis, with stronger cognitive, understanding, and creative abilities, providing a new interactive experience.
|
||||
- **[360 AI](https://lobechat.com/discover/provider/ai360)**: 360 AI is an AI model and service platform launched by 360 Company, offering various advanced natural language processing models, including 360GPT2 Pro, 360GPT Pro, 360GPT Turbo, and 360GPT Turbo Responsibility 8K. These models combine large-scale parameters and multimodal capabilities, widely applied in text generation, semantic understanding, dialogue systems, and code generation. With flexible pricing strategies, 360 AI meets diverse user needs, supports developer integration, and promotes the innovation and development of intelligent applications.
|
||||
- **[Search1API](https://lobechat.com/discover/provider/search1api)**: Search1API provides access to the DeepSeek series of models that can connect to the internet as needed, including standard and fast versions, supporting a variety of model sizes.
|
||||
- **[InfiniAI](https://lobechat.com/discover/provider/infiniai)**: Provides high-performance, easy-to-use, and secure large model services for application developers, covering the entire process from large model development to service deployment.
|
||||
- **[Qiniu](https://lobechat.com/discover/provider/qiniu)**: Qiniu, as a long-established cloud service provider, delivers cost-effective and reliable AI inference services for both real-time and batch processing, with a simple and user-friendly experience.
|
||||
|
||||
</details>
|
||||
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
> 📊 Total providers: [<kbd>**42**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
@@ -345,14 +382,14 @@ 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 |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Web](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | Smart web search that reads and analyzes pages to deliver comprehensive answers from Google results.<br/>`web` `search` |
|
||||
| [Bing_websearch](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | Search for information from the internet base BingApi<br/>`bingsearch` |
|
||||
| [Google CSE](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | Searches Google through their official CSE API.<br/>`web` `search` |
|
||||
| [Tongyi wanxiang Image Generator](https://lobechat.com/discover/plugin/alps-tongyi-image)<br/><sup>By **YoungTx** on **2024-08-09**</sup> | This plugin uses Alibaba's Tongyi Wanxiang model to generate images based on text prompts.<br/>`image` `tongyi` `wanxiang` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
@@ -865,7 +902,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/lobehub/lobe-chat?color=369eff&labelColor=black&logo=github&style=flat-square
|
||||
[github-releasedate-link]: https://github.com/lobehub/lobe-chat/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobe-chat?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/stargazers
|
||||
[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
|
||||
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
|
||||
[github-trending-url]: https://trendshift.io/repositories/2256
|
||||
|
||||
+54
-17
@@ -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,55 @@ 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 (+32)</kbd></summary>
|
||||
|
||||
- **[GitHub](https://lobechat.com/discover/provider/github)**: 通过 GitHub 模型,开发人员可以成为 AI 工程师,并使用行业领先的 AI 模型进行构建。
|
||||
- **[Novita](https://lobechat.com/discover/provider/novita)**: Novita AI 是一个提供多种大语言模型与 AI 图像生成的 API 服务的平台,灵活、可靠且具有成本效益。它支持 Llama3、Mistral 等最新的开源模型,并为生成式 AI 应用开发提供了全面、用户友好且自动扩展的 API 解决方案,适合 AI 初创公司的快速发展。
|
||||
- **[PPIO](https://lobechat.com/discover/provider/ppio)**: PPIO 派欧云提供稳定、高性价比的开源模型 API 服务,支持 DeepSeek 全系列、Llama、Qwen 等行业领先大模型。
|
||||
- **[302.AI](https://lobechat.com/discover/provider/ai302)**: 302.AI 是一个按需付费的 AI 应用平台,提供市面上最全的 AI API 和 AI 在线应用
|
||||
- **[Together AI](https://lobechat.com/discover/provider/togetherai)**: Together AI 致力于通过创新的 AI 模型实现领先的性能,提供广泛的自定义能力,包括快速扩展支持和直观的部署流程,满足企业的各种需求。
|
||||
- **[Fireworks AI](https://lobechat.com/discover/provider/fireworksai)**: Fireworks AI 是一家领先的高级语言模型服务商,专注于功能调用和多模态处理。其最新模型 Firefunction V2 基于 Llama-3,优化用于函数调用、对话及指令跟随。视觉语言模型 FireLLaVA-13B 支持图像和文本混合输入。其他 notable 模型包括 Llama 系列和 Mixtral 系列,提供高效的多语言指令跟随与生成支持。
|
||||
- **[Groq](https://lobechat.com/discover/provider/groq)**: Groq 的 LPU 推理引擎在最新的独立大语言模型(LLM)基准测试中表现卓越,以其惊人的速度和效率重新定义了 AI 解决方案的标准。Groq 是一种即时推理速度的代表,在基于云的部署中展现了良好的性能。
|
||||
- **[Perplexity](https://lobechat.com/discover/provider/perplexity)**: Perplexity 是一家领先的对话生成模型提供商,提供多种先进的 Llama 3.1 模型,支持在线和离线应用,特别适用于复杂的自然语言处理任务。
|
||||
- **[Mistral](https://lobechat.com/discover/provider/mistral)**: Mistral 提供先进的通用、专业和研究型模型,广泛应用于复杂推理、多语言任务、代码生成等领域,通过功能调用接口,用户可以集成自定义功能,实现特定应用。
|
||||
- **[ModelScope](https://lobechat.com/discover/provider/modelscope)**: ModelScope 是阿里云推出的模型即服务平台,提供丰富的 AI 模型和推理服务。
|
||||
- **[Ai21Labs](https://lobechat.com/discover/provider/ai21)**: AI21 Labs 为企业构建基础模型和人工智能系统,加速生成性人工智能在生产中的应用。
|
||||
- **[Upstage](https://lobechat.com/discover/provider/upstage)**: Upstage 专注于为各种商业需求开发 AI 模型,包括 Solar LLM 和文档 AI,旨在实现工作的人造通用智能(AGI)。通过 Chat API 创建简单的对话代理,并支持功能调用、翻译、嵌入以及特定领域应用。
|
||||
- **[xAI (Grok)](https://lobechat.com/discover/provider/xai)**: xAI 是一家致力于构建人工智能以加速人类科学发现的公司。我们的使命是推动我们对宇宙的共同理解。
|
||||
- **[Aliyun Bailian](https://lobechat.com/discover/provider/qwen)**: 通义千问是阿里云自主研发的超大规模语言模型,具有强大的自然语言理解和生成能力。它可以回答各种问题、创作文字内容、表达观点看法、撰写代码等,在多个领域发挥作用。
|
||||
- **[Wenxin](https://lobechat.com/discover/provider/wenxin)**: 企业级一站式大模型与 AI 原生应用开发及服务平台,提供最全面易用的生成式人工智能模型开发、应用开发全流程工具链
|
||||
- **[Hunyuan](https://lobechat.com/discover/provider/hunyuan)**: 由腾讯研发的大语言模型,具备强大的中文创作能力,复杂语境下的逻辑推理能力,以及可靠的任务执行能力
|
||||
- **[ZhiPu](https://lobechat.com/discover/provider/zhipu)**: 智谱 AI 提供多模态与语言模型的开放平台,支持广泛的 AI 应用场景,包括文本处理、图像理解与编程辅助等。
|
||||
- **[SiliconCloud](https://lobechat.com/discover/provider/siliconcloud)**: SiliconCloud,基于优秀开源基础模型的高性价比 GenAI 云服务
|
||||
- **[01.AI](https://lobechat.com/discover/provider/zeroone)**: 零一万物致力于推动以人为本的 AI 2.0 技术革命,旨在通过大语言模型创造巨大的经济和社会价值,并开创新的 AI 生态与商业模式。
|
||||
- **[Spark](https://lobechat.com/discover/provider/spark)**: 科大讯飞星火大模型提供多领域、多语言的强大 AI 能力,利用先进的自然语言处理技术,构建适用于智能硬件、智慧医疗、智慧金融等多种垂直场景的创新应用。
|
||||
- **[SenseNova](https://lobechat.com/discover/provider/sensenova)**: 商汤日日新,依托商汤大装置的强大的基础支撑,提供高效易用的全栈大模型服务。
|
||||
- **[Stepfun](https://lobechat.com/discover/provider/stepfun)**: 阶级星辰大模型具备行业领先的多模态及复杂推理能力,支持超长文本理解和强大的自主调度搜索引擎功能。
|
||||
- **[Baichuan](https://lobechat.com/discover/provider/baichuan)**: 百川智能是一家专注于人工智能大模型研发的公司,其模型在国内知识百科、长文本处理和生成创作等中文任务上表现卓越,超越了国外主流模型。百川智能还具备行业领先的多模态能力,在多项权威评测中表现优异。其模型包括 Baichuan 4、Baichuan 3 Turbo 和 Baichuan 3 Turbo 128k 等,分别针对不同应用场景进行优化,提供高性价比的解决方案。
|
||||
- **[Minimax](https://lobechat.com/discover/provider/minimax)**: MiniMax 是 2021 年成立的通用人工智能科技公司,致力于与用户共创智能。MiniMax 自主研发了不同模态的通用大模型,其中包括万亿参数的 MoE 文本大模型、语音大模型以及图像大模型。并推出了海螺 AI 等应用。
|
||||
- **[InternLM](https://lobechat.com/discover/provider/internlm)**: 致力于大模型研究与开发工具链的开源组织。为所有 AI 开发者提供高效、易用的开源平台,让最前沿的大模型与算法技术触手可及
|
||||
- **[Higress](https://lobechat.com/discover/provider/higress)**: Higress 是一款云原生 API 网关,在阿里内部为解决 Tengine reload 对长连接业务有损,以及 gRPC/Dubbo 负载均衡能力不足而诞生。
|
||||
- **[Gitee AI](https://lobechat.com/discover/provider/giteeai)**: Gitee AI 的 Serverless API 为 AI 开发者提供开箱即用的大模型推理 API 服务。
|
||||
- **[Taichu](https://lobechat.com/discover/provider/taichu)**: 中科院自动化研究所和武汉人工智能研究院推出新一代多模态大模型,支持多轮问答、文本创作、图像生成、3D 理解、信号分析等全面问答任务,拥有更强的认知、理解、创作能力,带来全新互动体验。
|
||||
- **[360 AI](https://lobechat.com/discover/provider/ai360)**: 360 AI 是 360 公司推出的 AI 模型和服务平台,提供多种先进的自然语言处理模型,包括 360GPT2 Pro、360GPT Pro、360GPT Turbo 和 360GPT Turbo Responsibility 8K。这些模型结合了大规模参数和多模态能力,广泛应用于文本生成、语义理解、对话系统与代码生成等领域。通过灵活的定价策略,360 AI 满足多样化用户需求,支持开发者集成,推动智能化应用的革新和发展。
|
||||
- **[Search1API](https://lobechat.com/discover/provider/search1api)**: Search1API 提供可根据需要自行联网的 DeepSeek 系列模型的访问,包括标准版和快速版本,支持多种参数规模的模型选择。
|
||||
- **[InfiniAI](https://lobechat.com/discover/provider/infiniai)**: 为应用开发者提供高性能、易上手、安全可靠的大模型服务,覆盖从大模型开发到大模型服务化部署的全流程。
|
||||
- **[Qiniu](https://lobechat.com/discover/provider/qiniu)**: 七牛作为老牌云服务厂商,提供高性价比稳定的实时、批量 AI 推理服务,简单易用。
|
||||
|
||||
</details>
|
||||
|
||||
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
|
||||
> 📊 Total providers: [<kbd>**42**</kbd>](https://lobechat.com/discover/providers)
|
||||
|
||||
<!-- PROVIDER LIST -->
|
||||
|
||||
@@ -338,14 +375,14 @@ 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/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
|
||||
| [必应网页搜索](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | 通过 BingApi 搜索互联网上的信息<br/>`bingsearch` |
|
||||
| [谷歌自定义搜索引擎](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | 通过他们的官方自定义搜索引擎 API 搜索谷歌。<br/>`网络` `搜索` |
|
||||
| [通义万象图像生成器](https://lobechat.com/discover/plugin/alps-tongyi-image)<br/><sup>By **YoungTx** on **2024-08-09**</sup> | 此插件使用阿里巴巴的通义万象模型根据文本提示生成图像。<br/>`图像` `通义` `万象` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
> 📊 Total plugins: [<kbd>**41**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
@@ -886,7 +923,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[github-release-shield]: https://img.shields.io/github/v/release/lobehub/lobe-chat?color=369eff&labelColor=black&logo=github&style=flat-square
|
||||
[github-releasedate-link]: https://github.com/lobehub/lobe-chat/releases
|
||||
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobe-chat?labelColor=black&style=flat-square
|
||||
[github-stars-link]: https://github.com/lobehub/lobe-chat/stargazers
|
||||
[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
|
||||
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
|
||||
[github-trending-url]: https://trendshift.io/repositories/2256
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,5 @@
|
||||
const dotenv = require('dotenv');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -34,50 +32,11 @@ const getProtocolScheme = () => {
|
||||
|
||||
const protocolScheme = getProtocolScheme();
|
||||
|
||||
// Determine icon file based on version type
|
||||
const getIconFileName = () => {
|
||||
if (isNightly) return 'Icon-nightly';
|
||||
if (isBeta) return 'Icon-beta';
|
||||
return 'Icon';
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
* @see https://www.electron.build/configuration
|
||||
*/
|
||||
const config = {
|
||||
/**
|
||||
* AfterPack hook to copy pre-generated Liquid Glass Assets.car for macOS 26+
|
||||
* @see https://github.com/electron-userland/electron-builder/issues/9254
|
||||
* @see https://github.com/MultiboxLabs/flow-browser/pull/159
|
||||
* @see https://github.com/electron/packager/pull/1806
|
||||
*/
|
||||
afterPack: async (context) => {
|
||||
// Only process macOS builds
|
||||
if (context.electronPlatformName !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconFileName = getIconFileName();
|
||||
const assetsCarSource = path.join(__dirname, 'build', `${iconFileName}.Assets.car`);
|
||||
const resourcesPath = path.join(
|
||||
context.appOutDir,
|
||||
`${context.packager.appInfo.productFilename}.app`,
|
||||
'Contents',
|
||||
'Resources',
|
||||
);
|
||||
const assetsCarDest = path.join(resourcesPath, 'Assets.car');
|
||||
|
||||
try {
|
||||
await fs.access(assetsCarSource);
|
||||
await fs.copyFile(assetsCarSource, assetsCarDest);
|
||||
console.log(`✅ Copied Liquid Glass icon: ${iconFileName}.Assets.car`);
|
||||
} catch {
|
||||
// Non-critical: Assets.car not found or copy failed
|
||||
// App will use fallback .icns icon on all macOS versions
|
||||
console.log(`⏭️ Skipping Assets.car (not found or copy failed)`);
|
||||
}
|
||||
},
|
||||
appId: isNightly
|
||||
? 'com.lobehub.lobehub-desktop-nightly'
|
||||
: isBeta
|
||||
@@ -122,7 +81,6 @@ const config = {
|
||||
compression: 'maximum',
|
||||
entitlementsInherit: 'build/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
CFBundleIconName: 'AppIcon',
|
||||
CFBundleURLTypes: [
|
||||
{
|
||||
CFBundleURLName: 'LobeHub Protocol',
|
||||
|
||||
+19
-20
@@ -32,47 +32,46 @@
|
||||
"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/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@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",
|
||||
"fast-glob": "^3.3.3",
|
||||
"electron-vite": "^3.0.0",
|
||||
"execa": "^9.5.2",
|
||||
"fix-path": "^5.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"just-diff": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"resolve": "^1.22.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": {
|
||||
|
||||
@@ -46,55 +46,4 @@ export const appBrowsers = {
|
||||
},
|
||||
} satisfies Record<string, BrowserWindowOpts>;
|
||||
|
||||
// Window templates for multi-instance windows
|
||||
export interface WindowTemplate {
|
||||
allowMultipleInstances: boolean;
|
||||
// Include common BrowserWindow options
|
||||
autoHideMenuBar?: boolean;
|
||||
baseIdentifier: string;
|
||||
basePath: string;
|
||||
devTools?: boolean;
|
||||
height?: number;
|
||||
keepAlive?: boolean;
|
||||
minWidth?: number;
|
||||
parentIdentifier?: string;
|
||||
showOnInit?: boolean;
|
||||
title?: string;
|
||||
titleBarStyle?: 'hidden' | 'default' | 'hiddenInset' | 'customButtonsOnHover';
|
||||
vibrancy?:
|
||||
| 'appearance-based'
|
||||
| 'content'
|
||||
| 'fullscreen-ui'
|
||||
| 'header'
|
||||
| 'hud'
|
||||
| 'menu'
|
||||
| 'popover'
|
||||
| 'selection'
|
||||
| 'sheet'
|
||||
| 'sidebar'
|
||||
| 'titlebar'
|
||||
| 'tooltip'
|
||||
| 'under-page'
|
||||
| 'under-window'
|
||||
| 'window';
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const windowTemplates = {
|
||||
chatSingle: {
|
||||
allowMultipleInstances: true,
|
||||
autoHideMenuBar: true,
|
||||
baseIdentifier: 'chatSingle',
|
||||
basePath: '/chat',
|
||||
height: 600,
|
||||
keepAlive: false, // Multi-instance windows don't need to stay alive
|
||||
minWidth: 400,
|
||||
parentIdentifier: 'chat',
|
||||
titleBarStyle: 'hidden',
|
||||
vibrancy: 'under-window',
|
||||
width: 900,
|
||||
},
|
||||
} satisfies Record<string, WindowTemplate>;
|
||||
|
||||
export type AppBrowsersIdentifiers = keyof typeof appBrowsers;
|
||||
export type WindowTemplateIdentifiers = keyof typeof windowTemplates;
|
||||
|
||||
@@ -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()}`,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
|
||||
import { extractSubPath, findMatchingRoute } from '~common/routes';
|
||||
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
BrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
} from '@/appBrowsers';
|
||||
import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, shortcut } from './index';
|
||||
@@ -18,16 +14,11 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@ipcClientEvent('openSettingsWindow')
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions: OpenSettingsWindowOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
? { tab: typeof options === 'string' ? options : undefined }
|
||||
: options;
|
||||
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings window', normalizedOptions);
|
||||
async openSettingsWindow(tab?: string) {
|
||||
console.log('[BrowserWindowsCtr] Received request to open settings window', tab);
|
||||
|
||||
try {
|
||||
await this.app.browserManager.showSettingsWindowWithTab(normalizedOptions);
|
||||
await this.app.browserManager.showSettingsWindowWithTab(tab);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -77,37 +68,15 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
|
||||
try {
|
||||
if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) {
|
||||
const extractedSubPath = extractSubPath(path, matchedRoute.pathPrefix);
|
||||
const sanitizedSubPath =
|
||||
extractedSubPath && !extractedSubPath.startsWith('?') ? extractedSubPath : undefined;
|
||||
let searchParams: Record<string, string> | undefined;
|
||||
try {
|
||||
const url = new URL(params.url);
|
||||
const entries = Array.from(url.searchParams.entries());
|
||||
if (entries.length > 0) {
|
||||
searchParams = entries.reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[BrowserWindowsCtr] Failed to parse URL for settings route interception:',
|
||||
params.url,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const subPath = extractSubPath(path, matchedRoute.pathPrefix);
|
||||
|
||||
await this.app.browserManager.showSettingsWindowWithTab({
|
||||
searchParams,
|
||||
tab: sanitizedSubPath,
|
||||
});
|
||||
await this.app.browserManager.showSettingsWindowWithTab(subPath);
|
||||
|
||||
return {
|
||||
intercepted: true,
|
||||
path,
|
||||
source,
|
||||
subPath: sanitizedSubPath,
|
||||
subPath,
|
||||
targetWindow: matchedRoute.targetWindow,
|
||||
};
|
||||
} else {
|
||||
@@ -131,77 +100,6 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new multi-instance window
|
||||
*/
|
||||
@ipcClientEvent('createMultiInstanceWindow')
|
||||
async createMultiInstanceWindow(params: {
|
||||
path: string;
|
||||
templateId: WindowTemplateIdentifiers;
|
||||
uniqueId?: string;
|
||||
}) {
|
||||
try {
|
||||
console.log('[BrowserWindowsCtr] Creating multi-instance window:', params);
|
||||
|
||||
const result = this.app.browserManager.createMultiInstanceWindow(
|
||||
params.templateId,
|
||||
params.path,
|
||||
params.uniqueId,
|
||||
);
|
||||
|
||||
// Show the window
|
||||
result.browser.show();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
windowId: result.identifier,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BrowserWindowsCtr] Failed to create multi-instance window:', error);
|
||||
return {
|
||||
error: error.message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all windows by template
|
||||
*/
|
||||
@ipcClientEvent('getWindowsByTemplate')
|
||||
async getWindowsByTemplate(templateId: string) {
|
||||
try {
|
||||
const windowIds = this.app.browserManager.getWindowsByTemplate(templateId);
|
||||
return {
|
||||
success: true,
|
||||
windowIds,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BrowserWindowsCtr] Failed to get windows by template:', error);
|
||||
return {
|
||||
error: error.message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all windows by template
|
||||
*/
|
||||
@ipcClientEvent('closeWindowsByTemplate')
|
||||
async closeWindowsByTemplate(templateId: string) {
|
||||
try {
|
||||
this.app.browserManager.closeWindowsByTemplate(templateId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[BrowserWindowsCtr] Failed to close windows by template:', error);
|
||||
return {
|
||||
error: error.message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open target window and navigate to specified sub-path
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
EditLocalFileParams,
|
||||
EditLocalFileResult,
|
||||
GlobFilesParams,
|
||||
GlobFilesResult,
|
||||
GrepContentParams,
|
||||
GrepContentResult,
|
||||
ListLocalFileParams,
|
||||
LocalMoveFilesResultItem,
|
||||
LocalReadFileParams,
|
||||
@@ -19,10 +13,10 @@ import {
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
|
||||
import { shell } from 'electron';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats, constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
||||
import * as fs from 'node:fs';
|
||||
import { rename as renamePromise } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
@@ -31,15 +25,40 @@ import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
// Create logger
|
||||
// 创建日志记录器
|
||||
const logger = createLogger('controllers:LocalFileCtr');
|
||||
|
||||
const statPromise = promisify(fs.stat);
|
||||
const readdirPromise = promisify(fs.readdir);
|
||||
const renamePromiseFs = promisify(fs.rename);
|
||||
const accessPromise = promisify(fs.access);
|
||||
const writeFilePromise = promisify(fs.writeFile);
|
||||
|
||||
export default class LocalFileCtr extends ControllerModule {
|
||||
private get searchService() {
|
||||
return this.app.getService(FileSearchService);
|
||||
}
|
||||
|
||||
// ==================== File Operation ====================
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', { keywords: params.keywords });
|
||||
|
||||
const options: Omit<SearchOptions, 'keywords'> = {
|
||||
limit: 30,
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await this.searchService.search(params.keywords, options);
|
||||
logger.debug('File search completed', { count: results.length });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('openLocalFile')
|
||||
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
|
||||
@@ -83,7 +102,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
const results: LocalReadFileResult[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
// Initialize result object
|
||||
// 初始化结果对象
|
||||
logger.debug('Reading single file:', { filePath });
|
||||
const result = await this.readFile({ path: filePath });
|
||||
results.push(result);
|
||||
@@ -139,7 +158,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
const stats = await statPromise(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
logger.warn('Attempted to read directory content:', { filePath });
|
||||
result.content = 'This is a directory and cannot be read as plain text.';
|
||||
@@ -178,7 +197,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
const results: FileResult[] = [];
|
||||
try {
|
||||
const entries = await readdir(dirPath);
|
||||
const entries = await readdirPromise(dirPath);
|
||||
logger.debug('Directory entries retrieved successfully:', {
|
||||
dirPath,
|
||||
entriesCount: entries.length,
|
||||
@@ -193,7 +212,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
const fullPath = path.join(dirPath, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
const stats = await statPromise(fullPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
results.push({
|
||||
createdTime: stats.birthtime,
|
||||
@@ -241,7 +260,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process each move request
|
||||
// 逐个处理移动请求
|
||||
for (const item of items) {
|
||||
const { oldPath: sourcePath, newPath } = item;
|
||||
const logPrefix = `[Moving file ${sourcePath} -> ${newPath}]`;
|
||||
@@ -253,7 +272,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
success: false,
|
||||
};
|
||||
|
||||
// Basic validation
|
||||
// 基本验证
|
||||
if (!sourcePath || !newPath) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: source or target path is empty`);
|
||||
resultItem.error = 'Both oldPath and newPath are required for each item.';
|
||||
@@ -262,9 +281,9 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if source exists
|
||||
// 检查源是否存在
|
||||
try {
|
||||
await access(sourcePath, constants.F_OK);
|
||||
await accessPromise(sourcePath, fs.constants.F_OK);
|
||||
logger.debug(`${logPrefix} Source file exists`);
|
||||
} catch (accessError: any) {
|
||||
if (accessError.code === 'ENOENT') {
|
||||
@@ -278,28 +297,28 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target path is the same as source path
|
||||
// 检查目标路径是否与源路径相同
|
||||
if (path.normalize(sourcePath) === path.normalize(newPath)) {
|
||||
logger.info(`${logPrefix} Source and target paths are identical, skipping move`);
|
||||
resultItem.success = true;
|
||||
resultItem.newPath = newPath; // Report target path even if not moved
|
||||
resultItem.newPath = newPath; // 即使未移动,也报告目标路径
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// LBYL: Ensure target directory exists
|
||||
// LBYL: 确保目标目录存在
|
||||
const targetDir = path.dirname(newPath);
|
||||
makeSureDirExist(targetDir);
|
||||
logger.debug(`${logPrefix} Ensured target directory exists: ${targetDir}`);
|
||||
|
||||
// Execute move (rename)
|
||||
await rename(sourcePath, newPath);
|
||||
// 执行移动 (rename)
|
||||
await renamePromiseFs(sourcePath, newPath);
|
||||
resultItem.success = true;
|
||||
resultItem.newPath = newPath;
|
||||
logger.info(`${logPrefix} Move successful`);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Move failed:`, error);
|
||||
// Use similar error handling logic as handleMoveFile
|
||||
// 使用与 handleMoveFile 类似的错误处理逻辑
|
||||
let errorMessage = (error as Error).message;
|
||||
if ((error as any).code === 'ENOENT')
|
||||
errorMessage = `Source path not found: ${sourcePath}.`;
|
||||
@@ -315,7 +334,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`;
|
||||
else if ((error as any).code === 'EEXIST')
|
||||
errorMessage = `An item already exists at the target path: ${newPath}.`;
|
||||
// Keep more specific errors from access or directory checks
|
||||
// 保留来自访问检查或目录检查的更具体错误
|
||||
else if (
|
||||
!errorMessage.startsWith('Source path not found') &&
|
||||
!errorMessage.startsWith('Permission denied accessing source path') &&
|
||||
@@ -392,9 +411,9 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
|
||||
// Perform the rename operation using rename directly
|
||||
// Perform the rename operation using fs.promises.rename directly
|
||||
try {
|
||||
await rename(currentPath, newPath);
|
||||
await renamePromise(currentPath, newPath);
|
||||
logger.info(`${logPrefix} Rename successful: ${currentPath} -> ${newPath}`);
|
||||
// Optionally return the newPath if frontend needs it
|
||||
// return { success: true, newPath: newPath };
|
||||
@@ -425,7 +444,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
const logPrefix = `[Writing file ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
|
||||
|
||||
// Validate parameters
|
||||
// 验证参数
|
||||
if (!filePath) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: path is empty`);
|
||||
return { error: 'Path cannot be empty', success: false };
|
||||
@@ -437,14 +456,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure target directory exists (use async to avoid blocking main thread)
|
||||
// 确保目标目录存在
|
||||
const dirname = path.dirname(filePath);
|
||||
logger.debug(`${logPrefix} Creating directory: ${dirname}`);
|
||||
await mkdir(dirname, { recursive: true });
|
||||
fs.mkdirSync(dirname, { recursive: true });
|
||||
|
||||
// Write file content
|
||||
// 写入文件内容
|
||||
logger.debug(`${logPrefix} Starting to write content to file`);
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
await writeFilePromise(filePath, content, 'utf8');
|
||||
logger.info(`${logPrefix} File written successfully`, {
|
||||
path: filePath,
|
||||
size: content.length,
|
||||
@@ -459,270 +478,4 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Search & Find ====================
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@ipcClientEvent('searchLocalFiles')
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
keywords: params.keywords,
|
||||
});
|
||||
|
||||
// Build search options from params, mapping directory to onlyIn
|
||||
const options: SearchOptions = {
|
||||
contentContains: params.contentContains,
|
||||
createdAfter: params.createdAfter ? new Date(params.createdAfter) : undefined,
|
||||
createdBefore: params.createdBefore ? new Date(params.createdBefore) : undefined,
|
||||
detailed: params.detailed,
|
||||
exclude: params.exclude,
|
||||
fileTypes: params.fileTypes,
|
||||
keywords: params.keywords,
|
||||
limit: params.limit || 30,
|
||||
liveUpdate: params.liveUpdate,
|
||||
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
|
||||
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
|
||||
onlyIn: params.directory, // Map directory param to onlyIn option
|
||||
sortBy: params.sortBy,
|
||||
sortDirection: params.sortDirection,
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await this.searchService.search(options.keywords, options);
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('grepContent')
|
||||
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting content search`, { output_mode, searchPath });
|
||||
|
||||
try {
|
||||
const regex = new RegExp(
|
||||
pattern,
|
||||
`g${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`,
|
||||
);
|
||||
|
||||
// Determine files to search
|
||||
let filesToSearch: string[] = [];
|
||||
const stats = await stat(searchPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
filesToSearch = [searchPath];
|
||||
} else {
|
||||
// Use glob pattern if provided, otherwise search all files
|
||||
const globPattern = params.glob || '**/*';
|
||||
filesToSearch = await fg(globPattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
// Filter by type if provided
|
||||
if (params.type) {
|
||||
const ext = `.${params.type}`;
|
||||
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
|
||||
|
||||
const matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (const filePath of filesToSearch) {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) continue;
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
if (regex.test(content)) {
|
||||
matches.push(filePath);
|
||||
totalMatches++;
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
const matchedLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const contextBefore = params['-B'] || params['-C'] || 0;
|
||||
const contextAfter = params['-A'] || params['-C'] || 0;
|
||||
|
||||
const startLine = Math.max(0, i - contextBefore);
|
||||
const endLine = Math.min(lines.length - 1, i + contextAfter);
|
||||
|
||||
for (let j = startLine; j <= endLine; j++) {
|
||||
const lineNum = params['-n'] ? `${j + 1}:` : '';
|
||||
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
|
||||
}
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
matches.push(...matchedLines);
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const fileMatches = (content.match(regex) || []).length;
|
||||
if (fileMatches > 0) {
|
||||
matches.push(`${filePath}:${fileMatches}`);
|
||||
totalMatches += fileMatches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ipcClientEvent('globLocalFiles')
|
||||
async handleGlobFiles({
|
||||
path: searchPath = process.cwd(),
|
||||
pattern,
|
||||
}: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const logPrefix = `[globFiles: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting glob search`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== File Editing ====================
|
||||
|
||||
@ipcClientEvent('editLocalFile')
|
||||
async handleEditFile({
|
||||
file_path: filePath,
|
||||
new_string,
|
||||
old_string,
|
||||
replace_all = false,
|
||||
}: EditLocalFileParams): Promise<EditLocalFileResult> {
|
||||
const logPrefix = `[editFile: ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting file edit`, { replace_all });
|
||||
|
||||
try {
|
||||
// Read file content
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
// Check if old_string exists
|
||||
if (!content.includes(old_string)) {
|
||||
logger.error(`${logPrefix} Old string not found in file`);
|
||||
return {
|
||||
error: 'The specified old_string was not found in the file',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform replacement
|
||||
let newContent: string;
|
||||
let replacements: number;
|
||||
|
||||
if (replace_all) {
|
||||
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const matches = content.match(regex);
|
||||
replacements = matches ? matches.length : 0;
|
||||
newContent = content.replaceAll(old_string, new_string);
|
||||
} else {
|
||||
// Replace only first occurrence
|
||||
const index = content.indexOf(old_string);
|
||||
if (index === -1) {
|
||||
return {
|
||||
error: 'Old string not found',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
newContent =
|
||||
content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
|
||||
logger.info(`${logPrefix} File edited successfully`, { replacements });
|
||||
return {
|
||||
replacements,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Edit failed:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
export default class MenuController extends ControllerModule {
|
||||
/**
|
||||
* Refresh menu
|
||||
* 刷新菜单
|
||||
*/
|
||||
@ipcClientEvent('refreshAppMenu')
|
||||
refreshAppMenu() {
|
||||
// Note: May need to decide whether to allow renderer process to refresh all menus based on specific circumstances
|
||||
// 注意:可能需要根据具体情况决定是否允许渲染进程刷新所有菜单
|
||||
return this.app.menuManager.refreshMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
@ipcClientEvent('showContextMenu')
|
||||
showContextMenu(params: { data?: any; type: string }) {
|
||||
@@ -19,11 +19,11 @@ export default class MenuController extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set development menu visibility
|
||||
* 设置开发菜单可见性
|
||||
*/
|
||||
@ipcClientEvent('setDevMenuVisibility')
|
||||
setDevMenuVisibility(visible: boolean) {
|
||||
// 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({
|
||||
|
||||
@@ -77,7 +77,6 @@ export default class SystemController extends ControllerModule {
|
||||
|
||||
// 更新i18n实例的语言
|
||||
await this.app.i18n.changeLanguage(locale === 'auto' ? app.getLocale() : locale);
|
||||
this.app.browserManager.broadcastToAllWindows('localeChanged', { locale });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -8,24 +8,24 @@ import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
// Create logger
|
||||
// 创建日志记录器
|
||||
const logger = createLogger('controllers:TrayMenuCtr');
|
||||
|
||||
export default class TrayMenuCtr extends ControllerModule {
|
||||
async toggleMainWindow() {
|
||||
logger.debug('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);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('BrowserWindowsCtr', () => {
|
||||
it('should show the settings window with the specified tab', async () => {
|
||||
const tab = 'appearance';
|
||||
const result = await browserWindowsCtr.openSettingsWindow(tab);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({ tab });
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(tab);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -120,11 +120,11 @@ describe('BrowserWindowsCtr', () => {
|
||||
it('should show settings window if matched route target is settings', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/settings/provider',
|
||||
url: 'app://host/settings/provider?active=provider&provider=ollama',
|
||||
path: '/settings?active=common',
|
||||
url: 'app://host/settings?active=common',
|
||||
};
|
||||
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
|
||||
const subPath = 'provider';
|
||||
const subPath = 'common';
|
||||
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
|
||||
(extractSubPath as Mock).mockReturnValue(subPath);
|
||||
|
||||
@@ -132,10 +132,7 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(params.path);
|
||||
expect(extractSubPath).toHaveBeenCalledWith(params.path, matchedRoute.pathPrefix);
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
|
||||
searchParams: { active: 'provider', provider: 'ollama' },
|
||||
tab: subPath,
|
||||
});
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(subPath);
|
||||
expect(result).toEqual({
|
||||
intercepted: true,
|
||||
path: params.path,
|
||||
@@ -173,11 +170,11 @@ describe('BrowserWindowsCtr', () => {
|
||||
it('should return error if processing route interception fails for settings', async () => {
|
||||
const params: InterceptRouteParams = {
|
||||
...baseParams,
|
||||
path: '/settings',
|
||||
path: '/settings?active=general',
|
||||
url: 'app://host/settings?active=general',
|
||||
};
|
||||
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
|
||||
const subPath = undefined;
|
||||
const subPath = 'general';
|
||||
const errorMessage = 'Processing error for settings';
|
||||
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
|
||||
(extractSubPath as Mock).mockReturnValue(subPath);
|
||||
@@ -185,10 +182,6 @@ describe('BrowserWindowsCtr', () => {
|
||||
|
||||
const result = await browserWindowsCtr.interceptRoute(params);
|
||||
|
||||
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
|
||||
searchParams: { active: 'general' },
|
||||
tab: subPath,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
error: errorMessage,
|
||||
intercepted: false,
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock file-loaders
|
||||
vi.mock('@lobechat/file-loaders', () => ({
|
||||
SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'],
|
||||
loadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fast-glob
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises and node:fs
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
Stats: class Stats {},
|
||||
constants: {
|
||||
F_OK: 0,
|
||||
},
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock FileSearchService
|
||||
const mockSearchService = {
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock makeSureDirExist
|
||||
vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockSearchService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('LocalFileCtr', () => {
|
||||
let localFileCtr: LocalFileCtr;
|
||||
let mockShell: any;
|
||||
let mockFg: any;
|
||||
let mockLoadFile: any;
|
||||
let mockFsPromises: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocks
|
||||
mockShell = (await import('electron')).shell;
|
||||
mockFg = (await import('fast-glob')).default;
|
||||
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
|
||||
mockFsPromises = await import('node:fs/promises');
|
||||
|
||||
localFileCtr = new LocalFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFile', () => {
|
||||
it('should open file successfully', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/file.txt');
|
||||
});
|
||||
|
||||
it('should return error when opening file fails', async () => {
|
||||
const error = new Error('Failed to open');
|
||||
vi.mocked(mockShell.openPath).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFolder', () => {
|
||||
it('should open directory when isDirectory is true', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '/test/folder',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should open parent directory when isDirectory is false', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '/test/folder/file.txt',
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should return error when opening folder fails', async () => {
|
||||
const error = new Error('Failed to open folder');
|
||||
vi.mocked(mockShell.openPath).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '/test/folder',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open folder' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFile', () => {
|
||||
it('should read file successfully with default location', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.fileType).toBe('txt');
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
expect(result.content).toBe(mockFileContent);
|
||||
});
|
||||
|
||||
it('should read file with custom location range', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt', loc: [1, 3] });
|
||||
|
||||
expect(result.content).toBe('line2\nline3');
|
||||
expect(result.lineCount).toBe(2);
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/missing.txt' });
|
||||
|
||||
expect(result.content).toContain('Error accessing or processing file');
|
||||
expect(result.lineCount).toBe(0);
|
||||
expect(result.charCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFiles', () => {
|
||||
it('should read multiple files successfully', async () => {
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: 'file content',
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFiles({
|
||||
paths: ['/test/file1.txt', '/test/file2.txt'],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockLoadFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
it('should write file successfully', async () => {
|
||||
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '/test/file.txt',
|
||||
content: 'test content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error when path is empty', async () => {
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '',
|
||||
content: 'test content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Path cannot be empty' });
|
||||
});
|
||||
|
||||
it('should return error when content is undefined', async () => {
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '/test/file.txt',
|
||||
content: undefined as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Content cannot be empty' });
|
||||
});
|
||||
|
||||
it('should handle write error', async () => {
|
||||
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(mockFsPromises.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
const result = await localFileCtr.handleWriteFile({
|
||||
path: '/test/file.txt',
|
||||
content: 'test content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to write file: Write failed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRenameFile', () => {
|
||||
it('should rename file successfully', async () => {
|
||||
vi.mocked(mockFsPromises.rename).mockResolvedValue(undefined);
|
||||
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, newPath: '/test/new.txt' });
|
||||
expect(mockFsPromises.rename).toHaveBeenCalledWith('/test/old.txt', '/test/new.txt');
|
||||
});
|
||||
|
||||
it('should skip rename when paths are identical', async () => {
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/file.txt',
|
||||
newName: 'file.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, newPath: '/test/file.txt' });
|
||||
expect(mockFsPromises.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid new name with path separators', async () => {
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: '../new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid new name');
|
||||
});
|
||||
|
||||
it('should reject invalid new name with special characters', async () => {
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new:file.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid new name');
|
||||
});
|
||||
|
||||
it('should handle file not found error', async () => {
|
||||
const error: any = new Error('File not found');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('File or directory not found');
|
||||
});
|
||||
|
||||
it('should handle file already exists error', async () => {
|
||||
const error: any = new Error('File exists');
|
||||
error.code = 'EEXIST';
|
||||
vi.mocked(mockFsPromises.rename).mockRejectedValue(error);
|
||||
|
||||
const result = await localFileCtr.handleRenameFile({
|
||||
path: '/test/old.txt',
|
||||
newName: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLocalFilesSearch', () => {
|
||||
it('should search files successfully', async () => {
|
||||
const mockResults = [
|
||||
{
|
||||
name: 'test.txt',
|
||||
path: '/test/test.txt',
|
||||
isDirectory: false,
|
||||
size: 100,
|
||||
type: 'txt',
|
||||
},
|
||||
];
|
||||
mockSearchService.search.mockResolvedValue(mockResults);
|
||||
|
||||
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
||||
|
||||
expect(result).toEqual(mockResults);
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('test', {
|
||||
keywords: 'test',
|
||||
limit: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
||||
|
||||
const result = await localFileCtr.handleLocalFilesSearch({ keywords: 'test' });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockFiles = [
|
||||
{ path: '/test/file1.txt', stats: { mtime: new Date('2024-01-02') } },
|
||||
{ path: '/test/file2.txt', stats: { mtime: new Date('2024-01-01') } },
|
||||
];
|
||||
vi.mocked(mockFg).mockResolvedValue(mockFiles);
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.files).toEqual(['/test/file1.txt', '/test/file2.txt']);
|
||||
expect(result.total_files).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle glob error', async () => {
|
||||
vi.mocked(mockFg).mockRejectedValue(new Error('Glob failed'));
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
files: [],
|
||||
total_files: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { Session, app, ipcMain, protocol } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { buildDir, LOCAL_DATABASE_DIR, nextStandaloneDir } from '@/const/dir';
|
||||
import { buildDir, nextStandaloneDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { IServiceModule } from '@/services';
|
||||
@@ -130,9 +129,6 @@ export class App {
|
||||
|
||||
this.initDevBranding();
|
||||
|
||||
// Clean up stale database lock file before starting IPC server
|
||||
await this.cleanupDatabaseLock();
|
||||
|
||||
// ==============
|
||||
await this.ipcServer.start();
|
||||
logger.debug('IPC server started');
|
||||
@@ -375,27 +371,6 @@ export class App {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up stale database lock file from previous crashes or abnormal exits
|
||||
*/
|
||||
private cleanupDatabaseLock = async () => {
|
||||
try {
|
||||
const dbPath = join(this.appStoragePath, LOCAL_DATABASE_DIR);
|
||||
const lockPath = `${dbPath}.lock`;
|
||||
|
||||
if (pathExistsSync(lockPath)) {
|
||||
logger.info(`Cleaning up stale database lock file: ${lockPath}`);
|
||||
await remove(lockPath);
|
||||
logger.info('Database lock file removed successfully');
|
||||
} else {
|
||||
logger.debug('No database lock file found, skipping cleanup');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup database lock file:', error);
|
||||
// Non-fatal error, allow application to continue
|
||||
}
|
||||
};
|
||||
|
||||
private registerNextHandler() {
|
||||
logger.debug('Registering Next.js handler');
|
||||
const handler = createHandler({
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { app } from 'electron';
|
||||
import { pathExistsSync, remove } from 'fs-extra';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LOCAL_DATABASE_DIR } from '@/const/dir';
|
||||
|
||||
// Mock electron modules
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: vi.fn(() => '/mock/app/path'),
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn(() => '/mock/user/path'),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
on: vi.fn(),
|
||||
commandLine: {
|
||||
appendSwitch: vi.fn(),
|
||||
},
|
||||
dock: {
|
||||
setIcon: vi.fn(),
|
||||
},
|
||||
exit: vi.fn(),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
},
|
||||
nativeTheme: {
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
},
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fs-extra module
|
||||
vi.mock('fs-extra', async () => {
|
||||
const actual = await vi.importActual('fs-extra');
|
||||
return {
|
||||
...actual,
|
||||
pathExistsSync: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock common/routes
|
||||
vi.mock('~common/routes', () => ({
|
||||
findMatchingRoute: vi.fn(),
|
||||
extractSubPath: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('fix-path', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/const/dir', () => ({
|
||||
buildDir: '/mock/build',
|
||||
nextStandaloneDir: '/mock/standalone',
|
||||
LOCAL_DATABASE_DIR: 'lobehub-local-db',
|
||||
appStorageDir: '/mock/storage/path',
|
||||
userDataDir: '/mock/user/data',
|
||||
DB_SCHEMA_HASH_FILENAME: 'lobehub-local-db-schema-hash',
|
||||
FILE_STORAGE_DIR: 'file-storage',
|
||||
INSTALL_PLUGINS_DIR: 'plugins',
|
||||
LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/electron-server-ipc', () => ({
|
||||
ElectronIPCServer: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock all infrastructure managers
|
||||
vi.mock('../infrastructure/I18nManager', () => ({
|
||||
I18nManager: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/StoreManager', () => ({
|
||||
StoreManager: vi.fn().mockImplementation(() => ({
|
||||
get: vi.fn((key) => {
|
||||
if (key === 'storagePath') return '/mock/storage/path';
|
||||
return undefined;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/StaticFileServerManager', () => ({
|
||||
StaticFileServerManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/UpdaterManager', () => ({
|
||||
UpdaterManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../infrastructure/ProtocolManager', () => ({
|
||||
ProtocolManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
processPendingUrls: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../browser/BrowserManager', () => ({
|
||||
BrowserManager: vi.fn().mockImplementation(() => ({
|
||||
initializeBrowsers: vi.fn(),
|
||||
getIdentifierByWebContents: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/MenuManager', () => ({
|
||||
MenuManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/ShortcutManager', () => ({
|
||||
ShortcutManager: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/TrayManager', () => ({
|
||||
TrayManager: vi.fn().mockImplementation(() => ({
|
||||
initializeTrays: vi.fn(),
|
||||
destroyAll: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/next-electron-rsc', () => ({
|
||||
createHandler: vi.fn(() => ({
|
||||
createInterceptor: vi.fn(),
|
||||
registerCustomHandler: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock controllers and services
|
||||
vi.mock('../../controllers/*Ctr.ts', () => ({}));
|
||||
vi.mock('../../services/*Srv.ts', () => ({}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { App } from '../App';
|
||||
|
||||
describe('App - Database Lock Cleanup', () => {
|
||||
let appInstance: App;
|
||||
let mockLockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock glob imports to return empty arrays
|
||||
(import.meta as any).glob = vi.fn(() => ({}));
|
||||
|
||||
mockLockPath = join('/mock/storage/path', LOCAL_DATABASE_DIR) + '.lock';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bootstrap - database lock cleanup', () => {
|
||||
it('should remove stale lock file if it exists during bootstrap', async () => {
|
||||
// Setup: simulate existing lock file
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockResolvedValue(undefined);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap which should trigger cleanup
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: lock file was removed
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should not attempt to remove lock file if it does not exist', async () => {
|
||||
// Setup: no lock file exists
|
||||
vi.mocked(pathExistsSync).mockReturnValue(false);
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Call bootstrap
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: lock file check was called
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
|
||||
// Verify: remove was NOT called since file doesn't exist
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue bootstrap even if lock cleanup fails', async () => {
|
||||
// Setup: simulate lock file exists but cleanup fails
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
vi.mocked(remove).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Create app instance
|
||||
appInstance = new App();
|
||||
|
||||
// Bootstrap should not throw even if cleanup fails
|
||||
await expect(appInstance.bootstrap()).resolves.not.toThrow();
|
||||
|
||||
// Verify: cleanup was attempted
|
||||
expect(pathExistsSync).toHaveBeenCalledWith(mockLockPath);
|
||||
expect(remove).toHaveBeenCalledWith(mockLockPath);
|
||||
});
|
||||
|
||||
it('should clean up lock file before starting IPC server', async () => {
|
||||
// Setup
|
||||
vi.mocked(pathExistsSync).mockReturnValue(true);
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(remove).mockImplementation(async () => {
|
||||
callOrder.push('remove');
|
||||
});
|
||||
|
||||
// Mock IPC server start to track call order
|
||||
const { ElectronIPCServer } = await import('@lobechat/electron-server-ipc');
|
||||
const mockStart = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('ipcServer.start');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(ElectronIPCServer).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
start: mockStart,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// Create app instance and bootstrap
|
||||
appInstance = new App();
|
||||
await appInstance.bootstrap();
|
||||
|
||||
// Verify: cleanup happens before IPC server starts
|
||||
expect(callOrder).toEqual(['remove', 'ipcServer.start']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appStoragePath', () => {
|
||||
it('should return storage path from store manager', () => {
|
||||
appInstance = new App();
|
||||
|
||||
const storagePath = appInstance.appStoragePath;
|
||||
|
||||
expect(storagePath).toBe('/mock/storage/path');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -336,6 +336,7 @@ export default class Browser {
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
},
|
||||
@@ -358,9 +359,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 +492,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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import {
|
||||
MainBroadcastEventKey,
|
||||
MainBroadcastParams,
|
||||
OpenSettingsWindowOptions,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import { WebContents } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import {
|
||||
AppBrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers,
|
||||
appBrowsers,
|
||||
windowTemplates,
|
||||
} from '../../appBrowsers';
|
||||
import { AppBrowsersIdentifiers, appBrowsers } from '../../appBrowsers';
|
||||
import type { App } from '../App';
|
||||
import type { BrowserWindowOpts } from './Browser';
|
||||
import Browser from './Browser';
|
||||
@@ -23,9 +14,9 @@ const logger = createLogger('core:BrowserManager');
|
||||
export class BrowserManager {
|
||||
app: App;
|
||||
|
||||
browsers: Map<string, Browser> = new Map();
|
||||
browsers: Map<AppBrowsersIdentifiers, Browser> = new Map();
|
||||
|
||||
private webContentsMap = new Map<WebContents, string>();
|
||||
private webContentsMap = new Map<WebContents, AppBrowsersIdentifiers>();
|
||||
|
||||
constructor(app: App) {
|
||||
logger.debug('Initializing BrowserManager');
|
||||
@@ -60,47 +51,26 @@ export class BrowserManager {
|
||||
};
|
||||
|
||||
broadcastToWindow = <T extends MainBroadcastEventKey>(
|
||||
identifier: string,
|
||||
identifier: AppBrowsersIdentifiers,
|
||||
event: T,
|
||||
data: MainBroadcastParams<T>,
|
||||
) => {
|
||||
logger.debug(`Broadcasting event ${event} to window: ${identifier}`);
|
||||
this.browsers.get(identifier)?.broadcast(event, data);
|
||||
this.browsers.get(identifier).broadcast(event, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Display the settings window and navigate to a specific tab
|
||||
* @param tab Settings window sub-path tab
|
||||
*/
|
||||
async showSettingsWindowWithTab(options?: OpenSettingsWindowOptions) {
|
||||
const tab = options?.tab;
|
||||
const searchParams = options?.searchParams;
|
||||
|
||||
const query = new URLSearchParams();
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (tab && tab !== 'common' && !query.has('active')) {
|
||||
query.set('active', tab);
|
||||
}
|
||||
|
||||
const queryString = query.toString();
|
||||
const activeTab = query.get('active') ?? tab;
|
||||
|
||||
logger.debug(
|
||||
`Showing settings window with navigation: active=${activeTab || 'default'}, query=${
|
||||
queryString || 'none'
|
||||
}`,
|
||||
);
|
||||
|
||||
if (queryString) {
|
||||
const browser = await this.redirectToPage('settings', undefined, queryString);
|
||||
async showSettingsWindowWithTab(tab?: string) {
|
||||
logger.debug(`Showing settings window with tab: ${tab || 'default'}`);
|
||||
// common is the main path for settings route
|
||||
if (tab && tab !== 'common') {
|
||||
const browser = await this.redirectToPage('settings', tab);
|
||||
|
||||
// make provider page more large
|
||||
if (activeTab?.startsWith('provider')) {
|
||||
if (tab.startsWith('provider/')) {
|
||||
logger.debug('Resizing window for provider settings');
|
||||
browser.setWindowSize({ height: 1000, width: 1400 });
|
||||
browser.moveToCenter();
|
||||
@@ -117,32 +87,21 @@ export class BrowserManager {
|
||||
* @param identifier Window identifier
|
||||
* @param subPath Sub-path, such as 'agent', 'about', etc.
|
||||
*/
|
||||
async redirectToPage(identifier: string, subPath?: string, search?: string) {
|
||||
async redirectToPage(identifier: AppBrowsersIdentifiers, subPath?: string) {
|
||||
try {
|
||||
// Ensure window is retrieved or created
|
||||
const browser = this.retrieveByIdentifier(identifier);
|
||||
browser.hide();
|
||||
|
||||
// Handle both static and dynamic windows
|
||||
let baseRoute: string;
|
||||
if (identifier in appBrowsers) {
|
||||
baseRoute = appBrowsers[identifier as AppBrowsersIdentifiers].path;
|
||||
} else {
|
||||
// For dynamic windows, extract base route from the browser options
|
||||
const browserOptions = browser.options;
|
||||
baseRoute = browserOptions.path;
|
||||
}
|
||||
const baseRoute = appBrowsers[identifier].path;
|
||||
|
||||
// Build complete URL path
|
||||
const fullPath = subPath ? `${baseRoute}/${subPath}` : baseRoute;
|
||||
const normalizedSearch =
|
||||
search && search.length > 0 ? (search.startsWith('?') ? search : `?${search}`) : '';
|
||||
const fullUrl = `${fullPath}${normalizedSearch}`;
|
||||
|
||||
logger.debug(`Redirecting to: ${fullUrl}`);
|
||||
logger.debug(`Redirecting to: ${fullPath}`);
|
||||
|
||||
// Load URL and show window
|
||||
await browser.loadUrl(fullUrl);
|
||||
await browser.loadUrl(fullPath);
|
||||
browser.show();
|
||||
|
||||
return browser;
|
||||
@@ -155,81 +114,13 @@ export class BrowserManager {
|
||||
/**
|
||||
* get Browser by identifier
|
||||
*/
|
||||
retrieveByIdentifier(identifier: string) {
|
||||
retrieveByIdentifier(identifier: AppBrowsersIdentifiers) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
|
||||
if (browser) return browser;
|
||||
|
||||
// Check if it's a static browser
|
||||
if (identifier in appBrowsers) {
|
||||
logger.debug(`Browser ${identifier} not found, initializing new instance`);
|
||||
return this.retrieveOrInitialize(appBrowsers[identifier as AppBrowsersIdentifiers]);
|
||||
}
|
||||
|
||||
throw new Error(`Browser ${identifier} not found and is not a static browser`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-instance window from template
|
||||
* @param templateId Template identifier
|
||||
* @param path Full path with query parameters
|
||||
* @param uniqueId Optional unique identifier, will be generated if not provided
|
||||
* @returns The window identifier and Browser instance
|
||||
*/
|
||||
createMultiInstanceWindow(
|
||||
templateId: WindowTemplateIdentifiers,
|
||||
path: string,
|
||||
uniqueId?: string,
|
||||
) {
|
||||
const template = windowTemplates[templateId];
|
||||
if (!template) {
|
||||
throw new Error(`Window template ${templateId} not found`);
|
||||
}
|
||||
|
||||
// Generate unique identifier
|
||||
const windowId =
|
||||
uniqueId ||
|
||||
`${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
// Create browser options from template
|
||||
const browserOpts: BrowserWindowOpts = {
|
||||
...template,
|
||||
identifier: windowId,
|
||||
path: path,
|
||||
};
|
||||
|
||||
logger.debug(`Creating multi-instance window: ${windowId} with path: ${path}`);
|
||||
|
||||
const browser = this.retrieveOrInitialize(browserOpts);
|
||||
|
||||
return {
|
||||
browser: browser,
|
||||
identifier: windowId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all windows based on template
|
||||
* @param templateId Template identifier
|
||||
* @returns Array of window identifiers matching the template
|
||||
*/
|
||||
getWindowsByTemplate(templateId: string): string[] {
|
||||
const prefix = `${templateId}_`;
|
||||
return Array.from(this.browsers.keys()).filter((id) => id.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all windows based on template
|
||||
* @param templateId Template identifier
|
||||
*/
|
||||
closeWindowsByTemplate(templateId: string): void {
|
||||
const windowIds = this.getWindowsByTemplate(templateId);
|
||||
windowIds.forEach((id) => {
|
||||
const browser = this.browsers.get(id);
|
||||
if (browser) {
|
||||
browser.close();
|
||||
}
|
||||
});
|
||||
logger.debug(`Browser ${identifier} not found, initializing new instance`);
|
||||
return this.retrieveOrInitialize(appBrowsers[identifier]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,7 +144,7 @@ export class BrowserManager {
|
||||
* @param options Browser window options
|
||||
*/
|
||||
private retrieveOrInitialize(options: BrowserWindowOpts) {
|
||||
let browser = this.browsers.get(options.identifier);
|
||||
let browser = this.browsers.get(options.identifier as AppBrowsersIdentifiers);
|
||||
if (browser) {
|
||||
logger.debug(`Retrieved existing browser: ${options.identifier}`);
|
||||
return browser;
|
||||
@@ -262,7 +153,7 @@ export class BrowserManager {
|
||||
logger.debug(`Creating new browser: ${options.identifier}`);
|
||||
browser = new Browser(options, this.app);
|
||||
|
||||
const identifier = options.identifier;
|
||||
const identifier = options.identifier as AppBrowsersIdentifiers;
|
||||
this.browsers.set(identifier, browser);
|
||||
|
||||
// 记录 WebContents 和 identifier 的映射
|
||||
@@ -274,32 +165,33 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
browser.browserWindow.on('show', () => {
|
||||
if (browser.webContents) this.webContentsMap.set(browser.webContents, browser.identifier);
|
||||
if (browser.webContents)
|
||||
this.webContentsMap.set(browser.webContents, browser.identifier as AppBrowsersIdentifiers);
|
||||
});
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
closeWindow(identifier: string) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
|
||||
browser?.close();
|
||||
}
|
||||
|
||||
minimizeWindow(identifier: string) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
|
||||
browser?.browserWindow.minimize();
|
||||
}
|
||||
|
||||
maximizeWindow(identifier: string) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
if (browser?.browserWindow.isMaximized()) {
|
||||
const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
|
||||
if (browser.browserWindow.isMaximized()) {
|
||||
browser?.browserWindow.unmaximize();
|
||||
} else {
|
||||
browser?.browserWindow.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
getIdentifierByWebContents(webContents: WebContents): string | null {
|
||||
getIdentifierByWebContents(webContents: WebContents): AppBrowsersIdentifiers | null {
|
||||
return this.webContentsMap.get(webContents) || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user