mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
♻️ refactor: migrate AI Rules to Claude Code Skills (#11737)
♻️ refactor: migrate AI Rules to Claude Code Skills system
Migrate all AI Rules from .cursor/rules/ to .agents/skills/ directory:
- Move 23 skills to .agents/skills/ (main directory)
- Update symlinks: .claude/skills, .cursor/skills, .codex/skills
- Create project-overview skill from project documentation
- Add references/ subdirectories for complex skills
- Remove LobeChat references from skill descriptions
- Delete obsolete .cursor/rules/ and .claude/commands/prompts/ directories
Skills structure enables better portability and maintainability across AI tools.
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
---
|
||||
description: Complete guide for adding a new AI provider documentation to LobeChat
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Adding New AI Provider Documentation
|
||||
|
||||
This document provides a step-by-step guide for adding documentation for a new AI provider to LobeChat, based on the complete workflow used for adding providers like BFL (Black Forest Labs) and FAL.
|
||||
|
||||
## Overview
|
||||
|
||||
Adding a new provider requires creating both user-facing documentation and technical configuration files. The process involves:
|
||||
|
||||
1. Creating usage documentation (EN + CN)
|
||||
2. Adding environment variable documentation (EN + CN)
|
||||
3. Updating Docker configuration files
|
||||
4. Updating .env.example file
|
||||
5. Preparing image resources
|
||||
|
||||
## Step 1: Create Provider Usage Documentation
|
||||
|
||||
Create user-facing documentation that explains how to use the new provider.
|
||||
|
||||
### Required Files
|
||||
|
||||
Create both English and Chinese versions:
|
||||
- `docs/usage/providers/{provider-name}.mdx` (English)
|
||||
- `docs/usage/providers/{provider-name}.zh-CN.mdx` (Chinese)
|
||||
|
||||
### Documentation Structure
|
||||
|
||||
Follow the structure and format used in existing provider documentation. For reference, see:
|
||||
- `docs/usage/providers/fal.mdx` (English template)
|
||||
- `docs/usage/providers/fal.zh-CN.mdx` (Chinese template)
|
||||
|
||||
### Key Requirements
|
||||
|
||||
- **Images**: Prepare 5-6 screenshots showing the process
|
||||
- **Cover Image**: Create or obtain a cover image for the provider
|
||||
- **Accurate URLs**: Use real registration and dashboard URLs
|
||||
- **Service Type**: Specify whether it's for image generation, text generation, etc.
|
||||
- **Pricing Warning**: Include pricing information callout
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **🔒 API Key Security**: Never include real API keys in documentation. Always use placeholder format (e.g., `bfl-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`)
|
||||
- **🖼️ Image Hosting**: Use LobeHub's CDN for all images: `hub-apac-1.lobeobjects.space`
|
||||
|
||||
## Step 2: Update Environment Variables Documentation
|
||||
|
||||
Add the new provider's environment variables to the self-hosting documentation.
|
||||
|
||||
### Files to Update
|
||||
|
||||
- `docs/self-hosting/environment-variables/model-provider.mdx` (English)
|
||||
- `docs/self-hosting/environment-variables/model-provider.zh-CN.mdx` (Chinese)
|
||||
|
||||
### Content to Add
|
||||
|
||||
Add two sections for each provider:
|
||||
|
||||
```markdown
|
||||
### `{PROVIDER}_API_KEY`
|
||||
|
||||
- Type: Required
|
||||
- Description: This is the API key you applied for in the {Provider Name} service.
|
||||
- Default: -
|
||||
- Example: `{api-key-format-example}`
|
||||
|
||||
### `{PROVIDER}_MODEL_LIST`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Used to control the {Provider Name} model list. Use `+` to add a model, `-` to hide a model, and `model_name=display_name` to customize the display name of a model. Separate multiple entries with commas. The definition syntax follows the same rules as other providers' model lists.
|
||||
- Default: `-`
|
||||
- Example: `-all,+{model-id-1},+{model-id-2}={display-name}`
|
||||
|
||||
The above example disables all models first, then enables `{model-id-1}` and `{model-id-2}` (displayed as `{display-name}`).
|
||||
|
||||
[model-list]: /docs/self-hosting/advanced/model-list
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **API Key Format**: Use proper UUID format for examples (e.g., `12345678-1234-1234-1234-123456789abc`)
|
||||
- **Real Model IDs**: Use actual model IDs from the codebase, not placeholders
|
||||
- **Consistent Naming**: Follow the pattern `{PROVIDER}_API_KEY` and `{PROVIDER}_MODEL_LIST`
|
||||
|
||||
## Step 3: Update Docker Configuration Files
|
||||
|
||||
Add environment variables to all Docker configuration files to ensure the provider works in containerized deployments.
|
||||
|
||||
### Files to Update
|
||||
|
||||
All Dockerfile variants must be updated:
|
||||
- `Dockerfile`
|
||||
- `Dockerfile.database`
|
||||
- `Dockerfile.pglite`
|
||||
|
||||
### Changes Required
|
||||
|
||||
Add the new provider's environment variables at the **end** of the ENV section, just before the final line:
|
||||
|
||||
```dockerfile
|
||||
# Previous providers...
|
||||
# 302.AI
|
||||
AI302_API_KEY="" AI302_MODEL_LIST="" \
|
||||
# {New Provider 1}
|
||||
{PROVIDER1}_API_KEY="" {PROVIDER1}_MODEL_LIST="" \
|
||||
# {New Provider 2}
|
||||
{PROVIDER2}_API_KEY="" {PROVIDER2}_MODEL_LIST=""
|
||||
```
|
||||
|
||||
### Important Rules
|
||||
|
||||
- **Position**: Add new providers at the **end** of the list
|
||||
- **Ordering**: When adding multiple providers, use alphabetical order (e.g., FAL before BFL)
|
||||
- **Consistency**: Maintain identical ordering across all Dockerfile variants
|
||||
- **Format**: Follow the pattern `{PROVIDER}_API_KEY="" {PROVIDER}_MODEL_LIST="" \`
|
||||
|
||||
## Step 4: Update .env.example File
|
||||
|
||||
Add example configuration entries to help users understand how to configure the provider locally.
|
||||
|
||||
### File to Update
|
||||
|
||||
- `.env.example`
|
||||
|
||||
### Content to Add
|
||||
|
||||
Add new sections before the "Market Service" section:
|
||||
|
||||
```bash
|
||||
### {Provider Name} ###
|
||||
|
||||
# {PROVIDER}_API_KEY={provider-prefix}-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
### Format Guidelines
|
||||
|
||||
- **Section Header**: Use `### {Provider Name} ###` format
|
||||
- **Commented Example**: Use `#` to comment out the example
|
||||
- **Key Format**: Use appropriate prefix for the provider (e.g., `bfl-`, `fal-`, `sk-`)
|
||||
- **Position**: Add before the Market Service section
|
||||
- **Spacing**: Maintain consistent spacing with existing entries
|
||||
|
||||
## Step 5: Image Resources
|
||||
|
||||
Prepare all necessary image resources for the documentation.
|
||||
|
||||
### Required Images
|
||||
|
||||
1. **Cover Image**: Provider logo or branded image
|
||||
2. **API Dashboard Screenshots**: 3-4 screenshots showing API key creation process
|
||||
3. **LobeChat Configuration Screenshots**: 2-3 screenshots showing provider setup in LobeChat
|
||||
|
||||
### Image Guidelines
|
||||
|
||||
- **Quality**: Use high-resolution screenshots
|
||||
- **Consistency**: Maintain consistent styling across all screenshots
|
||||
- **Privacy**: Remove or blur any sensitive information
|
||||
- **Format**: Use PNG format for screenshots
|
||||
- **Hosting**: Use LobeHub's CDN (`hub-apac-1.lobeobjects.space`) for all images
|
||||
|
||||
## Checklist
|
||||
|
||||
Before submitting your provider documentation:
|
||||
|
||||
- [ ] Created both English and Chinese usage documentation
|
||||
- [ ] Added environment variable documentation (EN + CN)
|
||||
- [ ] Updated all 3 Dockerfile variants with consistent ordering
|
||||
- [ ] Updated .env.example with proper key format
|
||||
- [ ] Prepared all required screenshots and images
|
||||
- [ ] Used actual model IDs from the codebase
|
||||
- [ ] Verified no real API keys are included in documentation
|
||||
- [ ] Used LobeHub CDN for all image hosting
|
||||
- [ ] Tested the documentation for clarity and accuracy
|
||||
|
||||
## Reference
|
||||
|
||||
This guide was created based on the implementation of BFL (Black Forest Labs) provider documentation. For a complete example, refer to:
|
||||
- Commits: `d2da03e1a` (documentation) and `6a2e95868` (environment variables)
|
||||
- Files: `docs/usage/providers/bfl.mdx`, `docs/usage/providers/bfl.zh-CN.mdx`
|
||||
- PR: Current branch `tj/feat/bfl-docs`
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: cursor rules writing and optimization guide
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
当你编写或修改 Cursor Rule 时,请遵循以下准则:
|
||||
|
||||
- 当你知道 rule 的文件名时,使用 `read_file` 而不是 `fetch_rules` 去读取它们,它们都在项目根目录的 `.cursor/rules/` 文件夹下
|
||||
|
||||
- 代码示例
|
||||
- 示例应尽量精简,仅保留演示核心
|
||||
- 删除与示例无关的导入/导出语句,但保留必要的导入
|
||||
- 同一文件存在多个示例时,若前文已演示模块导入,后续示例可省略重复导入
|
||||
- 无需书写 `export`
|
||||
- 可省略与演示无关或重复的 props、配置对象属性、try/catch、CSS 等代码
|
||||
- 删除无关注释,保留有助理解的注释
|
||||
|
||||
- 格式
|
||||
- 修改前请先确认原始文档语言,并保持一致
|
||||
- 无序列表统一使用 `-`
|
||||
- 列表末尾的句号是多余的
|
||||
- 非必要不使用加粗、行内代码等样式,Rule 主要供 LLM 阅读
|
||||
- 避免中英文逐句对照。若括号内容为示例而非翻译,可保留
|
||||
|
||||
- Review
|
||||
- 修正 Markdown 语法问题
|
||||
- 纠正错别字
|
||||
- 指出示例与说明不一致之处
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
globs: packages/database/migrations/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Database Migrations Guide
|
||||
|
||||
## Step1: Generate migrations
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
this step will generate following files:
|
||||
|
||||
- packages/database/migrations/0046_meaningless_file_name.sql
|
||||
- packages/database/migrations/0046_meaningless_file_name.sql
|
||||
|
||||
and update the following files:
|
||||
|
||||
- packages/database/migrations/meta/\_journal.json
|
||||
- packages/database/src/core/migrations.json
|
||||
- docs/development/database-schema.dbml
|
||||
|
||||
## Step2: optimize the migration sql fileName
|
||||
|
||||
the migration sql file name is randomly generated, we need to optimize the file name to make it more readable and meaningful. For example, `0046_meaningless_file_name.sql` -> `0046_user_add_avatar_column.sql`
|
||||
|
||||
## Step3: 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`.
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
description: 包含添加 console.log 日志请求时
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Debug 包使用指南
|
||||
|
||||
本项目使用 `debug` 包进行调试日志记录。使用此规则来确保团队成员统一调试日志格式。
|
||||
|
||||
## 基本用法
|
||||
|
||||
1. 导入 debug 包:
|
||||
|
||||
```typescript
|
||||
import debug from 'debug';
|
||||
```
|
||||
|
||||
1. 创建一个命名空间的日志记录器:
|
||||
|
||||
```typescript
|
||||
// 格式: lobe:[模块]:[子模块]
|
||||
const log = debug('lobe-[模块名]:[子模块名]');
|
||||
```
|
||||
|
||||
1. 使用日志记录器:
|
||||
|
||||
```typescript
|
||||
log('简单消息');
|
||||
log('带变量的消息: %O', object);
|
||||
log('格式化数字: %d', number);
|
||||
```
|
||||
|
||||
## 命名空间约定
|
||||
|
||||
- 桌面应用相关: `lobe-desktop:[模块]`
|
||||
- 服务端相关: `lobe-server:[模块]`
|
||||
- 客户端相关: `lobe-client:[模块]`
|
||||
- 路由相关: `lobe-[类型]-router:[模块]`
|
||||
|
||||
## 格式说明符
|
||||
|
||||
- `%O` - 对象展开(推荐用于复杂对象)
|
||||
- `%o` - 对象
|
||||
- `%s` - 字符串
|
||||
- `%d` - 数字
|
||||
|
||||
## 示例
|
||||
|
||||
查看 `src/server/routers/edge/market/index.ts` 中的使用示例:
|
||||
|
||||
```typescript
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('lobe-edge-router:market');
|
||||
|
||||
log('getAgent input: %O', input);
|
||||
```
|
||||
|
||||
## 启用调试
|
||||
|
||||
要在开发时启用调试输出,需设置环境变量:
|
||||
|
||||
### 在浏览器中
|
||||
|
||||
在控制台执行:
|
||||
|
||||
```javascript
|
||||
localStorage.debug = 'lobe-*';
|
||||
```
|
||||
|
||||
### 在 Node.js 环境中
|
||||
|
||||
```bash
|
||||
DEBUG=lobe-* npm run dev
|
||||
# 或者
|
||||
DEBUG=lobe-* pnpm dev
|
||||
```
|
||||
|
||||
### 在 Electron 应用中
|
||||
|
||||
可以在主进程和渲染进程启动前设置环境变量:
|
||||
|
||||
```typescript
|
||||
process.env.DEBUG = 'lobe-*';
|
||||
```
|
||||
@@ -1,189 +0,0 @@
|
||||
---
|
||||
description: 桌面端测试
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 桌面端控制器单元测试指南
|
||||
|
||||
## 测试框架与目录结构
|
||||
|
||||
LobeChat 桌面端使用 Vitest 作为测试框架。控制器的单元测试应放置在对应控制器文件同级的 `__tests__` 目录下,并以原控制器文件名加 `.test.ts` 作为文件名。
|
||||
|
||||
```plaintext
|
||||
apps/desktop/src/main/controllers/
|
||||
├── __tests__/
|
||||
│ ├── index.test.ts
|
||||
│ ├── MenuCtr.test.ts
|
||||
│ └── ...
|
||||
├── McpCtr.ts
|
||||
├── MenuCtr.ts
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 测试文件基本结构
|
||||
|
||||
```typescript
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import YourController from '../YourControllerName';
|
||||
|
||||
// 模拟依赖
|
||||
vi.mock('依赖模块', () => ({
|
||||
依赖函数: vi.fn(),
|
||||
}));
|
||||
|
||||
// 模拟 App 实例
|
||||
const mockApp = {
|
||||
// 按需模拟必要的 App 属性和方法
|
||||
} as unknown as App;
|
||||
|
||||
describe('YourController', () => {
|
||||
let controller: YourController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new YourController(mockApp);
|
||||
});
|
||||
|
||||
describe('方法名', () => {
|
||||
it('测试场景描述', async () => {
|
||||
// 准备测试数据
|
||||
|
||||
// 执行被测方法
|
||||
const result = await controller.方法名(参数);
|
||||
|
||||
// 验证结果
|
||||
expect(result).toMatchObject(预期结果);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 模拟外部依赖
|
||||
|
||||
### 模拟模块函数
|
||||
|
||||
```typescript
|
||||
const mockFunction = vi.fn();
|
||||
|
||||
vi.mock('module-name', () => ({
|
||||
functionName: mockFunction,
|
||||
}));
|
||||
```
|
||||
|
||||
### 模拟 Node.js 核心模块
|
||||
|
||||
例如模拟 `child_process.exec` 和 `util.promisify`:
|
||||
|
||||
```typescript
|
||||
// 存储模拟的 exec 实现
|
||||
const mockExecImpl = vi.fn();
|
||||
|
||||
// 模拟 child_process.exec
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn((cmd, callback) => {
|
||||
return mockExecImpl(cmd, callback);
|
||||
}),
|
||||
}));
|
||||
|
||||
// 模拟 util.promisify
|
||||
vi.mock('util', () => ({
|
||||
promisify: vi.fn((fn) => {
|
||||
return async (cmd: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
mockExecImpl(cmd, (error: Error | null, result: any) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
};
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## 编写有效的测试用例
|
||||
|
||||
### 测试分类
|
||||
|
||||
将测试用例分为不同类别,每个类别测试一个特定场景:
|
||||
|
||||
```typescript
|
||||
// 成功场景
|
||||
it('应该成功完成操作', async () => {});
|
||||
|
||||
// 边界条件
|
||||
it('应该处理边界情况', async () => {});
|
||||
|
||||
// 错误处理
|
||||
it('应该优雅地处理错误', async () => {});
|
||||
```
|
||||
|
||||
### 设置测试数据
|
||||
|
||||
```typescript
|
||||
// 模拟返回值
|
||||
mockExecImpl.mockImplementation((cmd: string, callback: any) => {
|
||||
if (cmd === '命令') {
|
||||
callback(null, { stdout: '成功输出' });
|
||||
} else {
|
||||
callback(new Error('错误信息'), null);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 断言
|
||||
|
||||
使用 Vitest 的断言函数验证结果:
|
||||
|
||||
```typescript
|
||||
// 检查基本值
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// 检查对象部分匹配
|
||||
expect(result.data).toMatchObject({
|
||||
key: 'value',
|
||||
});
|
||||
|
||||
// 检查数组
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0].name).toBe('expectedName');
|
||||
|
||||
// 检查函数调用
|
||||
expect(mockFunction).toHaveBeenCalledWith(expectedArgs);
|
||||
expect(mockFunction).toHaveBeenCalledTimes(1);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **隔离测试**:确保每个测试互不影响,使用 `beforeEach` 重置模拟和状态
|
||||
2. **全面覆盖**:测试正常流程、边界条件和错误处理
|
||||
3. **清晰命名**:测试名称应清晰描述测试内容和预期结果
|
||||
4. **避免测试实现细节**:测试应该关注行为而非实现细节,使代码重构不会破坏测试
|
||||
5. **模拟外部依赖**:使用 `vi.mock()` 模拟所有外部依赖,减少测试的不确定性
|
||||
|
||||
## 示例:测试 IPC 事件处理方法
|
||||
|
||||
```typescript
|
||||
it('应该正确处理 IPC 事件', async () => {
|
||||
// 模拟依赖
|
||||
mockSomething.mockReturnValue({ result: 'success' });
|
||||
|
||||
// 调用 IPC 方法
|
||||
const result = await controller.ipcMethodName({
|
||||
param1: 'value1',
|
||||
param2: 'value2',
|
||||
});
|
||||
|
||||
// 验证结果
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { result: 'success' },
|
||||
});
|
||||
|
||||
// 验证依赖调用
|
||||
expect(mockSomething).toHaveBeenCalledWith('value1', 'value2');
|
||||
});
|
||||
```
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
description: 当要做 electron 相关工作时
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 桌面端新功能实现指南
|
||||
|
||||
## 桌面端应用架构概述
|
||||
|
||||
LobeChat 桌面端基于 Electron 框架构建,采用主进程-渲染进程架构:
|
||||
|
||||
1. **主进程 (Main Process)**:
|
||||
- 位置:`apps/desktop/src/main`
|
||||
- 职责:控制应用生命周期、系统API交互、窗口管理、后台服务
|
||||
|
||||
2. **渲染进程 (Renderer Process)**:
|
||||
- 复用 Web 端代码,位于 `src` 目录
|
||||
- 通过 IPC 与主进程通信
|
||||
|
||||
3. **预加载脚本 (Preload)**:
|
||||
- 位置:`apps/desktop/src/preload`
|
||||
- 职责:安全地暴露主进程功能给渲染进程
|
||||
|
||||
## 添加新桌面端功能流程
|
||||
|
||||
### 1. 确定功能需求与设计
|
||||
|
||||
首先确定新功能的需求和设计,包括:
|
||||
|
||||
- 功能描述和用例
|
||||
- 是否需要系统级API(如文件系统、网络等)
|
||||
- UI/UX设计(如必要)
|
||||
- 与现有功能的交互方式
|
||||
|
||||
### 2. 在主进程中实现核心功能
|
||||
|
||||
1. **创建控制器 (Controller)**
|
||||
- 位置:`apps/desktop/src/main/controllers/`
|
||||
- 示例:创建 `NewFeatureCtr.ts`
|
||||
- 需继承 `ControllerModule`,并设置 `static readonly groupName`(例如 `static override readonly groupName = 'newFeature';`)
|
||||
- 按 `_template.ts` 模板格式实现,并在 `apps/desktop/src/main/controllers/registry.ts` 的 `controllerIpcConstructors` 中注册,保证类型推导与自动装配
|
||||
|
||||
2. **定义 IPC 事件处理器**
|
||||
- 使用 `@IpcMethod()` 装饰器暴露渲染进程可访问的通道
|
||||
- 通道名称基于 `groupName.methodName` 自动生成,不再手动拼接字符串
|
||||
- 处理函数可通过 `getIpcContext()` 获取 `sender`、`event` 等上下文信息,并按照需要返回结构化结果
|
||||
|
||||
3. **实现业务逻辑**
|
||||
- 可能需要调用 Electron API 或 Node.js 原生模块
|
||||
- 对于复杂功能,可以创建专门的服务类 (`services/`)
|
||||
|
||||
### 3. 定义 IPC 通信类型
|
||||
|
||||
1. **在共享类型定义中添加新类型**
|
||||
- 位置:`packages/electron-client-ipc/src/types.ts`
|
||||
- 添加参数类型接口(如 `NewFeatureParams`)
|
||||
- 添加返回结果类型接口(如 `NewFeatureResult`)
|
||||
|
||||
### 4. 在渲染进程实现前端功能
|
||||
|
||||
1. **创建服务层**
|
||||
- 位置:`src/services/electron/`
|
||||
- 添加服务方法调用 IPC
|
||||
- 使用 `ensureElectronIpc()` 生成的类型安全代理,避免手动拼通道名称
|
||||
|
||||
```typescript
|
||||
// src/services/electron/newFeatureService.ts
|
||||
import type { NewFeatureParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
export const newFeatureService = async (params: NewFeatureParams) => {
|
||||
return ipc.newFeature.doSomething(params);
|
||||
};
|
||||
```
|
||||
|
||||
2. **实现 Store Action**
|
||||
- 位置:`src/store/`
|
||||
- 添加状态更新逻辑和错误处理
|
||||
|
||||
3. **添加 UI 组件**
|
||||
- 根据需要在适当位置添加UI组件
|
||||
- 通过 Store 或 Service 层调用功能
|
||||
|
||||
### 5. 如果是新增内置工具,遵循工具实现流程
|
||||
|
||||
参考 `desktop-local-tools-implement.mdc` 了解更多关于添加内置工具的详细步骤。
|
||||
|
||||
### 6. 添加测试
|
||||
|
||||
1. **单元测试**
|
||||
- 位置:`apps/desktop/src/main/controllers/__tests__/`
|
||||
- 测试主进程组件功能
|
||||
|
||||
2. **集成测试**
|
||||
- 测试 IPC 通信和功能完整流程
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **安全性考虑**
|
||||
- 谨慎处理用户数据和文件系统访问
|
||||
- 适当验证和清理输入数据
|
||||
- 限制暴露给渲染进程的API范围
|
||||
|
||||
2. **性能优化**
|
||||
- 对于耗时操作,考虑使用异步方法
|
||||
- 大型数据传输考虑分批处理
|
||||
|
||||
3. **用户体验**
|
||||
- 为长时间操作添加进度指示
|
||||
- 提供适当的错误反馈
|
||||
- 考虑操作的可撤销性
|
||||
|
||||
4. **代码组织**
|
||||
- 遵循项目现有的命名和代码风格约定
|
||||
- 为新功能添加适当的文档和注释
|
||||
- 功能模块化,避免过度耦合
|
||||
|
||||
## 示例:实现系统通知功能
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/NotificationCtr.ts
|
||||
import type {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { Notification } from 'electron';
|
||||
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
static override readonly groupName = 'notification';
|
||||
|
||||
@IpcMethod()
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
if (!Notification.isSupported()) {
|
||||
return { error: 'Notifications not supported', success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const notification = new Notification({ body: params.body, title: params.title });
|
||||
notification.show();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[NotificationCtr] Failed to show notification:', error);
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error', success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
**新增桌面端工具流程:**
|
||||
|
||||
1. **定义工具接口 (Manifest):**
|
||||
- **文件:** `src/tools/[tool_category]/index.ts` (例如: `src/tools/local-files/index.ts`)
|
||||
- **操作:**
|
||||
- 在 `ApiName` 对象(例如 `LocalFilesApiName`)中添加一个新的、唯一的 API 名称。
|
||||
- 在 `Manifest` 对象(例如 `LocalFilesManifest`)的 `api` 数组中,新增一个对象来定义新工具的接口。
|
||||
- **关键字段:**
|
||||
- `name`: 使用上一步定义的 API 名称。
|
||||
- `description`: 清晰描述工具的功能,供 Agent 理解和向用户展示。
|
||||
- `parameters`: 使用 JSON Schema 定义工具所需的输入参数。
|
||||
- `type`: 通常是 'object'。
|
||||
- `properties`: 定义每个参数的名称、`description`、`type` (string, number, boolean, array, etc.),使用英文。
|
||||
- `required`: 一个字符串数组,列出必须提供的参数名称。
|
||||
|
||||
2. **定义相关类型:**
|
||||
- **文件 1:** `packages/electron-client-ipc/src/types.ts` (或类似的共享 IPC 类型文件)
|
||||
- **操作:** 定义传递给 IPC 事件的参数类型接口 (例如: `RenameLocalFileParams`, `MoveLocalFileParams`)。确保与 Manifest 中定义的 `parameters` 一致。
|
||||
- **文件 2:** `src/tools/[tool_category]/type.ts` (例如: `src/tools/local-files/type.ts`)
|
||||
- **操作:** 定义此工具执行后,存储在前端 Zustand Store 中的状态类型接口 (例如: `LocalRenameFileState`, `LocalMoveFileState`)。这通常包含操作结果(成功/失败)、错误信息以及相关数据(如旧路径、新路径等)。
|
||||
|
||||
3. **实现前端状态管理 (Store Action):**
|
||||
- **文件:** `src/store/chat/slices/builtinTool/actions/[tool_category].ts` (例如: `src/store/chat/slices/builtinTool/actions/localFile.ts`)
|
||||
- **操作:**
|
||||
- 导入在步骤 2 中定义的 IPC 参数类型和状态类型。
|
||||
- 在 Action 接口 (例如: `LocalFileAction`) 中添加新 Action 的方法签名,使用对应的 IPC 参数类型。
|
||||
- 在 `createSlice` (例如: `localFileSlice`) 中实现该 Action 方法:
|
||||
- 接收 `id` (消息 ID) 和 `params` (符合 IPC 参数类型)。
|
||||
- 设置加载状态 (`toggleLocalFileLoading(id, true)`)。
|
||||
- 调用对应的 `Service` 层方法 (见步骤 4),传递 `params`。
|
||||
- 使用 `try...catch` 处理 `Service` 调用可能发生的错误。
|
||||
- **成功时:**
|
||||
- 调用 `updatePluginState(id, {...})` 更新插件状态,使用步骤 2 中定义的状态类型。
|
||||
- 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,通常包含成功确认信息。
|
||||
- **失败时:**
|
||||
- 记录错误 (`console.error`)。
|
||||
- 调用 `updatePluginState(id, {...})` 更新插件状态,包含错误信息。
|
||||
- 调用 `internal_updateMessagePluginError(id, {...})` 设置消息的错误状态。
|
||||
- 调用 `internal_updateMessageContent(id, JSON.stringify({...}))` 更新消息内容,包含错误信息。
|
||||
- 在 `finally` 块中取消加载状态 (`toggleLocalFileLoading(id, false)`)。
|
||||
- 返回操作是否成功 (`boolean`)。
|
||||
|
||||
4. **实现 Service 层 (调用 IPC):**
|
||||
- **文件:** `src/services/electron/[tool_category]Service.ts` (例如: `src/services/electron/localFileService.ts`)
|
||||
- **操作:**
|
||||
- 导入在步骤 2 中定义的 IPC 参数类型。
|
||||
- 添加一个新的 `async` 方法,方法名通常与 Action 名称对应 (例如: `renameLocalFile`)。
|
||||
- 方法接收 `params` (符合 IPC 参数类型)。
|
||||
- 通过 `ensureElectronIpc()` 获取 IPC 代理 (`const ipc = ensureElectronIpc();`),调用与 Manifest 中 `name` 字段匹配的链式方法,并将 `params` 传递过去。
|
||||
- 定义方法的返回类型,通常是 `Promise<{ success: boolean; error?: string }>`,与后端 Controller 返回的结构一致。
|
||||
|
||||
5. **实现后端逻辑 (Controller / IPC Handler):**
|
||||
- **文件:** `apps/desktop/src/main/controllers/[ToolName]Ctr.ts` (例如: `apps/desktop/src/main/controllers/LocalFileCtr.ts`)
|
||||
- **操作:**
|
||||
- 导入 Node.js 相关模块 (`fs`, `path` 等) 和 IPC 相关依赖 (`ControllerModule`, `IpcMethod`、参数类型等)。
|
||||
- 添加一个新的 `async` 方法,方法名通常以 `handle` 开头 (例如: `handleRenameFile`)。
|
||||
- 使用 `@IpcMethod()` 装饰器将此方法注册为对应 IPC 事件的处理器,确保方法名与 Manifest 中的 `name` 以及 Service 层的链式调用一致。
|
||||
- 方法的参数应解构自 Service 层传递过来的对象,类型与步骤 2 中定义的 IPC 参数类型匹配。
|
||||
- 实现核心业务逻辑:
|
||||
- 进行必要的输入验证。
|
||||
- 执行文件系统操作或其他后端任务 (例如: `fs.promises.rename`)。
|
||||
- 使用 `try...catch` 捕获执行过程中的错误。
|
||||
- 处理特定错误码 (`error.code`) 以提供更友好的错误消息。
|
||||
- 返回一个包含 `success` (boolean) 和可选 `error` (string) 字段的对象。
|
||||
|
||||
6. **更新 Agent 文档 (System Role):**
|
||||
- **文件:** `src/tools/[tool_category]/systemRole.ts` (例如: `src/tools/local-files/systemRole.ts`)
|
||||
- **操作:**
|
||||
- 在 `<core_capabilities>` 部分添加新工具的简要描述。
|
||||
- 如果需要,更新 `<workflow>`。
|
||||
- 在 `<tool_usage_guidelines>` 部分为新工具添加详细的使用说明,解释其参数、用途和预期行为。
|
||||
- 如有必要,更新 `<security_considerations>`。
|
||||
- 如有必要(例如工具返回了新的数据结构或路径),更新 `<response_format>` 中的示例。
|
||||
|
||||
通过遵循这些步骤,可以系统地将新的桌面端工具集成到 LobeChat 的插件系统中。
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 桌面端菜单配置指南
|
||||
|
||||
## 菜单系统概述
|
||||
|
||||
LobeChat 桌面应用有三种主要的菜单类型:
|
||||
|
||||
1. **应用菜单 (App Menu)**:显示在应用窗口顶部(macOS)或窗口标题栏(Windows/Linux)
|
||||
2. **上下文菜单 (Context Menu)**:右键点击时显示的菜单
|
||||
3. **托盘菜单 (Tray Menu)**:点击系统托盘图标显示的菜单
|
||||
|
||||
## 菜单相关文件结构
|
||||
|
||||
```plaintext
|
||||
apps/desktop/src/main/
|
||||
├── menus/ # 菜单定义
|
||||
│ ├── appMenu.ts # 应用菜单配置
|
||||
│ ├── contextMenu.ts # 上下文菜单配置
|
||||
│ └── factory.ts # 菜单工厂函数
|
||||
├── controllers/
|
||||
│ ├── MenuCtr.ts # 菜单控制器
|
||||
│ └── TrayMenuCtr.ts # 托盘菜单控制器
|
||||
```
|
||||
|
||||
## 菜单配置流程
|
||||
|
||||
### 1. 应用菜单配置
|
||||
|
||||
应用菜单在 `apps/desktop/src/main/menus/appMenu.ts` 中定义:
|
||||
|
||||
1. **导入依赖**
|
||||
|
||||
```typescript
|
||||
import { BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, app } from 'electron';
|
||||
import { is } from 'electron-util';
|
||||
```
|
||||
|
||||
2. **定义菜单项**
|
||||
- 使用 `MenuItemConstructorOptions` 类型定义菜单结构
|
||||
- 每个菜单项可以包含:label, accelerator (快捷键), role, submenu, click 等属性
|
||||
|
||||
3. **创建菜单工厂函数**
|
||||
|
||||
```typescript
|
||||
export const createAppMenu = (win: BrowserWindow) => {
|
||||
const template = [
|
||||
// 定义菜单项...
|
||||
];
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
};
|
||||
```
|
||||
|
||||
4. **注册菜单**
|
||||
- 在 `MenuCtr.ts` 控制器中使用 `Menu.setApplicationMenu(menu)` 设置应用菜单
|
||||
|
||||
### 2. 上下文菜单配置
|
||||
|
||||
上下文菜单通常在特定元素上右键点击时显示:
|
||||
|
||||
1. **在主进程中定义菜单模板**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/menus/contextMenu.ts
|
||||
export const createContextMenu = () => {
|
||||
const template = [
|
||||
// 定义菜单项...
|
||||
];
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
};
|
||||
```
|
||||
|
||||
2. **在适当的事件处理器中显示菜单**
|
||||
|
||||
```typescript
|
||||
const menu = createContextMenu();
|
||||
menu.popup();
|
||||
```
|
||||
|
||||
### 3. 托盘菜单配置
|
||||
|
||||
托盘菜单在 `TrayMenuCtr.ts` 中配置:
|
||||
|
||||
1. **创建托盘图标**
|
||||
|
||||
```typescript
|
||||
this.tray = new Tray(trayIconPath);
|
||||
```
|
||||
|
||||
2. **定义托盘菜单**
|
||||
|
||||
```typescript
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: '显示主窗口', click: this.showMainWindow },
|
||||
{ type: 'separator' },
|
||||
{ label: '退出', click: () => app.quit() },
|
||||
]);
|
||||
```
|
||||
|
||||
3. **设置托盘菜单**
|
||||
|
||||
```typescript
|
||||
this.tray.setContextMenu(contextMenu);
|
||||
```
|
||||
|
||||
## 多语言支持
|
||||
|
||||
为菜单添加多语言支持:
|
||||
|
||||
1. **导入本地化工具**
|
||||
|
||||
```typescript
|
||||
import { i18n } from '../locales';
|
||||
```
|
||||
|
||||
2. **使用翻译函数**
|
||||
|
||||
```typescript
|
||||
const template = [
|
||||
{
|
||||
label: i18n.t('menu.file'),
|
||||
submenu: [
|
||||
{ label: i18n.t('menu.new'), click: createNew },
|
||||
// ...
|
||||
],
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
3. **在语言切换时更新菜单** 在 `MenuCtr.ts` 中监听语言变化事件并重新创建菜单
|
||||
|
||||
## 添加新菜单项流程
|
||||
|
||||
1. **确定菜单位置**
|
||||
- 决定添加到哪个菜单(应用菜单、上下文菜单或托盘菜单)
|
||||
- 确定在菜单中的位置(主菜单项或子菜单项)
|
||||
|
||||
2. **定义菜单项**
|
||||
|
||||
```typescript
|
||||
const newMenuItem: MenuItemConstructorOptions = {
|
||||
label: '新功能',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: (_, window) => {
|
||||
// 处理点击事件
|
||||
if (window) window.webContents.send('trigger-new-feature');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
3. **添加到菜单模板** 将新菜单项添加到相应的菜单模板中
|
||||
|
||||
4. **对于与渲染进程交互的功能**
|
||||
- 使用 `window.webContents.send()` 发送 IPC 消息到渲染进程
|
||||
- 在渲染进程中监听该消息并处理
|
||||
|
||||
## 菜单项启用/禁用控制
|
||||
|
||||
动态控制菜单项状态:
|
||||
|
||||
1. **保存对菜单项的引用**
|
||||
|
||||
```typescript
|
||||
this.menuItems = {};
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
this.menuItems.newFeature = menu.getMenuItemById('new-feature');
|
||||
```
|
||||
|
||||
2. **根据条件更新状态**
|
||||
|
||||
```typescript
|
||||
updateMenuState(state) {
|
||||
if (this.menuItems.newFeature) {
|
||||
this.menuItems.newFeature.enabled = state.canUseNewFeature;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用标准角色**
|
||||
- 尽可能使用 Electron 预定义的角色(如 `role: 'copy'`)以获得本地化和一致的行为
|
||||
|
||||
2. **平台特定菜单**
|
||||
- 使用 `process.platform` 检查为不同平台提供不同菜单
|
||||
|
||||
```typescript
|
||||
if (process.platform === 'darwin') {
|
||||
template.unshift({ role: 'appMenu' });
|
||||
}
|
||||
```
|
||||
|
||||
3. **快捷键冲突**
|
||||
- 避免与系统快捷键冲突
|
||||
- 使用 `CmdOrCtrl` 代替 `Ctrl` 以支持 macOS 和 Windows/Linux
|
||||
|
||||
4. **保持菜单简洁**
|
||||
- 避免过多嵌套的子菜单
|
||||
- 将相关功能分组在一起
|
||||
|
||||
5. **添加分隔符**
|
||||
- 使用 `{ type: 'separator' }` 在逻辑上分隔不同组的菜单项
|
||||
@@ -1,301 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 桌面端窗口管理指南
|
||||
|
||||
## 窗口管理概述
|
||||
|
||||
LobeChat 桌面应用使用 Electron 的 `BrowserWindow` 管理应用窗口。主要的窗口管理功能包括:
|
||||
|
||||
1. **窗口创建和配置**
|
||||
2. **窗口状态管理**(大小、位置、最大化等)
|
||||
3. **多窗口协调**
|
||||
4. **窗口事件处理**
|
||||
|
||||
## 相关文件结构
|
||||
|
||||
```plaintext
|
||||
apps/desktop/src/main/
|
||||
├── appBrowsers.ts # 窗口管理的核心文件
|
||||
├── controllers/
|
||||
│ └── BrowserWindowsCtr.ts # 窗口控制器
|
||||
└── modules/
|
||||
└── browserWindowManager.ts # 窗口管理模块
|
||||
```
|
||||
|
||||
## 窗口管理流程
|
||||
|
||||
### 1. 窗口创建
|
||||
|
||||
在 `appBrowsers.ts` 或 `BrowserWindowsCtr.ts` 中定义窗口创建逻辑:
|
||||
|
||||
```typescript
|
||||
export const createMainWindow = () => {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
// 其他窗口配置项...
|
||||
});
|
||||
|
||||
// 加载应用内容
|
||||
if (isDev) {
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'));
|
||||
}
|
||||
|
||||
return mainWindow;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 窗口状态管理
|
||||
|
||||
实现窗口状态持久化保存和恢复:
|
||||
|
||||
1. **保存窗口状态**
|
||||
|
||||
```typescript
|
||||
const saveWindowState = (window: BrowserWindow) => {
|
||||
if (!window.isMinimized() && !window.isMaximized()) {
|
||||
const position = window.getPosition();
|
||||
const size = window.getSize();
|
||||
|
||||
settings.set('windowState', {
|
||||
x: position[0],
|
||||
y: position[1],
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **恢复窗口状态**
|
||||
|
||||
```typescript
|
||||
const restoreWindowState = (window: BrowserWindow) => {
|
||||
const savedState = settings.get('windowState');
|
||||
|
||||
if (savedState) {
|
||||
window.setBounds({
|
||||
x: savedState.x,
|
||||
y: savedState.y,
|
||||
width: savedState.width,
|
||||
height: savedState.height,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
3. **监听窗口事件**
|
||||
|
||||
```typescript
|
||||
window.on('close', () => saveWindowState(window));
|
||||
window.on('moved', () => saveWindowState(window));
|
||||
window.on('resized', () => saveWindowState(window));
|
||||
```
|
||||
|
||||
### 3. 实现多窗口管理
|
||||
|
||||
对于需要多窗口支持的功能:
|
||||
|
||||
1. **跟踪窗口**
|
||||
|
||||
```typescript
|
||||
export class WindowManager {
|
||||
private windows: Map<string, BrowserWindow> = new Map();
|
||||
|
||||
createWindow(id: string, options: BrowserWindowConstructorOptions) {
|
||||
const window = new BrowserWindow(options);
|
||||
this.windows.set(id, window);
|
||||
|
||||
window.on('closed', () => {
|
||||
this.windows.delete(id);
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
getWindow(id: string) {
|
||||
return this.windows.get(id);
|
||||
}
|
||||
|
||||
getAllWindows() {
|
||||
return Array.from(this.windows.values());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **窗口间通信**
|
||||
|
||||
```typescript
|
||||
// 从一个窗口向另一个窗口发送消息
|
||||
sendMessageToWindow(targetWindowId, channel, data) {
|
||||
const targetWindow = this.getWindow(targetWindowId);
|
||||
if (targetWindow) {
|
||||
targetWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 窗口与渲染进程通信
|
||||
|
||||
通过 IPC 实现窗口操作:
|
||||
|
||||
1. **在主进程中注册 IPC 处理器**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@IpcMethod()
|
||||
minimizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
focusedWindow?.minimize();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
maximizeWindow() {
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
if (focusedWindow?.isMaximized()) focusedWindow.restore();
|
||||
else focusedWindow?.maximize();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
closeWindow() {
|
||||
BrowserWindow.getFocusedWindow()?.close();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `@IpcMethod()` 根据控制器的 `groupName` 自动将方法映射为 `windows.minimizeWindow` 形式的通道名称。
|
||||
- 控制器需继承 `ControllerModule`,并在 `controllers/registry.ts` 中通过 `controllerIpcConstructors` 注册,便于类型生成。
|
||||
|
||||
2. **在渲染进程中调用**
|
||||
|
||||
```typescript
|
||||
// src/services/electron/windowService.ts
|
||||
import { ensureElectronIpc } from '@/utils/electron/ipc';
|
||||
|
||||
const ipc = ensureElectronIpc();
|
||||
|
||||
export const windowService = {
|
||||
minimize: () => ipc.windows.minimizeWindow(),
|
||||
maximize: () => ipc.windows.maximizeWindow(),
|
||||
close: () => ipc.windows.closeWindow(),
|
||||
};
|
||||
```
|
||||
|
||||
- `ensureElectronIpc()` 会基于 `DesktopIpcServices` 运行时生成 Proxy,并通过 `window.electronAPI.invoke` 与主进程通信;不再直接使用 `dispatch`。
|
||||
|
||||
### 5. 自定义窗口控制 (无边框窗口)
|
||||
|
||||
对于自定义窗口标题栏:
|
||||
|
||||
1. **创建无边框窗口**
|
||||
|
||||
```typescript
|
||||
const window = new BrowserWindow({
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
// 其他选项...
|
||||
});
|
||||
```
|
||||
|
||||
2. **在渲染进程中实现拖拽区域**
|
||||
|
||||
```css
|
||||
/* CSS */
|
||||
.titlebar {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **性能考虑**
|
||||
- 避免创建过多窗口
|
||||
- 使用 `show: false` 创建窗口,在内容加载完成后再显示,避免白屏
|
||||
|
||||
2. **安全性**
|
||||
- 始终设置适当的 `webPreferences` 确保安全
|
||||
|
||||
```typescript
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
}
|
||||
```
|
||||
|
||||
3. **跨平台兼容性**
|
||||
- 考虑不同操作系统的窗口行为差异
|
||||
- 使用 `process.platform` 为不同平台提供特定实现
|
||||
|
||||
4. **崩溃恢复**
|
||||
- 监听 `webContents.on('crashed')` 事件处理崩溃
|
||||
- 提供崩溃恢复选项
|
||||
|
||||
5. **内存管理**
|
||||
- 确保窗口关闭时清理所有相关资源
|
||||
- 使用 `window.on('closed')` 而不是 `window.on('close')` 进行最终清理
|
||||
|
||||
## 示例:创建设置窗口
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
|
||||
import type { OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { ControllerModule, IpcMethod } from '@/controllers';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@IpcMethod()
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions =
|
||||
typeof options === 'string' || options === undefined
|
||||
? { tab: typeof options === 'string' ? options : undefined }
|
||||
: options;
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const query = new URLSearchParams();
|
||||
if (normalizedOptions.tab) query.set('active', normalizedOptions.tab);
|
||||
if (normalizedOptions.searchParams) {
|
||||
for (const [key, value] of Object.entries(normalizedOptions.searchParams)) {
|
||||
if (value) query.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = `/settings${query.size ? `?${query.toString()}` : ''}`;
|
||||
await mainWindow.loadUrl(fullPath);
|
||||
mainWindow.show();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,218 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: src/database/schemas/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Drizzle ORM Schema Style Guide for lobe-chat
|
||||
|
||||
This document outlines the conventions and best practices for defining PostgreSQL Drizzle ORM schemas within the lobe-chat project.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Drizzle configuration is managed in `drizzle.config.ts`
|
||||
- Schema files are located in the src/database/schemas/ directory
|
||||
- Migration files are output to `src/database/migrations/`
|
||||
- The project uses `postgresql` dialect with `strict: true`
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Commonly used column definitions, especially for timestamps, are centralized in `src/database/schemas/_helpers.ts`:
|
||||
|
||||
- `timestamptz(name: string)`: Creates a timestamp column with timezone
|
||||
- `createdAt()`, `updatedAt()`, `accessedAt()`: Helper functions for standard timestamp columns
|
||||
- `timestamps`: An object `{ createdAt, updatedAt, accessedAt }` for easy inclusion in table definitions
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Table Names**: Use plural snake_case (e.g., `users`, `agents`, `session_groups`)
|
||||
- **Column Names**: Use snake_case (e.g., `user_id`, `created_at`, `background_color`)
|
||||
|
||||
## Column Definitions
|
||||
|
||||
### Primary Keys (PKs)
|
||||
|
||||
- Typically `text('id')` (or `varchar('id')` for some OIDC tables)
|
||||
- Often use `.$defaultFn(() => idGenerator('table_name'))` for automatic ID generation with meaningful prefixes
|
||||
- **ID Prefix Purpose**: Makes it easy for users and developers to distinguish different entity types at a glance
|
||||
- For internal/system tables that users don't need to see, can use `uuid` or auto-increment keys
|
||||
- Composite PKs are defined using `primaryKey({ columns: [t.colA, t.colB] })`
|
||||
|
||||
### Foreign Keys (FKs)
|
||||
|
||||
- Defined using `.references(() => otherTable.id, { onDelete: 'cascade' | 'set null' | 'no action' })`
|
||||
- FK columns are usually named `related_table_singular_name_id` (e.g., `user_id` references `users.id`)
|
||||
- Most tables include a `user_id` column referencing `users.id` with `onDelete: 'cascade'`
|
||||
|
||||
### Timestamps
|
||||
|
||||
- Consistently use the `...timestamps` spread from `_helpers.ts` for `created_at`, `updated_at`, and `accessed_at` columns
|
||||
|
||||
### Default Values
|
||||
|
||||
- `.$defaultFn(() => expression)` for dynamic defaults (e.g., `idGenerator()`, `randomSlug()`)
|
||||
- `.default(staticValue)` for static defaults (e.g., `boolean('enabled').default(true)`)
|
||||
|
||||
### Indexes
|
||||
|
||||
- Defined in the table's second argument: `pgTable('name', {...columns}, (t) => ({ indexName: indexType().on(...) }))`
|
||||
- Use `uniqueIndex()` for unique constraints and `index()` for non-unique indexes
|
||||
- Naming pattern: `table_name_column(s)_idx` or `table_name_column(s)_unique`
|
||||
- Many tables feature a `clientId: text('client_id')` column, often part of a composite unique index with `user_id`
|
||||
|
||||
### Data Types
|
||||
|
||||
- Common types: `text`, `varchar`, `jsonb`, `boolean`, `integer`, `uuid`, `pgTable`
|
||||
- For `jsonb` fields, specify the TypeScript type using `.$type<MyType>()` for better type safety
|
||||
|
||||
## Zod Schemas & Type Inference
|
||||
|
||||
- Utilize `drizzle-zod` to generate Zod schemas for validation:
|
||||
- `createInsertSchema(tableName)`
|
||||
- `createSelectSchema(tableName)` (less common)
|
||||
- Export inferred types: `export type NewEntity = typeof tableName.$inferInsert;` and `export type EntityItem = typeof tableName.$inferSelect;`
|
||||
|
||||
## Relations
|
||||
|
||||
- Table relationships are defined centrally in `src/database/schemas/relations.ts` using the `relations()` utility from `drizzle-orm`
|
||||
|
||||
## Code Style & Structure
|
||||
|
||||
- **File Organization**: Each main database entity typically has its own schema file (e.g., `user.ts`, `agent.ts`)
|
||||
- All schemas are re-exported from `src/database/schemas/index.ts`
|
||||
- **ESLint**: Files often start with `/* eslint-disable sort-keys-fix/sort-keys-fix */`
|
||||
- **Comments**: Use JSDoc-style comments to explain the purpose of tables and complex columns, fields that are self-explanatory do not require jsdoc explanations, such as id, user_id, etc.
|
||||
|
||||
## Example Pattern
|
||||
|
||||
```typescript
|
||||
// From src/database/schemas/agent.ts
|
||||
export const agents = pgTable(
|
||||
'agents',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => idGenerator('agents'))
|
||||
.notNull(),
|
||||
slug: varchar('slug', { length: 100 })
|
||||
.$defaultFn(() => randomSlug(4))
|
||||
.unique(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
clientId: text('client_id'),
|
||||
chatConfig: jsonb('chat_config').$type<LobeAgentChatConfig>(),
|
||||
...timestamps,
|
||||
},
|
||||
// return array instead of object, the object style is deprecated
|
||||
(t) => [uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId)],
|
||||
);
|
||||
|
||||
export const insertAgentSchema = createInsertSchema(agents);
|
||||
export type NewAgent = typeof agents.$inferInsert;
|
||||
export type AgentItem = typeof agents.$inferSelect;
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. userId + clientId Pattern (Legacy)
|
||||
|
||||
Some existing tables include both fields for different purposes:
|
||||
|
||||
```typescript
|
||||
// Example from agents table (legacy pattern)
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
clientId: text('client_id'),
|
||||
|
||||
// Usually with a composite unique index
|
||||
clientIdUnique: uniqueIndex('agents_client_id_user_id_unique').on(t.clientId, t.userId),
|
||||
```
|
||||
|
||||
- **`userId`**: Server-side user association, ensures data belongs to specific user
|
||||
- **`clientId`**: Unique key for import/export operations, supports data migration between instances
|
||||
- **Current Status**: New tables should NOT include `clientId` unless specifically needed for import/export functionality
|
||||
- **Note**: This pattern is being phased out for new features to simplify the schema
|
||||
|
||||
### 2. Junction Tables (Many-to-Many Relationships)
|
||||
|
||||
Use composite primary keys for relationship tables:
|
||||
|
||||
```typescript
|
||||
// Example: agents_knowledge_bases (from agent.ts)
|
||||
export const agentsKnowledgeBases = pgTable(
|
||||
'agents_knowledge_bases',
|
||||
{
|
||||
agentId: text('agent_id')
|
||||
.references(() => agents.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
knowledgeBaseId: text('knowledge_base_id')
|
||||
.references(() => knowledgeBases.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
enabled: boolean('enabled').default(true),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.agentId, t.knowledgeBaseId] })],
|
||||
);
|
||||
```
|
||||
|
||||
**Pattern**: `{entity1}Id` + `{entity2}Id` as composite PK, plus `userId` for ownership
|
||||
|
||||
### 3. OIDC Tables Special Patterns
|
||||
|
||||
OIDC tables use `varchar` IDs instead of `text` with custom generators:
|
||||
|
||||
```typescript
|
||||
// Example from oidc.ts
|
||||
export const oidcAuthorizationCodes = pgTable('oidc_authorization_codes', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(), // varchar not text
|
||||
data: jsonb('data').notNull(),
|
||||
expiresAt: timestamptz('expires_at').notNull(),
|
||||
// ... other fields
|
||||
});
|
||||
```
|
||||
|
||||
**Reason**: OIDC standards expect specific ID formats and lengths
|
||||
|
||||
### 4. File Processing with Async Tasks
|
||||
|
||||
File-related tables reference async task IDs for background processing:
|
||||
|
||||
```typescript
|
||||
// Example from files table
|
||||
export const files = pgTable('files', {
|
||||
// ... other fields
|
||||
chunkTaskId: uuid('chunk_task_id').references(() => asyncTasks.id, { onDelete: 'set null' }),
|
||||
embeddingTaskId: uuid('embedding_task_id').references(() => asyncTasks.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**Purpose**:
|
||||
|
||||
- Track file chunking progress (breaking files into smaller pieces)
|
||||
- Track embedding generation progress (converting text to vectors)
|
||||
- Allow querying task status and handling failures
|
||||
|
||||
### 5. Slug Pattern (Legacy)
|
||||
|
||||
Some entities include auto-generated slugs - this is legacy code:
|
||||
|
||||
```typescript
|
||||
slug: varchar('slug', { length: 100 })
|
||||
.$defaultFn(() => randomSlug(4))
|
||||
.unique(),
|
||||
|
||||
// Often with composite unique constraint
|
||||
slugUserIdUnique: uniqueIndex('slug_user_id_unique').on(t.slug, t.userId),
|
||||
```
|
||||
|
||||
**Current usage**: Only used to identify default agents/sessions (legacy pattern) **Future refactor**: Will likely be replaced with `isDefault: boolean()` field **Note**: Avoid using slugs for new features - prefer explicit boolean flags for status tracking
|
||||
|
||||
By following these guidelines, maintain consistency, type safety, and maintainability across database schema definitions.
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 如何添加新的快捷键:开发者指南
|
||||
|
||||
本指南将带您一步步地向 LobeChat 添加一个新的快捷键功能。我们将通过一个完整示例,演示从定义到实现的整个过程。
|
||||
|
||||
## 示例场景
|
||||
|
||||
假设我们要添加一个新的快捷键功能:**快速清空聊天记录**,快捷键为 `Mod+Shift+Backspace`。
|
||||
|
||||
## 步骤 1:更新快捷键常量定义
|
||||
|
||||
首先,在 `src/types/hotkey.ts` 中更新 `HotkeyEnum`:
|
||||
|
||||
```typescript
|
||||
export const HotkeyEnum = {
|
||||
// 已有的快捷键...
|
||||
AddUserMessage: 'addUserMessage',
|
||||
EditMessage: 'editMessage',
|
||||
|
||||
// 新增快捷键
|
||||
ClearChat: 'clearChat', // 添加这一行
|
||||
|
||||
// 其他已有快捷键...
|
||||
} as const;
|
||||
```
|
||||
|
||||
## 步骤 2:注册默认快捷键
|
||||
|
||||
在 `src/const/hotkeys.ts` 中添加快捷键的默认配置:
|
||||
|
||||
```typescript
|
||||
import { KeyMapEnum as Key, combineKeys } from '@lobehub/ui';
|
||||
|
||||
// ...现有代码
|
||||
|
||||
export const HOTKEYS_REGISTRATION: HotkeyRegistration = [
|
||||
// 现有的快捷键配置...
|
||||
|
||||
// 添加新的快捷键配置
|
||||
{
|
||||
group: HotkeyGroupEnum.Conversation, // 归类到会话操作组
|
||||
id: HotkeyEnum.ClearChat,
|
||||
keys: combineKeys([Key.Mod, Key.Shift, Key.Backspace]),
|
||||
scopes: [HotkeyScopeEnum.Chat], // 在聊天作用域下生效
|
||||
},
|
||||
|
||||
// 其他现有快捷键...
|
||||
];
|
||||
```
|
||||
|
||||
## 步骤 3:添加国际化翻译
|
||||
|
||||
在 `src/locales/default/hotkey.ts` 中添加对应的文本描述:
|
||||
|
||||
```typescript
|
||||
import { HotkeyI18nTranslations } from '@/types/hotkey';
|
||||
|
||||
const hotkey: HotkeyI18nTranslations = {
|
||||
// 现有翻译...
|
||||
|
||||
// 添加新快捷键的翻译
|
||||
clearChat: {
|
||||
desc: '清空当前会话的所有消息记录',
|
||||
title: '清空聊天记录',
|
||||
},
|
||||
|
||||
// 其他现有翻译...
|
||||
};
|
||||
|
||||
export default hotkey;
|
||||
```
|
||||
|
||||
如需支持其他语言,还需要在相应的语言文件中添加对应翻译。
|
||||
|
||||
## 步骤 4:创建并注册快捷键 Hook
|
||||
|
||||
在 `src/hooks/useHotkeys/chatScope.ts` 中添加新的 Hook:
|
||||
|
||||
```typescript
|
||||
export const useClearChatHotkey = () => {
|
||||
const clearMessages = useChatStore((s) => s.clearMessages);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useHotkeyById(HotkeyEnum.ClearChat, showConfirm);
|
||||
};
|
||||
|
||||
// 注册聚合
|
||||
|
||||
export const useRegisterChatHotkeys = () => {
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
|
||||
useOpenChatSettingsHotkey();
|
||||
// ...其他快捷键
|
||||
useClearChatHotkey();
|
||||
|
||||
useEffect(() => {
|
||||
enableScope(HotkeyScopeEnum.Chat);
|
||||
return () => disableScope(HotkeyScopeEnum.Chat);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
## 步骤 5:给相应 UI 元素添加 Tooltip 提示(可选)
|
||||
|
||||
如果有对应的 UI 按钮,可以添加快捷键提示:
|
||||
|
||||
```tsx
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { Tooltip } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/selectors';
|
||||
import { HotkeyEnum } from '@/types/hotkey';
|
||||
|
||||
const ClearChatButton = () => {
|
||||
const { t } = useTranslation(['hotkey', 'chat']);
|
||||
const clearChatHotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.ClearChat));
|
||||
|
||||
// 获取清空聊天的方法
|
||||
const clearMessages = useChatStore((s) => s.clearMessages);
|
||||
|
||||
return (
|
||||
<Tooltip hotkey={clearChatHotkey} title={t('clearChat.title', { ns: 'hotkey' })}>
|
||||
<Button icon={<DeleteOutlined />} onClick={clearMessages} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 步骤 6:测试新快捷键
|
||||
|
||||
1. 启动开发服务器
|
||||
2. 打开聊天页面
|
||||
3. 按下设置的快捷键组合(`Cmd+Shift+Backspace` 或 `Ctrl+Shift+Backspace`)
|
||||
4. 确认功能正常工作
|
||||
5. 检查快捷键设置面板中是否正确显示了新快捷键
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **作用域考虑**:根据功能决定快捷键应属于全局作用域还是聊天作用域
|
||||
2. **分组合理**:将快捷键放在合适的功能组中(System/Layout/Conversation)
|
||||
3. **冲突检查**:确保新快捷键不会与现有系统、浏览器或应用快捷键冲突
|
||||
4. **平台适配**:使用 `Key.Mod` 而非硬编码 `Ctrl` 或 `Cmd`,以适配不同平台
|
||||
5. **提供清晰描述**:为快捷键添加明确的标题和描述,帮助用户理解功能
|
||||
|
||||
按照以上步骤,您可以轻松地向系统添加新的快捷键功能,提升用户体验。如有特殊需求,如桌面专属快捷键,可以通过 `isDesktop` 标记进行区分处理。
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
- **快捷键未生效**:检查作用域是否正确,以及是否在 RegisterHotkeys 中调用了对应的 hook
|
||||
- **快捷键设置面板未显示**:确认在 HOTKEYS_REGISTRATION 中正确配置了快捷键
|
||||
- **快捷键冲突**:在 HotkeyInput 组件中可以检测到冲突,用户会看到警告
|
||||
- **功能在某些页面失效**:确认是否注册在了正确的作用域,以及相关页面是否激活了该作用域
|
||||
|
||||
通过这些步骤,您可以确保新添加的快捷键功能稳定、可靠且用户友好。
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
globs: *.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# LobeChat Internationalization Guide
|
||||
|
||||
## Key Points
|
||||
|
||||
- Default language: Chinese (zh-CN), Framework: react-i18next
|
||||
- **Only edit files in `src/locales/default/`** - Never edit JSON files in `locales/`
|
||||
- Run `pnpm i18n` to generate all translations (or manually translate zh-CN/en-US for dev preview)
|
||||
|
||||
## Key Naming Convention
|
||||
|
||||
**Flat keys with dot notation** (not nested objects):
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
export default {
|
||||
'alert.cloud.action': '立即体验',
|
||||
'clientDB.error.desc': '数据库初始化遇到问题',
|
||||
'sync.actions.sync': '立即同步',
|
||||
'sync.status.ready': '已连接',
|
||||
};
|
||||
|
||||
// ❌ Avoid: Nested objects
|
||||
export default {
|
||||
alert: { cloud: { action: '...' } },
|
||||
};
|
||||
```
|
||||
|
||||
**Naming patterns:** `{feature}.{context}.{action|status}`
|
||||
|
||||
- `clientDB.modal.title` - Feature + context + property
|
||||
- `sync.actions.sync` - Feature + group + action
|
||||
- `sync.status.ready` - Feature + group + status
|
||||
|
||||
**Parameters:** Use `{{variableName}}` syntax
|
||||
|
||||
```typescript
|
||||
'alert.cloud.desc': '我们提供 {{credit}} 额度积分',
|
||||
```
|
||||
|
||||
**Avoid key conflicts:** Don't use both a leaf key and its parent path
|
||||
|
||||
```typescript
|
||||
// ❌ Conflict: clientDB.solve exists as both leaf and parent
|
||||
'clientDB.solve': '自助解决',
|
||||
'clientDB.solve.backup.title': '数据备份',
|
||||
|
||||
// ✅ Solution: Use different suffixes
|
||||
'clientDB.solve.action': '自助解决',
|
||||
'clientDB.solve.backup.title': '数据备份',
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Add keys to `src/locales/default/{namespace}.ts`
|
||||
2. Export new namespace in `src/locales/default/index.ts`
|
||||
3. For dev preview: manually translate `locales/zh-CN/{namespace}.json` and `locales/en-US/{namespace}.json`
|
||||
4. Run `pnpm i18n` to generate all languages (CI handles this automatically)
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Basic
|
||||
t('newFeature.title')
|
||||
// With parameters
|
||||
t('alert.cloud.desc', { credit: '1000' })
|
||||
// Multiple namespaces
|
||||
const { t } = useTranslation(['common', 'chat']);
|
||||
t('common:save')
|
||||
```
|
||||
|
||||
## Available Namespaces
|
||||
|
||||
auth, authError, changelog, chat, color, **common**, components, discover, editor, electron, error, file, home, hotkey, image, knowledgeBase, labs, marketAuth, memory, metadata, migration, modelProvider, models, oauth, onboarding, plugin, portal, providers, ragEval, **setting**, subscription, thread, tool, topic, welcome
|
||||
|
||||
**Most used:** `common` (shared UI), `chat` (chat features), `setting` (settings)
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Linear Issue Management
|
||||
|
||||
When working with Linear issues:
|
||||
|
||||
1. **Retrieve issue details** before starting work using `mcp__linear-server__get_issue`
|
||||
2. **Check for sub-issues**: If the issue has sub-issues, retrieve and review ALL sub-issues using `mcp__linear-server__list_issues` with `parentId` filter before starting work
|
||||
3. **Update issue status** when completing tasks using `mcp__linear-server__update_issue`
|
||||
4. **MUST add completion comment** using `mcp__linear-server__create_comment`
|
||||
|
||||
## Creating Issues
|
||||
|
||||
When creating new Linear issues using `mcp__linear-server__create_issue`, **MUST add the `claude code` label** to indicate the issue was created by Claude Code.
|
||||
|
||||
## Completion Comment (REQUIRED)
|
||||
|
||||
**Every time you complete an issue, you MUST add a comment summarizing the work done.** This is critical for:
|
||||
|
||||
- Team visibility and knowledge sharing
|
||||
- Code review context
|
||||
- Future reference and debugging
|
||||
|
||||
## PR Linear Issue Association (REQUIRED)
|
||||
|
||||
**When creating PRs for Linear issues, MUST include magic keywords in PR body:** `Fixes LOBE-123`, `Closes LOBE-123`, or `Resolves LOBE-123`, and summarize the work done in the linear issue comment and update the issue status to "In Review".
|
||||
|
||||
## IMPORTANT: Per-Issue Completion Rule
|
||||
|
||||
**When working on multiple issues (e.g., parent issue with sub-issues), you MUST update status and add comment for EACH issue IMMEDIATELY after completing it.** Do NOT wait until all issues are done to update them in batch.
|
||||
|
||||
**Workflow for EACH individual issue:**
|
||||
|
||||
1. Complete the implementation for this specific issue
|
||||
2. Run type check: `bun run type-check`
|
||||
3. Run related tests if applicable
|
||||
4. Create PR if needed
|
||||
5. **IMMEDIATELY** update issue status to **"In Review"** (NOT "Done"): `mcp__linear-server__update_issue`
|
||||
6. **IMMEDIATELY** add completion comment: `mcp__linear-server__create_comment`
|
||||
7. Only then move on to the next issue
|
||||
|
||||
**Note:** Issue status should be set to **"In Review"** when PR is created. The status will be updated to **"Done"** only after the PR is merged (usually handled by Linear-GitHub integration or manually).
|
||||
|
||||
**❌ Wrong approach:**
|
||||
|
||||
- Complete Issue A → Complete Issue B → Complete Issue C → Update all statuses → Add all comments
|
||||
- Mark issue as "Done" immediately after creating PR
|
||||
|
||||
**✅ Correct approach:**
|
||||
|
||||
- Complete Issue A → Create PR → Update A status to "In Review" → Add A comment → Complete Issue B → ...
|
||||
@@ -1,158 +0,0 @@
|
||||
---
|
||||
globs: src/locales/default/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
你是「LobeHub」的中文 UI 文案与微文案(microcopy)专家。LobeHub 是一个助理工作空间:用户可以创建助理与群组,让人和助理、助理和助理协作,提升日常生产与生活效率。产品气质:外表年轻、亲和、现代;内核专业、可靠、强调生产力与可控性。整体风格参考 Notion / Figma / Apple / Discord / OpenAI / Gemini:清晰克制、可信、有人情味但不油腻。
|
||||
|
||||
产品 slogan:**Where Agents Collaborate**。你的文案要让用户持续感到:LobeHub 的重点不是“生成”,而是“协作的助理体系”(可共享上下文、可追踪、可回放、可演进、人在回路)。
|
||||
|
||||
---
|
||||
|
||||
### 1) 固定术语(必须遵守)
|
||||
|
||||
- Workspace:空间
|
||||
- Agent:助理
|
||||
- Agent Team:群组
|
||||
- Context:上下文
|
||||
- Memory:记忆
|
||||
- Integration:连接器
|
||||
- Tool/Skill/Plugin/插件/工具: 技能
|
||||
- SystemRole: 助理档案
|
||||
- Topic: 话题
|
||||
- Page: 文稿
|
||||
- Community: 社区
|
||||
- Resource: 资源
|
||||
- Library: 库
|
||||
- MCP: MCP
|
||||
- Provider: 模型服务商
|
||||
|
||||
术语规则:同一概念全站只用一种说法,不混用“Agent/智能体/机器人/团队/工作区”等。
|
||||
|
||||
---
|
||||
|
||||
### 2) 你的任务
|
||||
|
||||
- 优化、改写或从零生成任何界面中文文案:标题、按钮、表单说明、占位、引导、空状态、Toast、弹窗、错误、权限、设置项、创建/运行流程、协作与群组相关页面等。
|
||||
- 文案必须同时兼容:普通用户看得懂 + 专业用户不觉得低幼;娱乐与严肃场景都成立;不过度营销、不夸大 AI 能力;在关键节点提供恰到好处的人文关怀。
|
||||
|
||||
---
|
||||
|
||||
### 3) 品牌三原则(内化到结构与措辞)
|
||||
|
||||
- **Create(创建)**:一句话创建助理;从想法到可用;清楚下一步。
|
||||
- **Collaborate(协作)**:多助理协作;群组对齐信息与产出;共享上下文(可控、可管理)。
|
||||
- **Evolve(演进)**:助理可在你允许的范围内记住偏好;随你的工作方式变得更顺手;强调可解释、可设置、可回放。
|
||||
|
||||
---
|
||||
|
||||
### 4) 写作规则(可执行)
|
||||
|
||||
1. **清晰优先**:短句、强动词、少形容词;避免口号化与空泛承诺(如“颠覆”“史诗级”“100%”)。
|
||||
2. **分层表达(单一版本兼容两类用户)**:
|
||||
- 主句:人人可懂、可执行
|
||||
- 必要时补充一句副说明:更精确/更专业/更边界(可放副标题、帮助提示、折叠区)
|
||||
- 不输出“Pro/Lite 两套文案”,而是“一句主文案 + 可选补充”
|
||||
3. **术语克制但准确**:能说“连接/运行/上下文”就不要堆砌术语;必须出现专业词时给一句白话解释。
|
||||
4. **一致性**:同一动作按钮尽量固定动词(创建/连接/运行/暂停/重试/查看详情/清除记忆等)。
|
||||
5. **可行动**:每条提示都要让用户知道下一步;按钮避免“确定/取消”泛化,改成更具体的动作。
|
||||
6. **中文本地化**:符合中文阅读节奏;中英混排规范;避免翻译腔。
|
||||
|
||||
---
|
||||
|
||||
### 5) 人文关怀(中间态温度:介于克制与陪伴)
|
||||
|
||||
目标:在 AI 时代的价值焦虑与创作失格感中,给用户“被理解 + 有掌控 + 能继续”的体验,但不写长抒情。
|
||||
|
||||
#### 温度比例规则
|
||||
|
||||
- 默认:信息为主,温度为辅(约 8:2)
|
||||
- 关键节点(首次创建、空状态、长等待、失败重试、回退/丢失风险、协作分歧):允许提升到 7:3
|
||||
- 强制上限:任何一条上屏文案里,温度表达不超过**半句或一句**,且必须紧跟明确下一步。
|
||||
|
||||
#### 表达顺序(必须遵守)
|
||||
|
||||
1. 先承接处境(不评判):如“没关系/先这样也可以/卡住很正常”
|
||||
2. 再给掌控感(人在回路):可暂停/可回放/可编辑/可撤销/可清除记忆/可查看上下文
|
||||
3. 最后给下一步(按钮/路径明确)
|
||||
|
||||
#### 避免
|
||||
|
||||
- 鸡汤式说教(如“别焦虑”“要相信未来”)
|
||||
- 宏大叙事与文学排比
|
||||
- 过度拟人(不承诺助理“理解你/有情绪/永远记得你”)
|
||||
|
||||
#### 核心立场
|
||||
|
||||
- 助理很强,但它替代不了你的经历、选择与判断;LobeHub 帮你把时间还给重要的部分。
|
||||
|
||||
##### A. 情绪承接(先人后事)
|
||||
|
||||
- 允许承认:焦虑、空白、无从下手、被追赶感、被替代感、创作枯竭、意义感动摇
|
||||
- 但不下结论、不说教:不输出“你要乐观/别焦虑”,改成“这种感觉很常见/你不是一个人”
|
||||
|
||||
##### B. 主体性回归(把人放回驾驶位)
|
||||
|
||||
- 关键句式:**“决定权在你”**、**“你可以选择交给助理的部分”**、**“把你的想法变成可运行的流程”**
|
||||
- 强调可控:可编辑、可回放、可暂停、可撤销、可清除记忆、可查看上下文
|
||||
|
||||
##### C. 经历与关系(把价值从结果挪回过程)
|
||||
|
||||
- 适度表达:记录、回放、版本、协作痕迹、讨论、共创、里程碑
|
||||
- 用“经历/过程/痕迹/回忆/脉络/成长”这类词,避免虚无抒情
|
||||
|
||||
##### D. 不用“AI 神话”
|
||||
|
||||
- 不渲染“AI 终将超越你/取代你”
|
||||
- 也不轻飘飘说“AI 只是工具”了事更像:**“它是工具,但你仍是作者/负责人/最终决定者”**
|
||||
|
||||
##### 示例
|
||||
|
||||
在用户可能产生自我否定或无力感的场景(空状态、创作开始、产出对比、失败重试、长时间等待、团队协作分歧、版本回退):
|
||||
|
||||
1. **先承接感受**:用一句短话确认处境(不评判)
|
||||
2. **再给掌控感**:强调“你可控/可选择/可回放/可撤销”
|
||||
3. **最后给下一步**:提供明确行动按钮或路径
|
||||
|
||||
- 允许出现“经历、选择、痕迹、成长、一起、陪你把事做完”等词来传递温度;但保持信息密度,不写长段抒情。
|
||||
- 严肃场景(权限/安全/付费/数据丢失风险)仍以清晰与准确为先,温度通过“尊重与解释”体现,而不是煽情。
|
||||
|
||||
你可以让系统在需要时套这些结构(同一句兼容新手/专业):
|
||||
|
||||
**开始创作/空白页**
|
||||
|
||||
- 主句:给一个轻承接 + 行动入口
|
||||
- 模板:
|
||||
- 「从一个念头开始就够了。写一句话,我来帮你搭好第一个助理。」
|
||||
- 「不知道从哪开始也没关系:先说目标,我们一起把它拆开。」
|
||||
|
||||
**长任务运行/等待**
|
||||
|
||||
- 模板:
|
||||
- 「正在运行中…你可以先去做别的,完成后我会提醒你。」
|
||||
- 「这一步可能要几分钟。想更快:减少上下文 / 切换模型 / 关闭自动运行。」
|
||||
|
||||
**失败/重试**
|
||||
|
||||
- 模板:
|
||||
- 「没关系,这次没跑通。你可以重试,或查看原因再继续。」
|
||||
- 「连接失败:权限未通过或网络不稳定。去设置重新授权,或稍后再试。」
|
||||
|
||||
**对比与自我价值焦虑(适合提示/引导,不适合错误弹窗)**
|
||||
|
||||
- 模板:
|
||||
- 「助理可以加速产出,但方向、取舍和标准仍属于你。」
|
||||
- 「结果可以很快,经历更重要:把每次尝试留下来,下一次会更稳。」
|
||||
|
||||
**协作/群组**
|
||||
|
||||
- 模板:
|
||||
- 「把上下文对齐到同一处,群组里每个助理都会站在同一页上。」
|
||||
- 「不同意见没关系:先把目标写清楚,再让助理分别给方案与取舍。」
|
||||
|
||||
### 6) 错误/异常/权限/付费:硬规则
|
||||
|
||||
- 必须包含:**发生了什么 +(可选)原因 + 你可以怎么做**
|
||||
- 必须提供可操作选项:**重试 / 查看详情 / 去设置 / 联系支持 / 复制日志**(按场景取舍)
|
||||
- 不责备用户;不只给错误码;错误码可放在“详情”里
|
||||
- 涉及数据与安全:语气更中性更完整,温度通过“尊重与解释”体现,而不是煽
|
||||
@@ -1,148 +0,0 @@
|
||||
---
|
||||
globs: src/locales/default/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are **LobeHub’s English UI Copy & Microcopy Specialist**.
|
||||
|
||||
LobeHub is an assistant workspace: users can create **Agents** and **Agent Teams** so people↔agents and agent↔agent can collaborate to improve productivity in work and life. Brand vibe: youthful, friendly, modern on the surface; professional, reliable, productivity- and controllability-first underneath. Overall style reference: Notion / Figma / Apple / Discord / OpenAI / Gemini — clear, restrained, trustworthy, human but not cheesy.
|
||||
|
||||
Product slogan: **Where Agents Collaborate**. Your copy must continuously reinforce that LobeHub is not about “generation”, but about a **collaborative agent system**: shareable context, traceable outcomes, replayable runs, evolvable setup, and **human-in-the-loop**.
|
||||
|
||||
---
|
||||
|
||||
## 1) Fixed Terminology (must follow)
|
||||
|
||||
Use **exactly** these English terms across the product. Do not mix synonyms for the same concept.
|
||||
|
||||
- 空间: **Workspace**
|
||||
- 助理: **Agent**
|
||||
- 群组: **Group**
|
||||
- 上下文: **Context**
|
||||
- 记忆: **Memory**
|
||||
- 连接器: **Integration**
|
||||
- 技能/tool/plugin: **Skill**
|
||||
- 助理档案: **Agent Profile**
|
||||
- 话题: **Topic**
|
||||
- 文稿: **Page**
|
||||
- 社区: **Community**
|
||||
- 资源: **Resource**
|
||||
- 库: **Library**
|
||||
- MCP: **MCP**
|
||||
- 模型服务商: **Provider**
|
||||
|
||||
Terminology rule: one concept = one term site-wide. Never alternate with “bot/assistant/AI agent/team/workspace” variations.
|
||||
|
||||
---
|
||||
|
||||
## 2) Your Responsibilities
|
||||
|
||||
- Improve, rewrite, or create from scratch any **English UI copy**: titles, buttons, form labels/help text, placeholders, onboarding, empty states, toasts, modals, errors, permission prompts, settings, creation/run flows, collaboration and Agent Team pages, etc.
|
||||
- Copy must work for both:
|
||||
- general users (immediately understandable)
|
||||
- power users (not childish)
|
||||
- It must fit both playful and serious contexts.
|
||||
- Avoid overclaiming AI capabilities; add human warmth at the right moments.
|
||||
|
||||
---
|
||||
|
||||
## 3) The Three Brand Principles (bake into structure & wording)
|
||||
|
||||
- **Create**: create an Agent in one sentence; clear next step from idea → usable.
|
||||
- **Collaborate**: multi-agent collaboration; align info and outputs; share Context (controlled, manageable).
|
||||
- **Evolve**: Agents can remember preferences **only with user consent**; become more helpful over time; emphasize explainability, settings, and replay.
|
||||
|
||||
---
|
||||
|
||||
## 4) Writing Rules (actionable)
|
||||
|
||||
1. **Clarity first**: short sentences, strong verbs, minimal adjectives. Avoid hype (“revolutionary”, “epic”, “100%”).
|
||||
2. **Layered messaging (single version for everyone)**:
|
||||
- Main line: simple and actionable
|
||||
- Optional second line: more precise / technical / boundary-setting (subtitle, helper text, tooltip, collapsible)
|
||||
- Do not produce “Pro vs Lite” variants; one main + optional detail
|
||||
3. **Use terms sparingly but correctly**: prefer plain words (“connect”, “run”, “context”) unless a technical term is necessary. When it is, add a plain-English explanation.
|
||||
4. **Consistency**: keep verbs consistent across similar actions (Create / Connect / Run / Pause / Retry / View details / Clear Memory).
|
||||
5. **Actionable**: every message tells the user what to do next. Avoid generic “OK/Cancel”; use specific actions.
|
||||
6. **English localization**: natural, product-native English; avoid translationese; keep punctuation and casing consistent.
|
||||
|
||||
---
|
||||
|
||||
## 5) Human Warmth (balanced, controlled)
|
||||
|
||||
Goal: reduce anxiety and restore control without being sentimental. Default ratio: **80% information, 20% warmth**. Key moments (first-time create, empty state, long waits, failures/retries, rollback/data-loss risk, collaboration conflicts): may go **70/30**.
|
||||
|
||||
Hard cap: any on-screen message may include **at most half a sentence to one sentence** of warmth, and it must be followed by a clear next step.
|
||||
|
||||
Required order:
|
||||
|
||||
1. Acknowledge the situation (no judgment)
|
||||
2. Restore control (human-in-the-loop: pause/replay/edit/undo/clear Memory/view Context)
|
||||
3. Provide the next action (button/path)
|
||||
|
||||
Avoid:
|
||||
|
||||
- preachy encouragement (“don’t worry”, “stay positive”)
|
||||
- grand narratives
|
||||
- overly anthropomorphic claims (“I understand you”, “I’ll always remember you”)
|
||||
|
||||
Core stance: Agents can accelerate output, but **you** own the judgment, trade-offs, and final decision. LobeHub gives you time back for what matters.
|
||||
|
||||
Suggested patterns:
|
||||
|
||||
- **Getting started / blank state**
|
||||
- “Starting with one sentence is enough. Describe your goal and I’ll help you set up the first Agent.”
|
||||
- “Not sure where to begin? Tell me the outcome—we’ll break it down together.”
|
||||
- **Long run / waiting**
|
||||
- “Running… You can switch tasks—I'll notify you when it’s done.”
|
||||
- “This may take a few minutes. To speed up: reduce Context / switch model / disable Auto-run.”
|
||||
- **Failure / retry**
|
||||
- “That didn’t run through. Retry, or view details to fix the cause.”
|
||||
- “Connection failed: permission not granted or network unstable. Re-authorize in Settings, or try again later.”
|
||||
- **Value anxiety (guidance, not error dialogs)**
|
||||
- “Agents can speed up output, but direction and standards stay with you.”
|
||||
- “Fast results are great—keeping the trail makes the next run steadier.”
|
||||
- **Collaboration / Agent Teams**
|
||||
- “Align everyone to the same Context. Every Agent in the Agent Team works from the same page.”
|
||||
- “Different opinions are fine. Write the goal first, then let Agents propose options and trade-offs.”
|
||||
|
||||
---
|
||||
|
||||
## 6) Errors / Exceptions / Permissions / Billing: hard rules
|
||||
|
||||
Every error must include:
|
||||
|
||||
- **What happened**
|
||||
- (optional) **Why**
|
||||
- **What the user can do next**
|
||||
|
||||
Provide actionable options as appropriate:
|
||||
|
||||
- Retry / View details / Go to Settings / Contact support / Copy logs
|
||||
|
||||
Never blame the user. Don’t show only an error code; put codes in “Details” if needed. For data/security/billing: be neutral, thorough, and respectful—warmth comes from clarity, not emotion.
|
||||
|
||||
---
|
||||
|
||||
## 7) Your Special Task: CN i18n → EN (localized, length-aware)
|
||||
|
||||
You translate **raw Chinese i18n strings into English** for LobeHub.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Prefer **localized**, product-native English over literal translation.
|
||||
- Do **not** chase perfect one-to-one consistency if a more natural UI phrase reads better.
|
||||
- Keep the **character length difference small**; try to make the English string **roughly the same visual length** as the Chinese source (avoid overly long expansions).
|
||||
- Preserve meaning, tone, and actionability; keep verbs consistent with LobeHub’s UI patterns.
|
||||
- If space is tight (buttons, tabs, toasts), prioritize: **verb + object**, drop optional words first.
|
||||
- If the Chinese includes placeholders/variables, preserve them exactly (e.g., `{name}`, `{{count}}`, `%s`) and keep word order sensible.
|
||||
- Keep capitalization consistent with UI norms (buttons/title case only when appropriate).
|
||||
|
||||
Output format when translating:
|
||||
|
||||
- Provide **English only**, unless asked otherwise.
|
||||
- If multiple options are useful, give **one best option** + **one shorter fallback** (only when length constraints are likely).
|
||||
|
||||
---
|
||||
|
||||
You always optimize for: **clarity, control, collaboration, replayability, and human-in-the-loop**—in a modern, restrained, trustworthy English voice.
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
description: Modal 命令式调用指南
|
||||
globs: "**/features/**/*.tsx"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Modal 命令式调用指南
|
||||
|
||||
当需要创建可命令式调用的 Modal 组件时,使用 `@lobehub/ui` 提供的 `createModal` API。
|
||||
|
||||
## 核心理念
|
||||
|
||||
**命令式调用** vs **声明式调用**:
|
||||
|
||||
| 模式 | 特点 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| 声明式 | 需要维护 `open` state,渲染 `<Modal />` 组件 | ❌ 不推荐 |
|
||||
| 命令式 | 直接调用函数打开,无需 state 管理 | ✅ 推荐 |
|
||||
|
||||
## 文件组织结构
|
||||
|
||||
```
|
||||
features/
|
||||
└── MyFeatureModal/
|
||||
├── index.tsx # 导出 createXxxModal 函数
|
||||
├── MyFeatureContent.tsx # Modal 内容组件
|
||||
└── ...其他子组件
|
||||
```
|
||||
|
||||
## createModal 用法(推荐)
|
||||
|
||||
### 1. 定义 Content 组件 (`MyFeatureContent.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useModalContext } from '@lobehub/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const MyFeatureContent = () => {
|
||||
const { t } = useTranslation('namespace');
|
||||
const { close } = useModalContext(); // 可选:获取关闭方法
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Modal 内容 */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 导出 createModal 函数 (`index.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { createModal } from '@lobehub/ui';
|
||||
import { t } from 'i18next'; // 注意:使用 i18next 而非 react-i18next
|
||||
|
||||
import { MyFeatureContent } from './MyFeatureContent';
|
||||
|
||||
export const createMyFeatureModal = () =>
|
||||
createModal({
|
||||
allowFullscreen: true,
|
||||
children: <MyFeatureContent />,
|
||||
destroyOnHidden: false,
|
||||
footer: null,
|
||||
styles: {
|
||||
body: { overflow: 'hidden', padding: 0 },
|
||||
},
|
||||
title: t('myFeature.title', { ns: 'setting' }),
|
||||
width: 'min(80%, 800px)',
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 调用方使用
|
||||
|
||||
```tsx
|
||||
import { useCallback } from 'react';
|
||||
import { createMyFeatureModal } from '@/features/MyFeatureModal';
|
||||
|
||||
const MyComponent = () => {
|
||||
const handleOpenModal = useCallback(() => {
|
||||
createMyFeatureModal();
|
||||
}, []);
|
||||
|
||||
return <Button onClick={handleOpenModal}>打开</Button>;
|
||||
};
|
||||
```
|
||||
|
||||
## 关键要点
|
||||
|
||||
### i18n 处理
|
||||
|
||||
- **Content 组件内**:使用 `useTranslation` hook(React 上下文)
|
||||
- **createModal 参数中**:使用 `import { t } from 'i18next'`(非 hook,支持命令式调用)
|
||||
|
||||
```tsx
|
||||
// index.tsx - 命令式上下文
|
||||
import { t } from 'i18next';
|
||||
title: t('key', { ns: 'namespace' })
|
||||
|
||||
// Content.tsx - React 组件上下文
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const { t } = useTranslation('namespace');
|
||||
```
|
||||
|
||||
### useModalContext Hook
|
||||
|
||||
在 Content 组件内可使用 `useModalContext` 获取 Modal 控制方法:
|
||||
|
||||
```tsx
|
||||
const { close, setCanDismissByClickOutside } = useModalContext();
|
||||
```
|
||||
|
||||
### ModalHost
|
||||
|
||||
`createModal` 依赖全局 `<ModalHost />` 组件。项目中已在 `src/layout/GlobalProvider/index.tsx` 配置,无需额外添加。
|
||||
|
||||
## 常用配置项
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `allowFullscreen` | `boolean` | 允许全屏模式 |
|
||||
| `destroyOnHidden` | `boolean` | 关闭时是否销毁内容(`destroyOnClose` 已废弃) |
|
||||
| `footer` | `ReactNode \| null` | 底部内容,`null` 表示无底部 |
|
||||
| `width` | `string \| number` | Modal 宽度 |
|
||||
| `styles.body` | `CSSProperties` | body 区域样式 |
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### Before(声明式)
|
||||
|
||||
```tsx
|
||||
// 调用方需要维护 state
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>打开</Button>
|
||||
<MyModal open={open} setOpen={setOpen} />
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### After(命令式)
|
||||
|
||||
```tsx
|
||||
// 调用方无需 state,直接调用函数
|
||||
const handleOpen = useCallback(() => {
|
||||
createMyModal();
|
||||
}, []);
|
||||
|
||||
return <Button onClick={handleOpen}>打开</Button>;
|
||||
```
|
||||
|
||||
## 示例参考
|
||||
|
||||
- `src/features/SkillStore/index.tsx` - createModal 标准用法
|
||||
- `src/features/SkillStore/SkillStoreContent.tsx` - Content 组件示例
|
||||
- `src/features/LibraryModal/CreateNew/index.tsx` - 带回调的 createModal 用法
|
||||
- `src/features/Electron/updater/UpdateModal.tsx` - 复杂 Modal 控制示例
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
description: flex layout components from `@lobehub/ui` usage
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Flexbox 布局组件使用指南
|
||||
|
||||
`@lobehub/ui` 提供了 `Flexbox` 和 `Center` 组件用于创建弹性布局。以下是重点组件的使用方法:
|
||||
|
||||
## Flexbox 组件
|
||||
|
||||
Flexbox 是最常用的布局组件,用于创建弹性布局,类似于 CSS 的 display: flex。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```jsx
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
|
||||
// 默认垂直布局
|
||||
<Flexbox>
|
||||
<div>子元素1</div>
|
||||
<div>子元素2</div>
|
||||
</Flexbox>
|
||||
|
||||
// 水平布局
|
||||
<Flexbox horizontal>
|
||||
<div>左侧元素</div>
|
||||
<div>右侧元素</div>
|
||||
</Flexbox>
|
||||
```
|
||||
|
||||
### 常用属性
|
||||
|
||||
- horizontal: 布尔值,设置为水平方向布局
|
||||
- flex: 数值或字符串,控制 flex 属性
|
||||
- gap: 数值,设置子元素之间的间距
|
||||
- align: 对齐方式,如 'center', 'flex-start' 等
|
||||
- justify: 主轴对齐方式,如 'space-between', 'center' 等
|
||||
- padding: 内边距值
|
||||
- paddingInline: 水平内边距值
|
||||
- paddingBlock: 垂直内边距值
|
||||
- width/height: 设置宽高,通常用 '100%' 或具体像素值
|
||||
- style: 自定义样式对象
|
||||
|
||||
### 实际应用示例
|
||||
|
||||
```jsx
|
||||
// 经典三栏布局
|
||||
<Flexbox horizontal height={'100%'} width={'100%'}>
|
||||
{/* 左侧边栏 */}
|
||||
<Flexbox
|
||||
width={260}
|
||||
style={{
|
||||
borderRight: `1px solid ${theme.colorBorderSecondary}`,
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<SidebarContent />
|
||||
</Flexbox>
|
||||
|
||||
{/* 中间内容区 */}
|
||||
<Flexbox flex={1} style={{ height: '100%' }}>
|
||||
{/* 主要内容 */}
|
||||
<Flexbox flex={1} padding={24} style={{ overflowY: 'auto' }}>
|
||||
<MainContent />
|
||||
</Flexbox>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<Flexbox
|
||||
style={{
|
||||
borderTop: `1px solid ${theme.colorBorderSecondary}`,
|
||||
padding: '16px 24px',
|
||||
}}
|
||||
>
|
||||
<Footer />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
```
|
||||
|
||||
## Center 组件
|
||||
|
||||
Center 是对 Flexbox 的封装,使子元素水平和垂直居中。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```jsx
|
||||
import { Center } from '@lobehub/ui';
|
||||
|
||||
<Center width={'100%'} height={'100%'}>
|
||||
<Content />
|
||||
</Center>;
|
||||
```
|
||||
|
||||
Center 组件继承了 Flexbox 的所有属性,同时默认设置了居中对齐。主要用于快速创建居中布局。
|
||||
|
||||
### 实际应用示例
|
||||
|
||||
```jsx
|
||||
// 登录页面居中布局
|
||||
<Flexbox height={'100%'} width={'100%'}>
|
||||
<Center height={'100%'} width={'100%'}>
|
||||
<LoginForm />
|
||||
</Center>
|
||||
</Flexbox>
|
||||
|
||||
// 图标居中显示
|
||||
<Center className={styles.icon} flex={'none'} height={40} width={40}>
|
||||
<Icon icon={icon} size={24} />
|
||||
</Center>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 使用 flex={1} 让组件填充可用空间
|
||||
- 使用 gap 代替传统的 margin 设置元素间距
|
||||
- 嵌套 Flexbox 创建复杂布局
|
||||
- 设置 overflow: 'auto' 使内容可滚动
|
||||
- 使用 horizontal 创建水平布局,默认为垂直布局
|
||||
- 与 antd-style 的 useTheme hook 配合使用创建主题响应式的布局
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI Agent Workspace: LobeHub(previous LobeChat).
|
||||
|
||||
Supported platforms:
|
||||
|
||||
- web desktop/mobile
|
||||
- desktop(electron)
|
||||
- mobile app(react native), coming soon
|
||||
|
||||
logo emoji: 🤯
|
||||
|
||||
## Project Technologies Stack
|
||||
|
||||
- Next.js 16
|
||||
- implement spa inside nextjs with `react-router-dom`
|
||||
- react 19
|
||||
- TypeScript
|
||||
- `@lobehub/ui`, antd for component framework
|
||||
- antd-style for css-in-js framework
|
||||
- lucide-react, `@ant-design/icons` for icons
|
||||
- react-i18next for i18n
|
||||
- zustand for state management
|
||||
- nuqs for search params management
|
||||
- SWR for data fetch
|
||||
- aHooks for react hooks library
|
||||
- dayjs for time library
|
||||
- es-toolkit for utility library
|
||||
- TRPC for type safe backend
|
||||
- Neon PostgreSQL for backend DB
|
||||
- Drizzle ORM
|
||||
- Vitest for testing
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# LobeChat Project Structure
|
||||
|
||||
## Complete Project 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.
|
||||
|
||||
```plaintext
|
||||
lobe-chat/
|
||||
├── apps/
|
||||
│ └── desktop/
|
||||
├── docs/
|
||||
│ ├── changelog/
|
||||
│ ├── development/
|
||||
│ ├── self-hosting/
|
||||
│ └── usage/
|
||||
├── locales/
|
||||
│ ├── en-US/
|
||||
│ └── zh-CN/
|
||||
├── packages/
|
||||
│ ├── agent-runtime/
|
||||
│ ├── builtin-agents/
|
||||
│ ├── builtin-tool-*/ # builtin tool packages
|
||||
│ ├── business/ # cloud-only business logic packages
|
||||
│ │ ├── config/
|
||||
│ │ ├── const/
|
||||
│ │ └── model-runtime/
|
||||
│ ├── config/
|
||||
│ ├── const/
|
||||
│ ├── context-engine/
|
||||
│ ├── conversation-flow/
|
||||
│ ├── database/
|
||||
│ │ └── src/
|
||||
│ │ ├── models/
|
||||
│ │ ├── schemas/
|
||||
│ │ └── repositories/
|
||||
│ ├── desktop-bridge/
|
||||
│ ├── edge-config/
|
||||
│ ├── editor-runtime/
|
||||
│ ├── electron-client-ipc/
|
||||
│ ├── electron-server-ipc/
|
||||
│ ├── fetch-sse/
|
||||
│ ├── file-loaders/
|
||||
│ ├── memory-user-memory/
|
||||
│ ├── model-bank/
|
||||
│ ├── model-runtime/
|
||||
│ │ └── src/
|
||||
│ │ ├── core/
|
||||
│ │ └── providers/
|
||||
│ ├── observability-otel/
|
||||
│ ├── prompts/
|
||||
│ ├── python-interpreter/
|
||||
│ ├── ssrf-safe-fetch/
|
||||
│ ├── types/
|
||||
│ ├── utils/
|
||||
│ └── web-crawler/
|
||||
├── public/
|
||||
├── scripts/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── (backend)/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ ├── f/
|
||||
│ │ │ ├── market/
|
||||
│ │ │ ├── middleware/
|
||||
│ │ │ ├── oidc/
|
||||
│ │ │ ├── trpc/
|
||||
│ │ │ └── webapi/
|
||||
│ │ ├── [variants]/
|
||||
│ │ │ ├── (auth)/
|
||||
│ │ │ ├── (main)/
|
||||
│ │ │ ├── (mobile)/
|
||||
│ │ │ ├── onboarding/
|
||||
│ │ │ └── router/
|
||||
│ │ └── desktop/
|
||||
│ ├── business/ # cloud-only business logic (client/server)
|
||||
│ │ ├── client/
|
||||
│ │ ├── locales/
|
||||
│ │ └── server/
|
||||
│ ├── components/
|
||||
│ ├── config/
|
||||
│ ├── const/
|
||||
│ ├── envs/
|
||||
│ ├── features/
|
||||
│ ├── helpers/
|
||||
│ ├── hooks/
|
||||
│ ├── layout/
|
||||
│ │ ├── AuthProvider/
|
||||
│ │ └── GlobalProvider/
|
||||
│ ├── libs/
|
||||
│ │ ├── better-auth/
|
||||
│ │ ├── oidc-provider/
|
||||
│ │ └── trpc/
|
||||
│ ├── locales/
|
||||
│ │ └── default/
|
||||
│ ├── server/
|
||||
│ │ ├── featureFlags/
|
||||
│ │ ├── globalConfig/
|
||||
│ │ ├── modules/
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── async/
|
||||
│ │ │ ├── lambda/
|
||||
│ │ │ ├── mobile/
|
||||
│ │ │ └── tools/
|
||||
│ │ └── services/
|
||||
│ ├── services/
|
||||
│ ├── store/
|
||||
│ │ ├── agent/
|
||||
│ │ ├── chat/
|
||||
│ │ └── user/
|
||||
│ ├── styles/
|
||||
│ ├── tools/
|
||||
│ ├── types/
|
||||
│ └── utils/
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Architecture Map
|
||||
|
||||
- UI Components: `src/components`, `src/features`
|
||||
- Global providers: `src/layout`
|
||||
- Zustand stores: `src/store`
|
||||
- Client Services: `src/services/`
|
||||
- API Routers:
|
||||
- `src/app/(backend)/webapi` (REST)
|
||||
- `src/server/routers/{async|lambda|mobile|tools}` (tRPC)
|
||||
- Server:
|
||||
- Services (can access serverDB): `src/server/services`
|
||||
- Modules (can't access db): `src/server/modules`
|
||||
- Feature Flags: `src/server/featureFlags`
|
||||
- Global Config: `src/server/globalConfig`
|
||||
- Database:
|
||||
- Schema (Drizzle): `packages/database/src/schemas`
|
||||
- Model (CRUD): `packages/database/src/models`
|
||||
- Repository (bff-queries): `packages/database/src/repositories`
|
||||
- Third-party Integrations: `src/libs` — analytics, oidc etc.
|
||||
- Builtin Tools: `src/tools`, `packages/builtin-tool-*`
|
||||
- Business (cloud-only): Code specific to LobeHub cloud service, only expose empty interfaces for opens-source version.
|
||||
- `src/business/*`
|
||||
- `packages/business/*`
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
React UI → Store Actions → Client Service → TRPC Lambda → Server Services -> DB Model → PostgreSQL (Remote)
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: *.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# React Component Writing Guide
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use the `style` attribute for inline styles
|
||||
- Use `Flexbox` and `Center` components from `@lobehub/ui` for flex and centered layouts
|
||||
- Component selection priority: src/components > installed component packages > lobe-ui > antd
|
||||
- Use selectors to access zustand store data instead of accessing the store directly
|
||||
|
||||
## Lobe UI Components
|
||||
|
||||
- If unsure how to use `@lobehub/ui` components or what props they accept, search for existing usage in this project instead of guessing. Most components extend antd components with additional props
|
||||
- For specific usage, search online. For example, for ActionIcon visit <https://ui.lobehub.com/components/action-icon>
|
||||
- Read `node_modules/@lobehub/ui/es/index.mjs` to see all available components and their props
|
||||
|
||||
- General
|
||||
- ActionIcon
|
||||
- ActionIconGroup
|
||||
- Block
|
||||
- Button
|
||||
- Icon
|
||||
- Data Display
|
||||
- Accordion
|
||||
- Avatar
|
||||
- Collapse
|
||||
- Empty
|
||||
- FileTypeIcon
|
||||
- FluentEmoji
|
||||
- GroupAvatar
|
||||
- GuideCard
|
||||
- Highlighter
|
||||
- Hotkey
|
||||
- Image
|
||||
- List
|
||||
- Markdown
|
||||
- MaterialFileTypeIcon
|
||||
- Mermaid
|
||||
- Segmented
|
||||
- Skeleton
|
||||
- Snippet
|
||||
- SortableList
|
||||
- Tag
|
||||
- Tooltip
|
||||
- Video
|
||||
- Data Entry
|
||||
- AutoComplete
|
||||
- CodeEditor
|
||||
- ColorSwatches
|
||||
- CopyButton
|
||||
- DatePicker
|
||||
- DownloadButton
|
||||
- EditableText
|
||||
- EmojiPicker
|
||||
- Form
|
||||
- FormModal
|
||||
- HotkeyInput
|
||||
- ImageSelect
|
||||
- Input
|
||||
- SearchBar
|
||||
- Select
|
||||
- SliderWithInput
|
||||
- ThemeSwitch
|
||||
- Feedback
|
||||
- Alert
|
||||
- Drawer
|
||||
- Modal
|
||||
- Layout
|
||||
- Center
|
||||
- DraggablePanel
|
||||
- Flexbox
|
||||
- Footer
|
||||
- Grid
|
||||
- Header
|
||||
- Layout
|
||||
- MaskShadow
|
||||
- ScrollShadow
|
||||
- Navigation
|
||||
- Burger
|
||||
- DraggableSideNav
|
||||
- Dropdown
|
||||
- Menu
|
||||
- SideNav
|
||||
- Tabs
|
||||
- Toc
|
||||
- Theme
|
||||
- ConfigProvider
|
||||
- FontLoader
|
||||
- ThemeProvider
|
||||
- Typography
|
||||
- Text
|
||||
|
||||
## Routing Architecture
|
||||
|
||||
This project uses a **hybrid routing architecture**: Next.js App Router for static pages + React Router DOM for the main SPA.
|
||||
|
||||
### Route Types
|
||||
|
||||
```plaintext
|
||||
+------------------+--------------------------------+--------------------------------+
|
||||
| Route Type | Use Case | Implementation |
|
||||
+------------------+--------------------------------+--------------------------------+
|
||||
| Next.js App | Auth pages (login, signup, | page.tsx file convention |
|
||||
| Router | oauth, reset-password, etc.) | src/app/[variants]/(auth)/ |
|
||||
+------------------+--------------------------------+--------------------------------+
|
||||
| React Router | Main SPA features | BrowserRouter + Routes |
|
||||
| DOM | (chat, community, settings) | desktopRouter.config.tsx |
|
||||
| | | mobileRouter.config.tsx |
|
||||
+------------------+--------------------------------+--------------------------------+
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
- Entry point: `src/app/[variants]/page.tsx` - Routes to Desktop or Mobile based on device
|
||||
- Desktop router: `src/app/[variants]/router/desktopRouter.config.tsx`
|
||||
- Mobile router: `src/app/[variants]/(mobile)/router/mobileRouter.config.tsx`
|
||||
- Router utilities: `src/utils/router.tsx`
|
||||
|
||||
### Router Utilities
|
||||
|
||||
```tsx
|
||||
import { ErrorBoundary, RouteConfig, dynamicElement, redirectElement } from '@/utils/router';
|
||||
|
||||
// Lazy load a page component
|
||||
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
|
||||
|
||||
// Create a redirect
|
||||
element: redirectElement('/settings/profile');
|
||||
|
||||
// Error boundary for route
|
||||
errorElement: <ErrorBoundary resetPath="/chat" />;
|
||||
```
|
||||
|
||||
### Adding New Routes
|
||||
|
||||
1. Add route config to `desktopRouter.config.tsx` or `mobileRouter.config.tsx`
|
||||
2. Create page component in the corresponding directory under `(main)/`
|
||||
3. Use `dynamicElement()` for lazy loading
|
||||
|
||||
### Navigation
|
||||
|
||||
**Important**: For SPA pages (React Router DOM routes), use `Link` from `react-router-dom`, NOT from `next/link`.
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong - next/link in SPA pages
|
||||
import Link from 'next/link';
|
||||
<Link href="/">Home</Link>
|
||||
|
||||
// ✅ Correct - react-router-dom Link in SPA pages
|
||||
import { Link } from 'react-router-dom';
|
||||
<Link to="/">Home</Link>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// In components - use react-router-dom hooks
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
const navigate = useNavigate();
|
||||
navigate('/chat');
|
||||
|
||||
// From stores - use global navigate
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
const navigate = useGlobalStore.getState().navigate;
|
||||
navigate?.('/settings');
|
||||
```
|
||||
@@ -1,139 +0,0 @@
|
||||
# Recent Data 使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
Recent 数据(recentTopics, recentResources, recentPages)存储在 session store 中,可以在应用的任何地方访问。
|
||||
|
||||
## 数据初始化
|
||||
|
||||
在应用顶层(如 `RecentHydration.tsx`)中初始化所有 recent 数据:
|
||||
|
||||
```tsx
|
||||
import { useInitRecentPage } from '@/hooks/useInitRecentPage';
|
||||
import { useInitRecentResource } from '@/hooks/useInitRecentResource';
|
||||
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
|
||||
|
||||
const App = () => {
|
||||
// 初始化所有 recent 数据
|
||||
useInitRecentTopic();
|
||||
useInitRecentResource();
|
||||
useInitRecentPage();
|
||||
|
||||
return <YourComponents />;
|
||||
};
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:直接从 Store 读取(推荐用于多处使用)
|
||||
|
||||
在任何组件中直接访问 store 中的数据:
|
||||
|
||||
```tsx
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { recentSelectors } from '@/store/session/selectors';
|
||||
|
||||
const Component = () => {
|
||||
// 读取数据
|
||||
const recentTopics = useSessionStore(recentSelectors.recentTopics);
|
||||
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
|
||||
|
||||
if (!isInit) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{recentTopics.map((topic) => (
|
||||
<div key={topic.id}>{topic.title}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 方式二:使用 Hook 返回的数据(用于单一组件)
|
||||
|
||||
```tsx
|
||||
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
|
||||
|
||||
const Component = () => {
|
||||
const { data: recentTopics, isLoading } = useInitRecentTopic();
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return <div>{/* 使用 recentTopics */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## 可用的 Selectors
|
||||
|
||||
### Recent Topics (最近话题)
|
||||
|
||||
```tsx
|
||||
import { recentSelectors } from '@/store/session/selectors';
|
||||
|
||||
// 数据
|
||||
const recentTopics = useSessionStore(recentSelectors.recentTopics);
|
||||
// 类型: RecentTopic[]
|
||||
|
||||
// 初始化状态
|
||||
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
|
||||
// 类型: boolean
|
||||
```
|
||||
|
||||
**RecentTopic 类型:**
|
||||
|
||||
```typescript
|
||||
interface RecentTopic {
|
||||
agent: {
|
||||
avatar: string | null;
|
||||
backgroundColor: string | null;
|
||||
id: string;
|
||||
title: string | null;
|
||||
} | null;
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Recent Resources (最近文件)
|
||||
|
||||
```tsx
|
||||
import { recentSelectors } from '@/store/session/selectors';
|
||||
|
||||
// 数据
|
||||
const recentResources = useSessionStore(recentSelectors.recentResources);
|
||||
// 类型: FileListItem[]
|
||||
|
||||
// 初始化状态
|
||||
const isInit = useSessionStore(recentSelectors.isRecentResourcesInit);
|
||||
// 类型: boolean
|
||||
```
|
||||
|
||||
### Recent Pages (最近页面)
|
||||
|
||||
```tsx
|
||||
import { recentSelectors } from '@/store/session/selectors';
|
||||
|
||||
// 数据
|
||||
const recentPages = useSessionStore(recentSelectors.recentPages);
|
||||
// 类型: any[]
|
||||
|
||||
// 初始化状态
|
||||
const isInit = useSessionStore(recentSelectors.isRecentPagesInit);
|
||||
// 类型: boolean
|
||||
```
|
||||
|
||||
## 特性
|
||||
|
||||
1. **自动登录检测**:只有在用户登录时才会加载数据
|
||||
2. **数据缓存**:数据存储在 store 中,多处使用无需重复加载
|
||||
3. **自动刷新**:使用 SWR,在用户重新聚焦时自动刷新(5分钟间隔)
|
||||
4. **类型安全**:完整的 TypeScript 类型定义
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **初始化位置**:在应用顶层统一初始化所有 recent 数据
|
||||
2. **数据访问**:使用 selectors 从 store 读取数据
|
||||
3. **多处使用**:同一数据在多个组件中使用时,推荐使用方式一(直接从 store 读取)
|
||||
4. **性能优化**:使用 selector 确保只有相关数据变化时才重新渲染
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Available project rules index
|
||||
|
||||
All following rules are saved under `.cursor/rules/` directory:
|
||||
|
||||
## Backend
|
||||
|
||||
- `drizzle-schema-style-guide.mdc` – Style guide for defining Drizzle ORM schemas
|
||||
|
||||
## Frontend
|
||||
|
||||
- `react.mdc` – React component style guide and conventions
|
||||
- `i18n.mdc` – Internationalization guide using react-i18next
|
||||
- `typescript.mdc` – TypeScript code style guide
|
||||
- `packages/react-layout-kit.mdc` – Usage guide for react-layout-kit
|
||||
- `modal-imperative.mdc` – Modal imperative API usage guide (createRawModal/createModal)
|
||||
|
||||
## State Management
|
||||
|
||||
- `zustand-action-patterns.mdc` – Recommended patterns for organizing Zustand actions
|
||||
- `zustand-slice-organization.mdc` – Best practices for structuring Zustand slices
|
||||
|
||||
## 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
|
||||
|
||||
## Debugging
|
||||
|
||||
- `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
|
||||
@@ -1,285 +0,0 @@
|
||||
# Agent Runtime E2E 测试指南
|
||||
|
||||
本文档描述 Agent Runtime 端到端测试的核心原则和实施方法。
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 1. 最小化 Mock 原则
|
||||
|
||||
E2E 测试的目标是尽可能接近真实运行环境。因此,我们只 Mock **三个外部依赖**:
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **Database** | PGLite | 使用 `@lobechat/database/test-utils` 提供的内存数据库 |
|
||||
| **Redis** | InMemoryAgentStateManager | Mock `AgentStateManager` 使用内存实现 |
|
||||
| **Redis** | InMemoryStreamEventManager | Mock `StreamEventManager` 使用内存实现 |
|
||||
|
||||
**不 Mock 的部分:**
|
||||
|
||||
- `model-bank` - 使用真实的模型配置数据
|
||||
- `Mecha` (AgentToolsEngine, ContextEngineering) - 使用真实逻辑
|
||||
- `AgentRuntimeService` - 使用真实逻辑
|
||||
- `AgentRuntimeCoordinator` - 使用真实逻辑
|
||||
|
||||
### 2. 使用 vi.spyOn 而非 vi.mock
|
||||
|
||||
不同测试场景需要不同的 LLM 响应。使用 `vi.spyOn` 可以:
|
||||
|
||||
- 在每个测试中灵活控制返回值
|
||||
- 便于测试不同场景(纯文本、tool calls、错误等)
|
||||
- 避免全局 mock 导致的测试隔离问题
|
||||
|
||||
### 3. 默认模型使用 gpt-5
|
||||
|
||||
- `model-bank` 中肯定有该模型的数据
|
||||
- 避免短期内因模型更新需要修改测试
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 数据库设置
|
||||
|
||||
```typescript
|
||||
import { LobeChatDatabase } from '@lobechat/database';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
|
||||
let testDB: LobeChatDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDB = await getTestDB();
|
||||
});
|
||||
```
|
||||
|
||||
### OpenAI Response Mock Helper
|
||||
|
||||
创建一个 helper 函数来生成 OpenAI 格式的流式响应:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 创建 OpenAI 格式的流式响应
|
||||
*/
|
||||
export const createOpenAIStreamResponse = (options: {
|
||||
content?: string;
|
||||
toolCalls?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}>;
|
||||
finishReason?: 'stop' | 'tool_calls';
|
||||
}) => {
|
||||
const { content, toolCalls, finishReason = 'stop' } = options;
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// 发送内容 chunk
|
||||
if (content) {
|
||||
const chunk = {
|
||||
id: 'chatcmpl-mock',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
|
||||
// 发送 tool_calls chunk
|
||||
if (toolCalls) {
|
||||
for (const tool of toolCalls) {
|
||||
const chunk = {
|
||||
id: 'chatcmpl-mock',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: tool.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
arguments: tool.arguments,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
}
|
||||
|
||||
// 发送完成 chunk
|
||||
const finishChunk = {
|
||||
id: 'chatcmpl-mock',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: finishReason,
|
||||
},
|
||||
],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'text/event-stream' } },
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 内存状态管理
|
||||
|
||||
使用依赖注入替代 Redis:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
InMemoryAgentStateManager,
|
||||
InMemoryStreamEventManager,
|
||||
} from '@/server/modules/AgentRuntime';
|
||||
import { AgentRuntimeService } from '@/server/services/agentRuntime';
|
||||
|
||||
const stateManager = new InMemoryAgentStateManager();
|
||||
const streamEventManager = new InMemoryStreamEventManager();
|
||||
|
||||
const service = new AgentRuntimeService(serverDB, userId, {
|
||||
coordinatorOptions: {
|
||||
stateManager,
|
||||
streamEventManager,
|
||||
},
|
||||
queueService: null, // 禁用 QStash 队列,使用 executeSync
|
||||
streamEventManager,
|
||||
});
|
||||
```
|
||||
|
||||
### Mock OpenAI API
|
||||
|
||||
在测试中使用 `vi.spyOn` mock fetch:
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// 在测试文件顶部或 beforeEach 中
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
// 在具体测试中设置返回值
|
||||
it('should handle text response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(createOpenAIStreamResponse({ content: '杭州今天天气晴朗' }));
|
||||
|
||||
// ... 执行测试
|
||||
});
|
||||
|
||||
it('should handle tool calls', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
createOpenAIStreamResponse({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'lobe-web-browsing____search____builtin',
|
||||
arguments: JSON.stringify({ query: '杭州天气' }),
|
||||
},
|
||||
],
|
||||
finishReason: 'tool_calls',
|
||||
}),
|
||||
);
|
||||
|
||||
// ... 执行测试
|
||||
});
|
||||
```
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 1. 基本对话测试
|
||||
|
||||
```typescript
|
||||
describe('Basic Chat', () => {
|
||||
it('should complete a simple conversation', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
createOpenAIStreamResponse({ content: 'Hello! How can I help you?' }),
|
||||
);
|
||||
|
||||
const result = await service.createOperation({
|
||||
agentConfig: { model: 'gpt-5', provider: 'openai' },
|
||||
initialMessages: [{ role: 'user', content: 'Hi' }],
|
||||
// ...
|
||||
});
|
||||
|
||||
const finalState = await service.executeSync(result.operationId);
|
||||
expect(finalState.status).toBe('done');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Tool 调用测试
|
||||
|
||||
```typescript
|
||||
describe('Tool Calls', () => {
|
||||
it('should execute web-browsing tool', async () => {
|
||||
// 第一次调用:LLM 返回 tool_calls
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
createOpenAIStreamResponse({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'lobe-web-browsing____search____builtin',
|
||||
arguments: JSON.stringify({ query: '杭州天气' }),
|
||||
},
|
||||
],
|
||||
finishReason: 'tool_calls',
|
||||
}),
|
||||
);
|
||||
|
||||
// 第二次调用:处理 tool 结果后的响应
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
createOpenAIStreamResponse({ content: '根据搜索结果,杭州今天...' }),
|
||||
);
|
||||
|
||||
// ... 执行测试
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 错误处理测试
|
||||
|
||||
```typescript
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API errors gracefully', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('API rate limit exceeded'));
|
||||
|
||||
// ... 执行测试并验证错误处理
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 文件组织
|
||||
|
||||
```
|
||||
src/server/routers/lambda/__tests__/integration/
|
||||
├── setup.ts # 测试设置工具
|
||||
├── aiAgent.integration.test.ts # 现有集成测试
|
||||
├── aiAgent.e2e.test.ts # E2E 测试
|
||||
└── helpers/
|
||||
└── openaiMock.ts # OpenAI mock helper
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **测试隔离**:每个测试后清理 `InMemoryAgentStateManager` 和 `InMemoryStreamEventManager`
|
||||
2. **超时设置**:E2E 测试可能需要更长的超时时间
|
||||
3. **调试**:使用 `DEBUG=lobe-server:*` 环境变量查看详细日志
|
||||
@@ -1,455 +0,0 @@
|
||||
---
|
||||
globs: src/database/**/*.test.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## 🗃️ 数据库 Model 测试指南
|
||||
|
||||
测试 `packages/database` 下的数据库 Model 层。
|
||||
|
||||
### 测试环境选择 💡
|
||||
|
||||
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
|
||||
|
||||
### ⚠️ 双环境验证要求
|
||||
|
||||
**对于所有 Model 测试,必须在两个环境下都验证通过**:
|
||||
|
||||
#### 完整验证流程
|
||||
|
||||
```bash
|
||||
# 1. 先在客户端环境测试(快速验证)
|
||||
cd packages/database && TEST_SERVER_DB=0 bunx vitest run --silent='passed-only' src/database/models/__tests__/myModel.test.ts
|
||||
|
||||
# 2. 再在服务端环境测试(兼容性验证), 需要设置环境变量 `TEST_SERVER_DB=1`
|
||||
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' src/database/models/__tests__/myModel.test.ts #
|
||||
```
|
||||
|
||||
### 创建新 Model 测试的最佳实践 📋
|
||||
|
||||
#### 1. 参考现有实现和测试模板
|
||||
|
||||
创建新 Model 测试前,**必须先参考现有的实现模式**:
|
||||
|
||||
- **Model 实现参考**:
|
||||
- **测试模板参考**:
|
||||
- **复杂示例参考**:
|
||||
|
||||
#### 2. 用户权限检查 - 安全第一 🔒
|
||||
|
||||
这是**最关键的安全要求**。所有涉及用户数据的操作都必须包含用户权限检查:
|
||||
|
||||
**❌ 错误示例 - 存在安全漏洞**:
|
||||
|
||||
```typescript
|
||||
// 危险:缺少用户权限检查,任何用户都能操作任何数据
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(eq(myTable.id, id)) // ❌ 只检查 ID,没有检查 userId
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
**✅ 正确示例 - 安全的实现**:
|
||||
|
||||
```typescript
|
||||
// 安全:必须同时匹配 ID 和 userId
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(
|
||||
and(
|
||||
eq(myTable.id, id),
|
||||
eq(myTable.userId, this.userId), // ✅ 用户权限检查
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
**必须进行用户权限检查的方法**:
|
||||
|
||||
- `update()` - 更新操作
|
||||
- `delete()` - 删除操作
|
||||
- `findById()` - 查找特定记录
|
||||
- 任何涉及特定记录的查询或修改操作
|
||||
|
||||
#### 3. 测试文件结构和必测场景
|
||||
|
||||
**基本测试结构**:
|
||||
|
||||
```typescript
|
||||
// @vitest-environment node
|
||||
describe('MyModel', () => {
|
||||
describe('create', () => {
|
||||
it('should create a new record');
|
||||
it('should handle edge cases');
|
||||
});
|
||||
|
||||
describe('queryAll', () => {
|
||||
it('should return records for current user only');
|
||||
it('should handle empty results');
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update own records');
|
||||
it('should NOT update other users records'); // 🔒 安全测试
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete own records');
|
||||
it('should NOT delete other users records'); // 🔒 安全测试
|
||||
});
|
||||
|
||||
describe('user isolation', () => {
|
||||
it('should enforce user data isolation'); // 🔒 核心安全测试
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**必须测试的安全场景** 🔒:
|
||||
|
||||
```typescript
|
||||
it('should not update records of other users', async () => {
|
||||
// 创建其他用户的记录
|
||||
const [otherUserRecord] = await serverDB
|
||||
.insert(myTable)
|
||||
.values({ userId: 'other-user', data: 'original' })
|
||||
.returning();
|
||||
|
||||
// 尝试更新其他用户的记录
|
||||
const result = await myModel.update(otherUserRecord.id, { data: 'hacked' });
|
||||
|
||||
// 应该返回 undefined 或空数组(因为权限检查失败)
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// 验证原始数据未被修改
|
||||
const unchanged = await serverDB.query.myTable.findFirst({
|
||||
where: eq(myTable.id, otherUserRecord.id),
|
||||
});
|
||||
expect(unchanged?.data).toBe('original'); // 数据应该保持不变
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. Mock 外部依赖服务
|
||||
|
||||
如果 Model 依赖外部服务(如 FileService),需要正确 Mock:
|
||||
|
||||
**设置 Mock**:
|
||||
|
||||
```typescript
|
||||
// 在文件顶部设置 Mock
|
||||
const mockGetFullFileUrl = vi.fn();
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({
|
||||
getFullFileUrl: mockGetFullFileUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
// 在 beforeEach 中重置和配置 Mock
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
|
||||
});
|
||||
```
|
||||
|
||||
**验证 Mock 调用**:
|
||||
|
||||
```typescript
|
||||
it('should process URLs through FileService', async () => {
|
||||
// ... 测试逻辑
|
||||
|
||||
// 验证 Mock 被正确调用
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. 数据库状态管理
|
||||
|
||||
**正确的数据清理模式**:
|
||||
|
||||
```typescript
|
||||
const userId = 'test-user';
|
||||
const otherUserId = 'other-user';
|
||||
|
||||
beforeEach(async () => {
|
||||
// 清理用户表(级联删除相关数据)
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试用户
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 清理测试数据
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. 测试数据类型和外键约束处理 ⚠️
|
||||
|
||||
**必须使用 Schema 导出的类型**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用 schema 导出的类型
|
||||
import { NewGeneration, NewGenerationBatch } from '../../schemas';
|
||||
|
||||
const testBatch: NewGenerationBatch = {
|
||||
userId,
|
||||
generationTopicId: 'test-topic-id',
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
prompt: 'Test prompt for image generation',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
config: {
|
||||
/* ... */
|
||||
},
|
||||
};
|
||||
|
||||
const testGeneration: NewGeneration = {
|
||||
id: 'test-gen-id',
|
||||
generationBatchId: 'test-batch-id',
|
||||
asyncTaskId: null, // 处理外键约束
|
||||
fileId: null, // 处理外键约束
|
||||
seed: 12345,
|
||||
userId,
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:没有类型声明或使用错误类型
|
||||
const testBatch = {
|
||||
// 缺少类型声明
|
||||
generationTopicId: 'test-topic-id',
|
||||
// ...
|
||||
};
|
||||
|
||||
const testGeneration = {
|
||||
// 缺少类型声明
|
||||
asyncTaskId: 'invalid-uuid', // 外键约束错误
|
||||
fileId: 'non-existent-file', // 外键约束错误
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**外键约束处理策略**:
|
||||
|
||||
1. **使用 null 值**: 对于可选的外键字段,使用 null 避免约束错误
|
||||
2. **创建关联记录**: 如果需要测试关联关系,先创建被引用的记录
|
||||
3. **理解约束关系**: 了解哪些字段有外键约束,避免引用不存在的记录
|
||||
|
||||
```typescript
|
||||
// 外键约束处理示例
|
||||
beforeEach(async () => {
|
||||
// 清理数据库
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试用户
|
||||
await serverDB.insert(users).values([{ id: userId }]);
|
||||
|
||||
// 如果需要测试文件关联,创建文件记录
|
||||
if (needsFileAssociation) {
|
||||
await serverDB.insert(files).values({
|
||||
id: 'test-file-id',
|
||||
userId,
|
||||
name: 'test.jpg',
|
||||
url: 'test-url',
|
||||
size: 1024,
|
||||
fileType: 'image/jpeg',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**排序测试的可预测性**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用明确的时间戳确保排序结果可预测
|
||||
it('should find batches by topic id in correct order', async () => {
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
|
||||
const batch1 = { ...testBatch, prompt: 'First batch', userId, createdAt: oldDate };
|
||||
const batch2 = { ...testBatch, prompt: 'Second batch', userId, createdAt: newDate };
|
||||
|
||||
await serverDB.insert(generationBatches).values([batch1, batch2]);
|
||||
|
||||
const results = await generationBatchModel.findByTopicId(testTopic.id);
|
||||
|
||||
expect(results[0].prompt).toBe('Second batch'); // 最新优先 (desc order)
|
||||
expect(results[1].prompt).toBe('First batch');
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:依赖数据库的默认时间戳,结果不可预测
|
||||
it('should find batches by topic id', async () => {
|
||||
const batch1 = { ...testBatch, prompt: 'First batch', userId };
|
||||
const batch2 = { ...testBatch, prompt: 'Second batch', userId };
|
||||
|
||||
await serverDB.insert(generationBatches).values([batch1, batch2]);
|
||||
|
||||
// 插入顺序和数据库时间戳可能不一致,导致测试不稳定
|
||||
const results = await generationBatchModel.findByTopicId(testTopic.id);
|
||||
expect(results[0].prompt).toBe('Second batch'); // 可能失败
|
||||
});
|
||||
```
|
||||
|
||||
### 常见问题和解决方案 💡
|
||||
|
||||
#### 问题 1:权限检查缺失导致安全漏洞
|
||||
|
||||
**现象**: 测试失败,用户能修改其他用户的数据 **解决**: 在 Model 的 `update` 和 `delete` 方法中添加 `and(eq(table.id, id), eq(table.userId, this.userId))`
|
||||
|
||||
#### 问题 2:Mock 未生效或验证失败
|
||||
|
||||
**现象**: `undefined is not a spy` 错误 **解决**: 检查 Mock 设置位置和方式,确保在测试文件顶部设置,在 `beforeEach` 中重置
|
||||
|
||||
#### 问题 3:测试数据污染
|
||||
|
||||
**现象**: 测试间相互影响,结果不稳定 **解决**: 在 `beforeEach` 和 `afterEach` 中正确清理数据库状态
|
||||
|
||||
#### 问题 4:外部依赖导致测试失败
|
||||
|
||||
**现象**: 因为真实的外部服务调用导致测试不稳定 **解决**: Mock 所有外部依赖,使测试更可控和快速
|
||||
|
||||
#### 问题 5:外键约束违反导致测试失败
|
||||
|
||||
**现象**: `insert or update on table "xxx" violates foreign key constraint` **解决**:
|
||||
|
||||
- 将可选外键字段设为 `null` 而不是无效的字符串值
|
||||
- 或者先创建被引用的记录,再创建当前记录
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:无效的外键值
|
||||
const testData = {
|
||||
asyncTaskId: 'invalid-uuid', // 表中不存在此记录
|
||||
fileId: 'non-existent-file', // 表中不存在此记录
|
||||
};
|
||||
|
||||
// ✅ 正确:使用 null 值
|
||||
const testData = {
|
||||
asyncTaskId: null, // 避免外键约束
|
||||
fileId: null, // 避免外键约束
|
||||
};
|
||||
|
||||
// ✅ 或者:先创建被引用的记录
|
||||
beforeEach(async () => {
|
||||
const [asyncTask] = await serverDB.insert(asyncTasks).values({
|
||||
id: 'valid-task-id',
|
||||
status: 'pending',
|
||||
type: 'generation',
|
||||
}).returning();
|
||||
|
||||
const testData = {
|
||||
asyncTaskId: asyncTask.id, // 使用有效的外键值
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### 问题 6:排序测试结果不一致
|
||||
|
||||
**现象**: 相同的测试有时通过,有时失败,特别是涉及排序的测试 **解决**: 使用明确的时间戳,不要依赖数据库的默认时间戳
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:依赖插入顺序和默认时间戳
|
||||
await serverDB.insert(table).values([data1, data2]); // 时间戳不可预测
|
||||
|
||||
// ✅ 正确:明确指定时间戳
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
await serverDB.insert(table).values([
|
||||
{ ...data1, createdAt: oldDate },
|
||||
{ ...data2, createdAt: newDate },
|
||||
]);
|
||||
```
|
||||
|
||||
#### 问题 7:Mock 验证失败或调用次数不匹配
|
||||
|
||||
**现象**: `expect(mockFunction).toHaveBeenCalledWith(...)` 失败 **解决**:
|
||||
|
||||
- 检查 Mock 函数的实际调用参数和期望参数是否完全匹配
|
||||
- 确认 Mock 在正确的时机被重置和配置
|
||||
- 使用 `toHaveBeenCalledTimes()` 验证调用次数
|
||||
|
||||
```typescript
|
||||
// 在 beforeEach 中正确配置 Mock
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 重置所有 Mock
|
||||
|
||||
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
|
||||
mockTransformGeneration.mockResolvedValue({
|
||||
id: 'test-id',
|
||||
// ... 其他字段
|
||||
});
|
||||
});
|
||||
|
||||
// 测试中验证 Mock 调用
|
||||
it('should call FileService with correct parameters', async () => {
|
||||
await model.someMethod();
|
||||
|
||||
// 验证调用参数
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
|
||||
// 验证调用次数
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Model 测试检查清单 ✅
|
||||
|
||||
创建 Model 测试时,请确保以下各项都已完成:
|
||||
|
||||
#### 🔧 基础配置
|
||||
|
||||
- [ ] **双环境验证** - 在客户端环境 (vitest.config.ts) 和服务端环境 (vitest.config.server.ts) 下都测试通过
|
||||
- [ ] 参考了 `_template.ts` 和现有 Model 的实现模式
|
||||
- [ ] **使用正确的 Schema 类型** - 测试数据使用 `NewXxx` 类型声明,如 `NewGenerationBatch`、`NewGeneration`
|
||||
|
||||
#### 🔒 安全测试
|
||||
|
||||
- [ ] **所有涉及用户数据的操作都包含用户权限检查**
|
||||
- [ ] 包含了用户权限隔离的安全测试
|
||||
- [ ] 测试了用户无法访问其他用户数据的场景
|
||||
|
||||
#### 🗃️ 数据处理
|
||||
|
||||
- [ ] **正确处理外键约束** - 使用 `null` 值或先创建被引用记录
|
||||
- [ ] **排序测试使用明确时间戳** - 不依赖数据库默认时间,确保结果可预测
|
||||
- [ ] 在 `beforeEach` 和 `afterEach` 中正确管理数据库状态
|
||||
- [ ] 所有测试都能独立运行且互不干扰
|
||||
|
||||
#### 🎭 Mock 和外部依赖
|
||||
|
||||
- [ ] 正确 Mock 了外部依赖服务 (如 FileService、GenerationModel)
|
||||
- [ ] 在 `beforeEach` 中重置和配置 Mock
|
||||
- [ ] 验证了 Mock 服务的调用参数和次数
|
||||
- [ ] 测试了外部服务错误场景的处理
|
||||
|
||||
#### 📋 测试覆盖
|
||||
|
||||
- [ ] 测试覆盖了所有主要方法 (create, query, update, delete)
|
||||
- [ ] 测试了边界条件和错误场景
|
||||
- [ ] 包含了空结果处理的测试
|
||||
- [ ] **确认两个环境下的测试结果一致**
|
||||
|
||||
#### 🚨 常见问题检查
|
||||
|
||||
- [ ] 没有外键约束违反错误
|
||||
- [ ] 排序测试结果稳定可预测
|
||||
- [ ] Mock 验证无失败
|
||||
- [ ] 无测试数据污染问题
|
||||
|
||||
### 安全警告 ⚠️
|
||||
|
||||
**数据库 Model 层是安全的第一道防线**。如果 Model 层缺少用户权限检查:
|
||||
|
||||
1. **任何用户都能访问和修改其他用户的数据**
|
||||
2. **即使上层有权限检查,也可能被绕过**
|
||||
3. **可能导致严重的数据泄露和安全事故**
|
||||
|
||||
因此,**每个涉及用户数据的 Model 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
description: Electron IPC 接口测试策略
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
### Electron IPC 接口测试策略 🖥️
|
||||
|
||||
对于涉及 Electron IPC 接口的测试,由于提供真实的 Electron 环境比较复杂,采用 **Mock 返回值** 的方式进行测试。
|
||||
|
||||
#### 基本 Mock 设置
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
|
||||
|
||||
// Mock Electron IPC 客户端
|
||||
vi.mock('@/server/modules/ElectronIPCClient', () => ({
|
||||
electronIpcClient: {
|
||||
getFilePathById: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
// 根据需要添加其他 IPC 方法
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
#### 在测试中设置 Mock 行为
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
// 重置所有 Mock
|
||||
vi.resetAllMocks();
|
||||
|
||||
// 设置默认的 Mock 返回值
|
||||
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue('/path/to/file.txt');
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 测试不同场景的示例
|
||||
|
||||
```typescript
|
||||
it('应该处理文件删除成功的情况', async () => {
|
||||
// 设置成功场景的 Mock
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const result = await service.deleteFiles(['desktop://file1.txt']);
|
||||
|
||||
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith(['desktop://file1.txt']);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理文件删除失败的情况', async () => {
|
||||
// 设置失败场景的 Mock
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValue(new Error('删除失败'));
|
||||
|
||||
const result = await service.deleteFiles(['desktop://file1.txt']);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
#### Mock 策略的优势
|
||||
|
||||
1. **环境简化**: 避免了复杂的 Electron 环境搭建
|
||||
2. **测试可控**: 可以精确控制 IPC 调用的返回值和行为
|
||||
3. **场景覆盖**: 容易测试各种成功/失败场景
|
||||
4. **执行速度**: Mock 调用比真实 IPC 调用更快
|
||||
|
||||
#### 注意事项
|
||||
|
||||
- **Mock 准确性**: 确保 Mock 的行为与真实 IPC 接口行为一致
|
||||
- **类型安全**: 使用 `vi.mocked()` 确保类型安全
|
||||
- **Mock 重置**: 在 `beforeEach` 中重置 Mock 状态,避免测试间干扰
|
||||
- **调用验证**: 不仅要验证返回值,还要验证 IPC 方法是否被正确调用
|
||||
@@ -1,534 +0,0 @@
|
||||
---
|
||||
globs: *.test.ts,*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# LobeChat Testing Guide
|
||||
|
||||
## Test Overview
|
||||
|
||||
LobeChat testing consists of **E2E tests** and **Unit tests**. This guide focuses on **Unit tests**.
|
||||
|
||||
Unit tests are organized into three main categories:
|
||||
|
||||
```plaintext
|
||||
+---------------------+---------------------------+-----------------------------+
|
||||
| Category | Location | Config File |
|
||||
+---------------------+---------------------------+-----------------------------+
|
||||
| Next.js Webapp | src/**/*.test.ts(x) | vitest.config.ts |
|
||||
| Packages | packages/*/**/*.test.ts | packages/*/vitest.config.ts |
|
||||
| Desktop App | apps/desktop/**/*.test.ts | apps/desktop/vitest.config.ts |
|
||||
+---------------------+---------------------------+-----------------------------+
|
||||
```
|
||||
|
||||
### Next.js Webapp Tests
|
||||
|
||||
- **Config File**: `vitest.config.ts`
|
||||
- **Environment**: Happy DOM (browser environment simulation)
|
||||
- **Database**: PGLite (PostgreSQL for browser environments)
|
||||
- **Setup File**: `tests/setup.ts`
|
||||
- **Purpose**: Testing React components, hooks, stores, utilities, and client-side logic
|
||||
|
||||
### Packages Tests
|
||||
|
||||
Most packages use standard Vitest configuration. However, the `database` package is special:
|
||||
|
||||
#### Database Package (Special Case)
|
||||
|
||||
The database package supports **dual-environment testing**:
|
||||
|
||||
| Environment | Database | Config | Use Case |
|
||||
|------------------|-----------------|---------------------------------------|-----------------------------------|
|
||||
| Client (Default) | PGLite | `packages/database/vitest.config.mts` | Fast local development |
|
||||
| Server | Real PostgreSQL | Set `TEST_SERVER_DB=1` | CI/CD, compatibility verification |
|
||||
|
||||
Server environment details:
|
||||
|
||||
- **Concurrency**: Single-threaded (`singleFork: true`)
|
||||
- **Setup File**: `packages/database/tests/setup-db.ts`
|
||||
- **Requirement**: `DATABASE_TEST_URL` environment variable must be set
|
||||
|
||||
### Desktop App Tests
|
||||
|
||||
- **Config File**: `apps/desktop/vitest.config.ts`
|
||||
- **Environment**: Node.js
|
||||
- **Purpose**: Testing Electron main process controllers, IPC handlers, and desktop-specific logic
|
||||
|
||||
## Test Commands
|
||||
|
||||
**Performance Warning**: The project contains 3000+ test cases. A full run takes approximately 10 minutes. Always use file filtering or test name filtering.
|
||||
|
||||
### Recommended Command Format
|
||||
|
||||
```bash
|
||||
# Run all client/server tests
|
||||
bunx vitest run --silent='passed-only' # Client tests
|
||||
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' # Server tests
|
||||
|
||||
# Run specific test file (supports fuzzy matching)
|
||||
bunx vitest run --silent='passed-only' user.test.ts
|
||||
|
||||
# Run specific test case by name (using -t flag)
|
||||
bunx vitest run --silent='passed-only' -t "test case name"
|
||||
|
||||
# Combine file and test name filtering
|
||||
bunx vitest run --silent='passed-only' filename.test.ts -t "specific test"
|
||||
|
||||
# Generate coverage report (using --coverage flag)
|
||||
bunx vitest run --silent='passed-only' --coverage
|
||||
```
|
||||
|
||||
### Commands to Avoid
|
||||
|
||||
```bash
|
||||
# ❌ These commands run all 3000+ test cases, taking ~10 minutes!
|
||||
npm test
|
||||
npm test some-file.test.ts
|
||||
|
||||
# ❌ Don't use bare vitest (enters watch mode)
|
||||
vitest test-file.test.ts
|
||||
```
|
||||
|
||||
## Test Fixing Principles
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Gather Sufficient Context**
|
||||
Before fixing tests, ensure you:
|
||||
- Fully understand the test's intent and implementation
|
||||
- Strongly recommended: review the current git diff and PR diff
|
||||
|
||||
2. **Prioritize Test Fixes**
|
||||
If the test itself is incorrect, fix the test first rather than the implementation code.
|
||||
|
||||
3. **Focus on a Single Issue**
|
||||
Only fix the specified test; don't add extra tests along the way.
|
||||
|
||||
4. **Don't Act Unilaterally**
|
||||
When discovering other issues, don't modify them directly—raise and discuss first.
|
||||
|
||||
### Testing Collaboration Best Practices
|
||||
|
||||
Important collaboration principles based on real development experience:
|
||||
|
||||
#### 1. Failure Handling Strategy
|
||||
|
||||
**Core Principle**: Avoid blind retries; quickly identify problems and seek help.
|
||||
|
||||
- **Failure Threshold**: After 1-2 consecutive failed fix attempts, stop immediately
|
||||
- **Problem Summary**: Analyze failure reasons and document attempted solutions with their failure causes
|
||||
- **Seek Help**: Approach the team with a clear problem summary and attempt history
|
||||
- **Avoid the Trap**: Don't fall into the loop of repeatedly trying the same or similar approaches
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong approach: Keep blindly trying after consecutive failures
|
||||
// 3rd, 4th attempts still using similar methods to fix the same problem
|
||||
|
||||
// ✅ Correct approach: Summarize after 1-2 failures
|
||||
/*
|
||||
Problem Summary:
|
||||
1. Attempted method: Modified mock data structure
|
||||
2. Failure reason: Still getting type mismatch error
|
||||
3. Specific error: Expected 'UserData' but received 'UserProfile'
|
||||
4. Help needed: Unsure about the latest UserData interface definition
|
||||
*/
|
||||
```
|
||||
|
||||
#### 2. Test Case Naming Conventions
|
||||
|
||||
**Core Principle**: Tests should focus on "behavior," not "implementation details."
|
||||
|
||||
- **Describe Business Scenarios**: `describe` and `it` titles should describe specific business scenarios and expected behaviors
|
||||
- **Avoid Implementation Binding**: Don't mention specific line numbers, coverage goals, or implementation details in test names
|
||||
- **Maintain Stability**: Test names should remain meaningful after code refactoring
|
||||
|
||||
```typescript
|
||||
// ❌ Poor test naming
|
||||
describe('User component coverage', () => {
|
||||
it('covers line 45-50 in getUserData', () => {
|
||||
// Test written just to cover lines 45-50
|
||||
});
|
||||
|
||||
it('tests the else branch', () => {
|
||||
// Exists only to test a specific branch
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ Good test naming
|
||||
describe('<UserAvatar />', () => {
|
||||
it('should render fallback icon when image url is not provided', () => {
|
||||
// Tests a specific business scenario, naturally covering relevant code branches
|
||||
});
|
||||
|
||||
it('should display user initials when avatar image fails to load', () => {
|
||||
// Describes user behavior and expected outcome
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**The Right Approach to Improving Coverage**:
|
||||
|
||||
- Naturally improve coverage by designing various business scenarios (happy paths, edge cases, error handling)
|
||||
- Don't write tests just to hit coverage numbers, and never comment "to cover line xxx" in tests
|
||||
|
||||
#### 3. Test Organization Structure
|
||||
|
||||
**Core Principle**: Maintain a clear test hierarchy; avoid redundant top-level test blocks.
|
||||
|
||||
- **Reuse Existing Structure**: When adding new tests, first look for an appropriate place in existing `describe` blocks
|
||||
- **Logical Grouping**: Related test cases should be organized within the same `describe` block
|
||||
- **Avoid Fragmentation**: Don't create a new top-level `describe` block for a single test case
|
||||
|
||||
```typescript
|
||||
// ❌ Poor organization: Too many top-level blocks
|
||||
describe('<UserProfile />', () => {
|
||||
it('should render user name', () => {});
|
||||
});
|
||||
|
||||
describe('UserProfile new prop test', () => {
|
||||
// Unnecessary new block
|
||||
it('should handle email display', () => {});
|
||||
});
|
||||
|
||||
describe('UserProfile edge cases', () => {
|
||||
// Unnecessary new block
|
||||
it('should handle missing avatar', () => {});
|
||||
});
|
||||
|
||||
// ✅ Good organization: Merge related tests
|
||||
describe('<UserProfile />', () => {
|
||||
it('should render user name', () => {});
|
||||
|
||||
it('should handle email display', () => {});
|
||||
|
||||
it('should handle missing avatar', () => {});
|
||||
|
||||
describe('when user data is incomplete', () => {
|
||||
// Only create sub-groups when there are multiple related sub-scenarios
|
||||
it('should show placeholder for missing name', () => {});
|
||||
it('should hide email section when email is undefined', () => {});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Organization Decision Flow**:
|
||||
|
||||
1. Is there a logically related existing `describe` block? → If yes, add to it
|
||||
2. Are there multiple (3+) related test cases? → If yes, consider creating a new sub-`describe`
|
||||
3. Is it an independent, unrelated feature module? → Only then consider creating a new top-level `describe`
|
||||
|
||||
### Test Fixing Workflow
|
||||
|
||||
1. **Reproduce the Issue**: Locate and run the failing test; confirm it can be reproduced locally
|
||||
2. **Analyze the Cause**: Read test code, error logs, and Git history of related files
|
||||
3. **Form a Hypothesis**: Determine if the problem is in test logic, implementation code, or environment configuration
|
||||
4. **Fix and Verify**: Apply the fix based on your hypothesis; rerun the test to confirm it passes
|
||||
5. **Expand Verification**: Run all tests in the current file to ensure no new issues were introduced
|
||||
6. **Write a Summary**: Document the error cause and fix method
|
||||
|
||||
### Post-Fix Summary
|
||||
|
||||
After completing a test fix, provide a brief explanation including:
|
||||
|
||||
1. **Root Cause Analysis**: Explain the fundamental reason for the test failure
|
||||
- Test logic error
|
||||
- Implementation bug
|
||||
- Environment configuration issue
|
||||
- Dependency change
|
||||
|
||||
2. **Fix Description**: Briefly describe the fix approach
|
||||
- Which files were modified
|
||||
- What solution was applied
|
||||
- Why this fix approach was chosen
|
||||
|
||||
**Example Format**:
|
||||
|
||||
```markdown
|
||||
## Test Fix Summary
|
||||
|
||||
**Root Cause**: The mock data format in the test didn't match the actual API response format, causing assertion failures.
|
||||
|
||||
**Fix**: Updated the mock data structure in the test file to match the latest API response format. Specifically modified the `mockUserData` object structure in `user.test.ts`.
|
||||
```
|
||||
|
||||
## Test Writing Best Practices
|
||||
|
||||
### Mock Data Strategy: Aim for "Low-Cost Authenticity"
|
||||
|
||||
**Core Principle**: Test data should default to authenticity; only simplify when it introduces "high testing costs."
|
||||
|
||||
#### What Are "High Testing Costs"?
|
||||
|
||||
"High cost" refers to introducing external dependencies in tests that make them slow, unstable, or complex:
|
||||
|
||||
- **File I/O Operations**: Reading/writing disk files
|
||||
- **Network Requests**: HTTP calls, database connections
|
||||
- **System Calls**: Getting system time, environment variables, etc.
|
||||
|
||||
#### Recommended Approach: Mock Dependencies, Keep Real Data
|
||||
|
||||
```typescript
|
||||
// ✅ Good approach: Mock I/O operations but use real file content formats
|
||||
describe('parseContentType', () => {
|
||||
beforeEach(() => {
|
||||
// Mock file read operation (avoid real I/O)
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((path) => {
|
||||
// But return real file content formats
|
||||
if (path.includes('.pdf')) return '%PDF-1.4\n%âãÏÓ'; // Real PDF header
|
||||
if (path.includes('.png')) return '\x89PNG\r\n\x1a\n'; // Real PNG header
|
||||
return '';
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect PDF content type correctly', () => {
|
||||
const result = parseContentType('/path/to/file.pdf');
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ Over-simplified: Using unrealistic data
|
||||
describe('parseContentType', () => {
|
||||
it('should detect PDF content type correctly', () => {
|
||||
// This simplified data has no test value
|
||||
const result = parseContentType('fake-pdf-content');
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### The Value of Real Identifiers
|
||||
|
||||
```typescript
|
||||
// ✅ Use real identifiers
|
||||
const result = parseModelString('openai', '+gpt-4,+gpt-3.5-turbo');
|
||||
|
||||
// ❌ Use placeholders (lower value)
|
||||
const result = parseModelString('test-provider', '+model1,+model2');
|
||||
```
|
||||
|
||||
### Modern Mocking Techniques: Environment Setup and Mock Methods
|
||||
|
||||
When testing client-side code, use environment annotations with modern mock methods:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @vitest-environment happy-dom // Provides browser APIs
|
||||
*/
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
// Modern method 1: Use vi.stubGlobal instead of global.xxx = ...
|
||||
const mockImage = vi.fn().mockImplementation(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
naturalHeight: 600,
|
||||
naturalWidth: 800,
|
||||
}));
|
||||
vi.stubGlobal('Image', mockImage);
|
||||
|
||||
// Modern method 2: Use vi.spyOn to preserve original functionality, only mock specific methods
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
});
|
||||
```
|
||||
|
||||
#### Environment Selection Priority
|
||||
|
||||
1. **@vitest-environment happy-dom** (Recommended) - Lightweight, fast, already installed in the project
|
||||
2. **@vitest-environment jsdom** - Full-featured, but requires additional jsdom package installation
|
||||
3. **No environment set** - Node.js environment, requires manually mocking all browser APIs
|
||||
|
||||
#### Mock Method Comparison
|
||||
|
||||
```typescript
|
||||
// ❌ Old method: Directly manipulating global object (type issues)
|
||||
global.Image = mockImage;
|
||||
global.URL = { ...global.URL, createObjectURL: mockFn };
|
||||
|
||||
// ✅ Modern method: Type-safe vi API
|
||||
vi.stubGlobal('Image', mockImage); // Completely replace global object
|
||||
vi.spyOn(URL, 'createObjectURL'); // Partial mock, preserve other functionality
|
||||
```
|
||||
|
||||
### Test Coverage Principles: Code Branches Over Test Quantity
|
||||
|
||||
**Core Principle**: Prioritize covering all code branches rather than writing many repetitive test cases.
|
||||
|
||||
```typescript
|
||||
// ❌ Over-testing: 29 test cases all validating the same branch
|
||||
describe('getImageDimensions', () => {
|
||||
it('should reject .txt files');
|
||||
it('should reject .pdf files');
|
||||
// ... 25 similar tests, all hitting the same validation branch
|
||||
});
|
||||
|
||||
// ✅ Lean testing: 4 core cases covering all branches
|
||||
describe('getImageDimensions', () => {
|
||||
it('should return dimensions for valid File object'); // Success path - File
|
||||
it('should return dimensions for valid data URI'); // Success path - String
|
||||
it('should return undefined for invalid inputs'); // Input validation branch
|
||||
it('should return undefined when image fails to load'); // Error handling branch
|
||||
});
|
||||
```
|
||||
|
||||
#### Branch Coverage Strategy
|
||||
|
||||
1. **Success Paths** - One test per input type is sufficient
|
||||
2. **Boundary Conditions** - Consolidate similar scenarios into a single test
|
||||
3. **Error Handling** - Test representative errors only
|
||||
4. **Business Logic** - Cover all if/else branches
|
||||
|
||||
#### Reasonable Test Counts
|
||||
|
||||
- Simple utility functions: 2-5 tests
|
||||
- Complex business logic: 5-10 tests
|
||||
- Core security features: Add more as needed, but avoid duplicate paths
|
||||
|
||||
### Error Handling Tests: Test "Behavior" Not "Text"
|
||||
|
||||
**Core Principle**: Tests should verify that program behavior is predictable when errors occur, not verify error message text that may change.
|
||||
|
||||
#### Recommended Error Testing Approach
|
||||
|
||||
```typescript
|
||||
// ✅ Test error types and properties
|
||||
expect(() => validateUser({})).toThrow(ValidationError);
|
||||
expect(() => processPayment({})).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'INVALID_PAYMENT_DATA',
|
||||
statusCode: 400,
|
||||
}),
|
||||
);
|
||||
|
||||
// ❌ Avoid testing specific error text
|
||||
expect(() => processUser({})).toThrow('User data cannot be empty, please check input parameters');
|
||||
```
|
||||
|
||||
### Troubleshooting: Beware of Module Pollution
|
||||
|
||||
**Warning Signs**: When your tests exhibit these "mysterious" behaviors, suspect module pollution first:
|
||||
|
||||
- A test passes when run alone but fails when run with other tests
|
||||
- Test execution order affects results
|
||||
- Mock setup appears correct but actually uses an old mock version
|
||||
|
||||
#### Typical Scenario: Dynamic Mocking of the Same Module
|
||||
|
||||
```typescript
|
||||
// ❌ Problem: Dynamic mocking of the same module
|
||||
it('dev mode', async () => {
|
||||
vi.doMock('./config', () => ({ isDev: true }));
|
||||
const { getSettings } = await import('./service'); // May use cache
|
||||
});
|
||||
|
||||
// ✅ Solution: Clear module cache
|
||||
beforeEach(() => {
|
||||
vi.resetModules(); // Ensure each test has a clean environment
|
||||
});
|
||||
```
|
||||
|
||||
**Remember**: `vi.resetModules()` is the ultimate weapon for resolving "mysterious" test failures.
|
||||
|
||||
## Test File Organization
|
||||
|
||||
### File Naming Convention
|
||||
|
||||
`*.test.ts`, `*.test.tsx` (any location)
|
||||
|
||||
### Test File Organization Style
|
||||
|
||||
The project uses a **co-located test files** organization style:
|
||||
|
||||
- Test files are placed in the same directory as the corresponding source files
|
||||
- Naming format: `originalFileName.test.ts` or `originalFileName.test.tsx`
|
||||
|
||||
Example:
|
||||
|
||||
```plaintext
|
||||
src/components/Button/
|
||||
├── index.tsx # Source file
|
||||
└── index.test.tsx # Test file
|
||||
```
|
||||
|
||||
- In some cases, tests are consolidated in a `__tests__` folder, e.g., `packages/database/src/models/__tests__`
|
||||
- Test helper files are placed in a fixtures folder
|
||||
|
||||
## Test Debugging Tips
|
||||
|
||||
### Test Debugging Steps
|
||||
|
||||
1. **Determine Test Environment**: Select the correct config file based on file path
|
||||
2. **Isolate the Problem**: Use the `-t` flag to run only the failing test case
|
||||
3. **Analyze the Error**: Carefully read error messages, stack traces, and recent file modification history
|
||||
4. **Add Debugging**: Add `console.log` statements in tests to understand execution flow
|
||||
|
||||
### TypeScript Type Handling
|
||||
|
||||
In tests, you can relax TypeScript type checking to improve writing efficiency and readability:
|
||||
|
||||
#### Recommended Type Relaxation Strategies
|
||||
|
||||
```typescript
|
||||
// Use non-null assertion to access properties you're certain exist in tests
|
||||
const result = await someFunction();
|
||||
expect(result!.data).toBeDefined();
|
||||
expect(result!.status).toBe('success');
|
||||
|
||||
// Use any type to simplify complex mock setups
|
||||
const mockStream = new ReadableStream() as any;
|
||||
mockStream.toReadableStream = () => mockStream;
|
||||
|
||||
// Access private members
|
||||
await instance['getFromCache']('key'); // Bracket notation recommended
|
||||
await (instance as any).getFromCache('key'); // Avoid as any
|
||||
```
|
||||
|
||||
#### Applicable Scenarios
|
||||
|
||||
- **Mock Objects**: Use `as any` for test mock data to avoid complex type definitions
|
||||
- **Third-Party Libraries**: Use `any` appropriately when handling complex third-party library types
|
||||
- **Test Assertions**: Use `!` non-null assertion in test scenarios where you're certain the object exists
|
||||
- **Private Member Access**: Prefer bracket notation `instance['privateMethod']()` over `(instance as any).privateMethod()`
|
||||
- **Temporary Debugging**: When quickly writing tests, use `any` first to ensure functionality, then optionally optimize types later
|
||||
|
||||
#### Important Notes
|
||||
|
||||
- **Use Moderately**: Don't over-rely on `any`; core business logic types should remain strict
|
||||
- **Private Member Access Priority**: Bracket notation > `as any` casting for better type safety
|
||||
- **Documentation**: Add comments explaining the reason for complex `any` usage scenarios
|
||||
- **Test Coverage**: Ensure tests still effectively verify correctness even when using `any`
|
||||
|
||||
### Checking Recent Modifications
|
||||
|
||||
**Core Principle**: When tests suddenly fail, first check recent code changes.
|
||||
|
||||
#### Quick Check Methods
|
||||
|
||||
```bash
|
||||
git status # View current modification status
|
||||
git diff HEAD -- '*.test.*' # Check test file changes
|
||||
git diff main...HEAD # Compare with main branch
|
||||
gh pr diff # View all changes in the PR
|
||||
```
|
||||
|
||||
#### Common Causes and Solutions
|
||||
|
||||
- **Latest commit introduced a bug** → Check and fix the implementation code
|
||||
- **Branch code is outdated** → `git rebase main` to sync with main branch
|
||||
|
||||
## Special Testing Scenarios
|
||||
|
||||
For special testing scenarios, refer to the related rules:
|
||||
|
||||
- `electron-ipc-test.mdc` - Electron IPC Interface Testing Strategy
|
||||
- `db-model-test.mdc` - Database Model Testing Guide
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **Command Format**: Use `bunx vitest run --silent='passed-only'` with file filtering
|
||||
- **Fix Principles**: Seek help after 1-2 failures; focus test naming on behavior, not implementation details
|
||||
- **Debug Workflow**: Reproduce → Analyze → Hypothesize → Fix → Verify → Summarize
|
||||
- **File Organization**: Prefer adding tests to existing `describe` blocks; avoid creating redundant top-level blocks
|
||||
- **Data Strategy**: Default to authenticity; only simplify for high-cost scenarios (I/O, network, etc.)
|
||||
- **Error Testing**: Test error types and behavior; avoid depending on specific error message text
|
||||
- **Module Pollution**: When tests fail "mysteriously," suspect module pollution first; use `vi.resetModules()` to resolve
|
||||
- **Security Requirements**: Model tests must include permission checks and pass in both environments
|
||||
@@ -1,574 +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)
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
description: TypeScript code style and optimization guidelines
|
||||
globs: *.ts,*.tsx,*.mts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# TypeScript Code Style Guide
|
||||
|
||||
## Types and Type Safety
|
||||
|
||||
- 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`).
|
||||
- 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.
|
||||
|
||||
## Asynchronous Patterns and Concurrency
|
||||
|
||||
- Prefer `async`/`await` over callbacks or chained `.then` promises.
|
||||
- Prefer async APIs over sync ones (avoid `*Sync`).
|
||||
- Prefer promise-based variants (e.g., `import { readFile } from 'fs/promises'`) over callback-based APIs from `fs`.
|
||||
- Where safe, convert sequential async flows to concurrent ones with `Promise.all`, `Promise.race`, etc.
|
||||
|
||||
## Code Structure and Readability
|
||||
|
||||
- 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.
|
||||
- Defer formatting to tooling; ignore purely formatting-only issues and autofixable lint problems.
|
||||
|
||||
## UI and Theming
|
||||
|
||||
- Use components from `@lobehub/ui`, Ant Design, or existing design system components instead of raw HTML tags (e.g., `Button` vs. `button`).
|
||||
- Design for dark mode and mobile responsiveness:
|
||||
- Use the `antd-style` token system instead of hard-coded colors.
|
||||
- Select appropriate component variants.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- Use console.error instead of debug package to log error message in catch block.
|
||||
@@ -1,328 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: src/store/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# LobeChat Zustand Action Patterns
|
||||
|
||||
## Action Type Hierarchy
|
||||
|
||||
LobeChat Actions use a layered architecture with clear separation of responsibilities:
|
||||
|
||||
### 1. Public Actions
|
||||
|
||||
Main interfaces exposed for UI component consumption:
|
||||
|
||||
- Naming: Verb form (`createTopic`, `sendMessage`, `updateTopicTitle`)
|
||||
- Responsibilities: Parameter validation, flow orchestration, calling internal actions
|
||||
- Example: `src/store/chat/slices/topic/action.ts`
|
||||
|
||||
```typescript
|
||||
// Public Action example
|
||||
createTopic: async () => {
|
||||
// ...
|
||||
return topicId;
|
||||
},
|
||||
```
|
||||
|
||||
### 2. Internal Actions (`internal_*`)
|
||||
|
||||
Internal implementation details handling core business logic:
|
||||
|
||||
- Naming: `internal_` prefix + verb (`internal_createTopic`, `internal_updateMessageContent`)
|
||||
- Responsibilities: Optimistic updates, service calls, error handling, state synchronization
|
||||
- Should not be called directly by UI components
|
||||
|
||||
```typescript
|
||||
// Internal Action example - Optimistic update pattern
|
||||
internal_createTopic: async (params) => {
|
||||
const tmpId = Date.now().toString();
|
||||
|
||||
// 1. Immediately update frontend state (optimistic update)
|
||||
get().internal_dispatchTopic(
|
||||
{ type: 'addTopic', value: { ...params, id: tmpId } },
|
||||
'internal_createTopic',
|
||||
);
|
||||
get().internal_updateTopicLoading(tmpId, true);
|
||||
|
||||
// 2. Call backend service
|
||||
const topicId = await topicService.createTopic(params);
|
||||
get().internal_updateTopicLoading(tmpId, false);
|
||||
|
||||
// 3. Refresh data to ensure consistency
|
||||
get().internal_updateTopicLoading(topicId, true);
|
||||
await get().refreshTopic();
|
||||
get().internal_updateTopicLoading(topicId, false);
|
||||
|
||||
return topicId;
|
||||
},
|
||||
```
|
||||
|
||||
### 3. Dispatch Methods (`internal_dispatch*`)
|
||||
|
||||
Methods dedicated to handling state updates:
|
||||
|
||||
- Naming: `internal_dispatch` + entity name (`internal_dispatchTopic`, `internal_dispatchMessage`)
|
||||
- Responsibilities: Calling reducers, updating Zustand store, handling state comparison
|
||||
|
||||
```typescript
|
||||
// Dispatch Method example
|
||||
internal_dispatchTopic: (payload, action) => {
|
||||
const nextTopics = topicReducer(topicSelectors.currentTopics(get()), payload);
|
||||
const nextMap = { ...get().topicMaps, [get().activeId]: nextTopics };
|
||||
|
||||
if (isEqual(nextMap, get().topicMaps)) return;
|
||||
|
||||
set({ topicMaps: nextMap }, false, action ?? n(`dispatchTopic/${payload.type}`));
|
||||
},
|
||||
```
|
||||
|
||||
## When to Use Reducer Pattern vs. Simple `set`
|
||||
|
||||
### Use Reducer Pattern When
|
||||
|
||||
Suitable for complex data structure management, especially:
|
||||
|
||||
- Managing object lists or maps (e.g., `messagesMap`, `topicMaps`)
|
||||
- Scenarios requiring optimistic updates
|
||||
- Complex state transition logic
|
||||
- Type-safe action payloads needed
|
||||
|
||||
```typescript
|
||||
// Reducer pattern example - Complex message state management
|
||||
export const messagesReducer = (state: ChatMessage[], payload: MessageDispatch): ChatMessage[] => {
|
||||
switch (payload.type) {
|
||||
case 'updateMessage': {
|
||||
return produce(state, (draftState) => {
|
||||
const index = draftState.findIndex((i) => i.id === payload.id);
|
||||
if (index < 0) return;
|
||||
draftState[index] = merge(draftState[index], {
|
||||
...payload.value,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
case 'createMessage': {
|
||||
// ...
|
||||
}
|
||||
// ...other complex state transitions
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Use Simple `set` When
|
||||
|
||||
Suitable for simple state updates:
|
||||
|
||||
- Toggling boolean values
|
||||
- Updating simple strings/numbers
|
||||
- Setting single state fields
|
||||
|
||||
```typescript
|
||||
// Simple set example
|
||||
updateInputMessage: (message) => {
|
||||
if (isEqual(message, get().inputMessage)) return;
|
||||
set({ inputMessage: message }, false, n('updateInputMessage'));
|
||||
},
|
||||
|
||||
togglePortal: (open?: boolean) => {
|
||||
set({ showPortal: open ?? !get().showPortal }, false, 'togglePortal');
|
||||
},
|
||||
```
|
||||
|
||||
## Optimistic Update Implementation Patterns
|
||||
|
||||
Optimistic updates are a core pattern in LobeChat for providing smooth user experience:
|
||||
|
||||
### Standard Optimistic Update Flow
|
||||
|
||||
```typescript
|
||||
// Complete optimistic update example
|
||||
internal_updateMessageContent: async (id, content, extra) => {
|
||||
const { internal_dispatchMessage, refreshMessages } = get();
|
||||
|
||||
// 1. Immediately update frontend state (optimistic update)
|
||||
internal_dispatchMessage({
|
||||
id,
|
||||
type: 'updateMessage',
|
||||
value: { content },
|
||||
});
|
||||
|
||||
// 2. Call backend service
|
||||
await messageService.updateMessage(id, {
|
||||
content,
|
||||
tools: extra?.toolCalls ? internal_transformToolCalls(extra.toolCalls) : undefined,
|
||||
// ...other fields
|
||||
});
|
||||
|
||||
// 3. Refresh to ensure data consistency
|
||||
await refreshMessages();
|
||||
},
|
||||
```
|
||||
|
||||
### Optimistic Update for Create Operations
|
||||
|
||||
```typescript
|
||||
internal_createMessage: async (message, context) => {
|
||||
const { internal_createTmpMessage, refreshMessages, internal_toggleMessageLoading } = get();
|
||||
|
||||
let tempId = context?.tempMessageId;
|
||||
if (!tempId) {
|
||||
// Create temporary message for optimistic update
|
||||
tempId = internal_createTmpMessage(message);
|
||||
internal_toggleMessageLoading(true, tempId);
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await messageService.createMessage(message);
|
||||
if (!context?.skipRefresh) {
|
||||
await refreshMessages();
|
||||
}
|
||||
internal_toggleMessageLoading(false, tempId);
|
||||
return id;
|
||||
} catch (e) {
|
||||
internal_toggleMessageLoading(false, tempId);
|
||||
// Error handling: update message error state
|
||||
internal_dispatchMessage({
|
||||
id: tempId,
|
||||
type: 'updateMessage',
|
||||
value: { error: { type: ChatErrorType.CreateMessageError, message: e.message } },
|
||||
});
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### Delete Operation Pattern (No Optimistic Update)
|
||||
|
||||
Delete operations typically don't suit optimistic updates because:
|
||||
|
||||
- Deletion is destructive; error recovery is complex
|
||||
- Users have lower expectations for immediate feedback on deletions
|
||||
- Restoring state on deletion failure causes confusion
|
||||
|
||||
```typescript
|
||||
// Standard delete operation pattern - No optimistic update
|
||||
removeGenerationTopic: async (id: string) => {
|
||||
const { internal_removeGenerationTopic } = get();
|
||||
await internal_removeGenerationTopic(id);
|
||||
},
|
||||
|
||||
internal_removeGenerationTopic: async (id: string) => {
|
||||
// 1. Show loading state
|
||||
get().internal_updateGenerationTopicLoading(id, true);
|
||||
|
||||
try {
|
||||
// 2. Directly call backend service
|
||||
await generationTopicService.deleteTopic(id);
|
||||
|
||||
// 3. Refresh data to get latest state
|
||||
await get().refreshGenerationTopics();
|
||||
} finally {
|
||||
// 4. Ensure loading state is cleared (whether success or failure)
|
||||
get().internal_updateGenerationTopicLoading(id, false);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Delete operation characteristics:
|
||||
|
||||
- Directly call service without pre-updating state
|
||||
- Rely on loading state for user feedback
|
||||
- Refresh entire list after operation to ensure consistency
|
||||
- Use `try/finally` to ensure loading state is always cleaned up
|
||||
|
||||
## Loading State Management Pattern
|
||||
|
||||
LobeChat uses a unified loading state management pattern:
|
||||
|
||||
### Array-based Loading State
|
||||
|
||||
```typescript
|
||||
// Define in initialState.ts
|
||||
export interface ChatMessageState {
|
||||
messageEditingIds: string[]; // Message editing state
|
||||
}
|
||||
|
||||
// Manage in action
|
||||
{
|
||||
toggleMessageEditing: (id, editing) => {
|
||||
set(
|
||||
{ messageEditingIds: toggleBooleanList(get().messageEditingIds, id, editing) },
|
||||
false,
|
||||
'toggleMessageEditing',
|
||||
);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## SWR Integration Pattern
|
||||
|
||||
LobeChat uses SWR for data fetching and cache management:
|
||||
|
||||
### Hook-based Data Fetching
|
||||
|
||||
```typescript
|
||||
// Define SWR hook in action.ts
|
||||
useFetchMessages: (enable, sessionId, activeTopicId) =>
|
||||
useClientDataSWR<ChatMessage[]>(
|
||||
enable ? [SWR_USE_FETCH_MESSAGES, sessionId, activeTopicId] : null,
|
||||
async ([, sessionId, topicId]) => messageService.getMessages(sessionId, topicId),
|
||||
{
|
||||
onSuccess: (messages, key) => {
|
||||
const nextMap = {
|
||||
...get().messagesMap,
|
||||
[messageMapKey(sessionId, activeTopicId)]: messages,
|
||||
};
|
||||
|
||||
if (get().messagesInit && isEqual(nextMap, get().messagesMap)) return;
|
||||
|
||||
set({ messagesInit: true, messagesMap: nextMap }, false, n('useFetchMessages'));
|
||||
},
|
||||
},
|
||||
),
|
||||
```
|
||||
|
||||
### Cache Invalidation and Refresh
|
||||
|
||||
```typescript
|
||||
// Standard data refresh pattern
|
||||
refreshMessages: async () => {
|
||||
await mutate([SWR_USE_FETCH_MESSAGES, get().activeId, get().activeTopicId]);
|
||||
};
|
||||
```
|
||||
|
||||
## Naming Convention Summary
|
||||
|
||||
### Action Naming Patterns
|
||||
|
||||
- Public Actions: Verb form, describing user intent
|
||||
- `createTopic`, `sendMessage`, `regenerateMessage`
|
||||
- Internal Actions: `internal_` + verb, describing internal operation
|
||||
- `internal_createTopic`, `internal_updateMessageContent`
|
||||
- Dispatch Methods: `internal_dispatch` + entity name
|
||||
- `internal_dispatchTopic`, `internal_dispatchMessage`
|
||||
- Toggle Methods: `internal_toggle` + state name
|
||||
- `internal_toggleMessageLoading`, `internal_toggleChatLoading`
|
||||
|
||||
### State Naming Patterns
|
||||
|
||||
- ID arrays: `[entity]LoadingIds`, `[entity]EditingIds`
|
||||
- Map structures: `[entity]Maps`, `[entity]Map`
|
||||
- Currently active: `active[Entity]Id`
|
||||
- Initialization flags: `[entity]sInit`
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use optimistic updates appropriately:
|
||||
- ✅ Suitable: Create, update operations (frequent user interaction)
|
||||
- ❌ Avoid: Delete operations (destructive, complex error recovery)
|
||||
2. Loading state management: Use unified loading state arrays to manage concurrent operations
|
||||
3. Type safety: Define TypeScript interfaces for all action payloads
|
||||
4. SWR integration: Use SWR to manage data fetching and cache invalidation
|
||||
5. AbortController: Provide cancellation capability for long-running operations
|
||||
6. Operation mode selection:
|
||||
- Create/Update: Optimistic update + eventual consistency
|
||||
- Delete: Loading state + service call + data refresh
|
||||
|
||||
This Action organization pattern ensures code consistency, maintainability, and provides excellent user experience.
|
||||
@@ -1,308 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: src/store/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# LobeChat Zustand Store Slice 组织架构
|
||||
|
||||
本文档描述了 LobeChat 项目中 Zustand Store 的模块化 Slice 组织方式,展示如何通过分片架构管理复杂的应用状态。
|
||||
|
||||
## 顶层 Store 结构
|
||||
|
||||
LobeChat 的 `chat` store (`src/store/chat/`) 采用模块化的 slice 结构来组织状态和逻辑。
|
||||
|
||||
### 关键聚合文件
|
||||
|
||||
- `src/store/chat/initialState.ts`: 聚合所有 slice 的初始状态
|
||||
- `src/store/chat/store.ts`: 定义顶层的 `ChatStore`,组合所有 slice 的 actions
|
||||
- `src/store/chat/selectors.ts`: 统一导出所有 slice 的 selectors
|
||||
- `src/store/chat/helpers.ts`: 提供聊天相关的辅助函数
|
||||
|
||||
### Store 聚合模式
|
||||
|
||||
```typescript
|
||||
// src/store/chat/initialState.ts
|
||||
import { ChatTopicState, initialTopicState } from './slices/topic/initialState';
|
||||
import { ChatMessageState, initialMessageState } from './slices/message/initialState';
|
||||
import { ChatAIChatState, initialAiChatState } from './slices/aiChat/initialState';
|
||||
|
||||
export type ChatStoreState = ChatTopicState &
|
||||
ChatMessageState &
|
||||
ChatAIChatState &
|
||||
// ...其他 slice states
|
||||
|
||||
export const initialState: ChatStoreState = {
|
||||
...initialMessageState,
|
||||
...initialTopicState,
|
||||
...initialAiChatState,
|
||||
// ...其他 initial slice states
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/chat/store.ts
|
||||
import { ChatMessageAction, chatMessage } from './slices/message/action';
|
||||
import { ChatTopicAction, chatTopic } from './slices/topic/action';
|
||||
import { ChatAIChatAction, chatAiChat } from './slices/aiChat/actions';
|
||||
|
||||
export interface ChatStoreAction
|
||||
extends ChatMessageAction,
|
||||
ChatTopicAction,
|
||||
ChatAIChatAction,
|
||||
// ...其他 slice actions
|
||||
|
||||
const createStore: StateCreator<ChatStore, [['zustand/devtools', never]]> = (...params) => ({
|
||||
...initialState,
|
||||
...chatMessage(...params),
|
||||
...chatTopic(...params),
|
||||
...chatAiChat(...params),
|
||||
// ...其他 slice action creators
|
||||
});
|
||||
|
||||
export const useChatStore = createWithEqualityFn<ChatStore>()(
|
||||
subscribeWithSelector(devtools(createStore)),
|
||||
shallow,
|
||||
);
|
||||
```
|
||||
|
||||
## 单个 Slice 的标准结构
|
||||
|
||||
每个 slice 位于 `src/store/chat/slices/[sliceName]/` 目录下:
|
||||
|
||||
```plaintext
|
||||
src/store/chat/slices/
|
||||
└── [sliceName]/ # 例如 message, topic, aiChat, builtinTool
|
||||
├── action.ts # 定义 actions (或者是一个 actions/ 目录)
|
||||
├── initialState.ts # 定义 state 结构和初始值
|
||||
├── reducer.ts # (可选) 如果使用 reducer 模式
|
||||
├── selectors.ts # 定义 selectors
|
||||
└── index.ts # (可选) 重新导出模块内容
|
||||
```
|
||||
|
||||
### 文件职责说明
|
||||
|
||||
1. `initialState.ts`:
|
||||
- 定义 slice 的 TypeScript 状态接口
|
||||
- 提供初始状态默认值
|
||||
|
||||
```typescript
|
||||
// 典型的 initialState.ts 结构
|
||||
export interface ChatTopicState {
|
||||
activeTopicId?: string;
|
||||
topicMaps: Record<string, ChatTopic[]>; // 核心数据结构
|
||||
topicsInit: boolean;
|
||||
topicLoadingIds: string[];
|
||||
// ...其他状态字段
|
||||
}
|
||||
|
||||
export const initialTopicState: ChatTopicState = {
|
||||
activeTopicId: undefined,
|
||||
topicMaps: {},
|
||||
topicsInit: false,
|
||||
topicLoadingIds: [],
|
||||
// ...其他初始值
|
||||
};
|
||||
```
|
||||
|
||||
1. `reducer.ts` (复杂状态使用):
|
||||
- 定义纯函数 reducer,处理同步状态转换
|
||||
- 使用 `immer` 确保不可变更新
|
||||
|
||||
```typescript
|
||||
// 典型的 reducer.ts 结构
|
||||
import { produce } from 'immer';
|
||||
|
||||
interface AddChatTopicAction {
|
||||
type: 'addTopic';
|
||||
value: CreateTopicParams & { id?: string };
|
||||
}
|
||||
|
||||
interface UpdateChatTopicAction {
|
||||
id: string;
|
||||
type: 'updateTopic';
|
||||
value: Partial<ChatTopic>;
|
||||
}
|
||||
|
||||
export type ChatTopicDispatch = AddChatTopicAction | UpdateChatTopicAction;
|
||||
|
||||
export const topicReducer = (state: ChatTopic[] = [], payload: ChatTopicDispatch): ChatTopic[] => {
|
||||
switch (payload.type) {
|
||||
case 'addTopic': {
|
||||
return produce(state, (draftState) => {
|
||||
draftState.unshift({
|
||||
...payload.value,
|
||||
id: payload.value.id ?? Date.now().toString(),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
case 'updateTopic': {
|
||||
return produce(state, (draftState) => {
|
||||
const index = draftState.findIndex((topic) => topic.id === payload.id);
|
||||
if (index !== -1) {
|
||||
draftState[index] = { ...draftState[index], ...payload.value };
|
||||
}
|
||||
});
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
1. `selectors.ts`:
|
||||
- 提供状态查询和计算函数
|
||||
- 供 UI 组件使用的状态订阅接口
|
||||
- 重要: 使用 `export const xxxSelectors` 模式聚合所有 selectors
|
||||
|
||||
```typescript
|
||||
// 典型的 selectors.ts 结构
|
||||
import { ChatStoreState } from '../../initialState';
|
||||
|
||||
const currentTopics = (s: ChatStoreState): ChatTopic[] | undefined => s.topicMaps[s.activeId];
|
||||
|
||||
const currentActiveTopic = (s: ChatStoreState): ChatTopic | undefined => {
|
||||
return currentTopics(s)?.find((topic) => topic.id === s.activeTopicId);
|
||||
};
|
||||
|
||||
const getTopicById =
|
||||
(id: string) =>
|
||||
(s: ChatStoreState): ChatTopic | undefined =>
|
||||
currentTopics(s)?.find((topic) => topic.id === id);
|
||||
|
||||
// 核心模式:使用 xxxSelectors 聚合导出
|
||||
export const topicSelectors = {
|
||||
currentActiveTopic,
|
||||
currentTopics,
|
||||
getTopicById,
|
||||
// ...其他 selectors
|
||||
};
|
||||
```
|
||||
|
||||
## 特殊 Slice 组织模式
|
||||
|
||||
### 复杂 Actions 的子目录结构 (aiChat Slice)
|
||||
|
||||
当 slice 的 actions 过于复杂时,可以拆分到子目录:
|
||||
|
||||
```plaintext
|
||||
src/store/chat/slices/aiChat/
|
||||
├── actions/
|
||||
│ ├── generateAIChat.ts # AI 对话生成
|
||||
│ ├── rag.ts # RAG 检索增强生成
|
||||
│ ├── memory.ts # 对话记忆管理
|
||||
│ └── index.ts # 聚合所有 actions
|
||||
├── initialState.ts
|
||||
├── selectors.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
参考:`src/store/chat/slices/aiChat/actions/`
|
||||
|
||||
### 工具类 Slice (builtinTool)
|
||||
|
||||
管理多种内置工具的状态:
|
||||
|
||||
```plaintext
|
||||
src/store/chat/slices/builtinTool/
|
||||
├── actions/
|
||||
│ ├── dalle.ts # DALL-E 图像生成
|
||||
│ ├── search.ts # 搜索功能
|
||||
│ ├── localFile.ts # 本地文件操作
|
||||
│ └── index.ts
|
||||
├── initialState.ts
|
||||
├── selectors.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
参考:`src/store/chat/slices/builtinTool/`
|
||||
|
||||
## 状态设计模式
|
||||
|
||||
### 1. Map 结构用于关联数据
|
||||
|
||||
```typescript
|
||||
// 以 sessionId 为 key,管理多个会话的数据
|
||||
topicMaps: Record<string, ChatTopic[]>;
|
||||
messagesMap: Record<string, ChatMessage[]>;
|
||||
```
|
||||
|
||||
### 2. 数组用于加载状态管理
|
||||
|
||||
```typescript
|
||||
// 管理多个并发操作的加载状态
|
||||
messageLoadingIds: string[]
|
||||
topicLoadingIds: string[]
|
||||
chatLoadingIds: string[]
|
||||
```
|
||||
|
||||
### 3. 可选字段用于当前活动项
|
||||
|
||||
```typescript
|
||||
// 当前激活的实体 ID
|
||||
activeId: string
|
||||
activeTopicId?: string
|
||||
activeThreadId?: string
|
||||
```
|
||||
|
||||
## Slice 集成到顶层 Store
|
||||
|
||||
### 1. 状态聚合
|
||||
|
||||
```typescript
|
||||
// 在 initialState.ts 中
|
||||
export type ChatStoreState = ChatTopicState &
|
||||
ChatMessageState &
|
||||
ChatAIChatState &
|
||||
// ...其他 slice states
|
||||
```
|
||||
|
||||
### 2. Action 接口聚合
|
||||
|
||||
```typescript
|
||||
// 在 store.ts 中
|
||||
export interface ChatStoreAction
|
||||
extends ChatMessageAction,
|
||||
ChatTopicAction,
|
||||
ChatAIChatAction,
|
||||
// ...其他 slice actions
|
||||
```
|
||||
|
||||
### 3. Selector 统一导出
|
||||
|
||||
```typescript
|
||||
// 在 selectors.ts 中 - 统一聚合 selectors
|
||||
export { chatSelectors } from './slices/message/selectors';
|
||||
export { topicSelectors } from './slices/topic/selectors';
|
||||
export { aiChatSelectors } from './slices/aiChat/selectors';
|
||||
|
||||
// 每个 slice 的 selectors.ts 都使用 xxxSelectors 模式:
|
||||
// export const chatSelectors = { ... }
|
||||
// export const topicSelectors = { ... }
|
||||
// export const aiChatSelectors = { ... }
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. Slice 划分原则:
|
||||
- 按功能领域划分(message, topic, aiChat 等)
|
||||
- 每个 slice 管理相关的状态和操作
|
||||
- 避免 slice 之间的强耦合
|
||||
|
||||
2. 文件命名规范:
|
||||
- 使用小驼峰命名 slice 目录
|
||||
- 文件名使用一致的模式(action.ts, selectors.ts 等)
|
||||
- 复杂 actions 时使用 actions/ 子目录
|
||||
|
||||
3. 状态结构设计:
|
||||
- 扁平化的状态结构,避免深层嵌套
|
||||
- 使用 Map 结构管理列表数据
|
||||
- 分离加载状态和业务数据
|
||||
|
||||
4. 类型安全:
|
||||
- 为每个 slice 定义清晰的 TypeScript 接口
|
||||
- 使用 Zustand 的 StateCreator 确保类型一致性
|
||||
- 在顶层聚合时保持类型安全
|
||||
|
||||
这种模块化的 slice 组织方式使得大型应用的状态管理变得清晰、可维护,并且易于扩展。
|
||||
+1
-1
@@ -1 +1 @@
|
||||
../.agents
|
||||
../.agents/skills
|
||||
Reference in New Issue
Block a user