Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa6d64df2 | |||
| 403aebd52e | |||
| 356cf0c392 | |||
| be98d56ef4 | |||
| 3fae1b2638 | |||
| ee4cc6c2e0 | |||
| b8c0e2d639 | |||
| 74d20bdbe8 | |||
| 7bab44e74c | |||
| fdaa72564c | |||
| 7d85151cb6 | |||
| 23ef2eea59 | |||
| 74ab822140 | |||
| d726ff108d | |||
| dd7b661140 | |||
| 49023419cf | |||
| 2eccbc79eb | |||
| 3527cb65f1 | |||
| 1731c841d8 | |||
| 404ac21229 | |||
| 2eaa2dbea0 | |||
| 50c0ed168d | |||
| 780e231afa | |||
| 07f3e6a4c4 | |||
| 3c35edced5 | |||
| 15770f188f | |||
| 9d7c6014fd | |||
| d1e4a54b01 | |||
| 1bc8815fb4 | |||
| 284826bed0 | |||
| b50f1212cb | |||
| 946517a52e | |||
| d30cc62acf | |||
| 0192140909 | |||
| 9bae13b6c1 | |||
| fd7662c3ac | |||
| 66dbb246d9 | |||
| 3a2935a6e3 | |||
| 82ca0074d4 | |||
| 225d3b6ed5 | |||
| 66984d9418 | |||
| b16f19bfed | |||
| f61b222e9e | |||
| 368f7dbbc1 | |||
| 9c2350e643 | |||
| 7e8e5ef5b2 | |||
| 0ed9e7d947 | |||
| 19cc6e8562 | |||
| 2696de4078 | |||
| 9fb4b0dfc3 | |||
| caffbbd384 | |||
| 8a89b1a14e | |||
| dd1a6356b7 | |||
| d87919eba2 | |||
| 4bcf9822f7 | |||
| 9f2eea3d2f | |||
| 4917d175bb | |||
| 5b53773dc6 | |||
| 46bdf21f37 | |||
| 60739bc903 | |||
| 75273d5497 | |||
| 22e2de2b00 | |||
| cbddf3ed25 | |||
| 14fd4bb2e7 | |||
| cef8208457 | |||
| 7d85a772db | |||
| 83dd2865f0 | |||
| c04507e34f | |||
| c801f9ce58 | |||
| ac2a83a3ce | |||
| 7bdda9bab4 | |||
| e8321355f8 | |||
| 27c4881205 | |||
| 472c40e969 | |||
| c98e7f8032 | |||
| 0b1557d6ad | |||
| 5d852be8a2 | |||
| 993b0fa81f | |||
| 83783b4650 | |||
| 7dd65f0cb4 | |||
| ecf1fdc2f7 | |||
| 71c33300cf | |||
| 768ee2bf23 | |||
| d23d8f1c29 | |||
| 7eadecd340 | |||
| 785406be9a | |||
| 054ca5fd97 | |||
| 5d3a4ad460 | |||
| 8eb257bb2e | |||
| 25bfc802b5 | |||
| 752e576b80 | |||
| 166f3e2400 | |||
| a55b65b0b1 | |||
| 3359b3f237 | |||
| 8ba8e8217b | |||
| 43be2d1905 | |||
| 53e0b51cbd | |||
| c530000334 | |||
| 2bb1506ea2 | |||
| c8f32c301e | |||
| 1261cee35d |
@@ -1,13 +1,14 @@
|
||||
---
|
||||
description: How to code review
|
||||
globs:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Role Description
|
||||
|
||||
- You are a senior full-stack engineer skilled in performance optimization, security, and design systems.
|
||||
- You excel at reviewing code and providing constructive feedback.
|
||||
- Your task is to review submitted Git diffs **in Chinese** and return a structured review report.
|
||||
- You are a senior full-stack engineer skilled in performance optimization, security, and design systems.
|
||||
- You excel at reviewing code and providing constructive feedback.
|
||||
- Your task is to review submitted Git diffs **in Chinese** and return a structured review report.
|
||||
- Review style: concise, direct, focused on what matters most, with actionable suggestions.
|
||||
|
||||
## Before the Review
|
||||
@@ -16,54 +17,59 @@ Gather the modified code and context. Please strictly follow the process below:
|
||||
|
||||
1. Use `read_file` to read [package.json](mdc:package.json)
|
||||
2. Use terminal to run command `git diff HEAD | cat` to obtain the diff and list the changed files. If you recieived empty result, run the same command once more.
|
||||
3. Use `read_file` to open each changed file.
|
||||
4. Use `read_file` to read [rules-attach.mdc](mdc:.cursor/rules/rules-attach.mdc). Even if you think it's unnecessary, you must read it.
|
||||
5. combine changed files, step3 and `agent_requestable_workspace_rules`, list the rules which need to read
|
||||
3. Use `read_file` to open each changed file.
|
||||
4. Use `read_file` to read [rules-attach.mdc](mdc:.cursor/rules/rules-attach.mdc). Even if you think it's unnecessary, you must read it.
|
||||
5. combine changed files, step3 and `agent_requestable_workspace_rules`, list the rules which need to read
|
||||
6. Use `read_file` to read the rules list in step 5
|
||||
|
||||
## Review
|
||||
|
||||
### Code Style
|
||||
|
||||
- Ensure JSDoc comments accurately reflect the implementation; update them when needed.
|
||||
- Look for opportunities to simplify or modernize code with the latest JavaScript/TypeScript features.
|
||||
- Prefer `async`/`await` over callbacks or chained `.then` promises.
|
||||
- Use consistent, descriptive naming—avoid obscure abbreviations.
|
||||
- Replace magic numbers or strings with well-named constants.
|
||||
read [typescript.mdc](mdc:.cursor/rules/typescript.mdc) to learn the project's code style.
|
||||
|
||||
- Ensure JSDoc comments accurately reflect the implementation; update them when needed.
|
||||
- Look for opportunities to simplify or modernize code with the latest JavaScript/TypeScript features.
|
||||
- Prefer `async`/`await` over callbacks or chained `.then` promises.
|
||||
- Use consistent, descriptive naming—avoid obscure abbreviations.
|
||||
- Replace magic numbers or strings with well-named constants.
|
||||
- Use semantically meaningful variable, function, and class names.
|
||||
- Ignore purely formatting issues and other autofixable lint problems.
|
||||
|
||||
### Code Optimization
|
||||
|
||||
- Prefer `for…of` loops to index-based `for` loops when feasible.
|
||||
- Decide whether callbacks should be **debounced** or **throttled**.
|
||||
- Use components from `@lobehub/ui`, Ant Design, or the existing design system instead of raw HTML tags (e.g., `Button` vs. `button`).
|
||||
- reuse npm packages already installed (e.g., `lodash/omit`) rather than reinventing the wheel.
|
||||
- Design for dark mode and mobile responsiveness:
|
||||
- Use the `antd-style` token system instead of hard-coded colors.
|
||||
- Select the proper component variants.
|
||||
- Performance considerations:
|
||||
- Where safe, convert sequential async flows to concurrent ones with `Promise.all`, `Promise.race`, etc.
|
||||
- Prefer `for…of` loops to index-based `for` loops when feasible.
|
||||
- Decide whether callbacks should be **debounced** or **throttled**.
|
||||
- Use components from `@lobehub/ui`, Ant Design, or the existing design system instead of raw HTML tags (e.g., `Button` vs. `button`).
|
||||
- reuse npm packages already installed (e.g., `lodash/omit`) rather than reinventing the wheel.
|
||||
- Design for dark mode and mobile responsiveness:
|
||||
- Use the `antd-style` token system instead of hard-coded colors.
|
||||
- Select the proper component variants.
|
||||
- Performance considerations:
|
||||
- Where safe, convert sequential async flows to concurrent ones with `Promise.all`, `Promise.race`, etc.
|
||||
- Query only the required columns from a database rather than selecting entire rows.
|
||||
|
||||
### Obvious Bugs
|
||||
|
||||
- Do not silently swallow errors in `catch` blocks; at minimum, log them.
|
||||
- Revert temporary code used only for testing (e.g., debug logs, temporary configs).
|
||||
- Remove empty handlers (e.g., an empty `onClick`).
|
||||
- Do not silently swallow errors in `catch` blocks; at minimum, log them.
|
||||
- Revert temporary code used only for testing (e.g., debug logs, temporary configs).
|
||||
- Remove empty handlers (e.g., an empty `onClick`).
|
||||
- Confirm the UI degrades gracefully for unauthenticated users.
|
||||
- Don't leave any debug logs in the code (except when using the `debug` module properly).
|
||||
- When using the `debug` module, avoid `import { log } from 'debug'` as it logs directly to console. Use proper debug namespaces instead.
|
||||
- Check logs for sensitive information like api key, etc
|
||||
|
||||
## After the Review: output
|
||||
|
||||
1. Summary
|
||||
- Start with a brief explanation of what the change set does.
|
||||
- Summarize the changes for each modified file (or logical group).
|
||||
- Start with a brief explanation of what the change set does.
|
||||
- Summarize the changes for each modified file (or logical group).
|
||||
2. Comments Issues
|
||||
- List the most critical issues first.
|
||||
- Use an ordered list, which will be convenient for me to reference later.
|
||||
- For each issue:
|
||||
- Mark severity tag (`❌ Must fix`, `⚠️ Should fix`, `💅 Nitpick`)
|
||||
- Provode file path to the relevant file.
|
||||
- Provide recommended fix
|
||||
- End with a **git commit** command, instruct the author to run it.
|
||||
- We use gitmoji to label commit messages, format: [emoji] <type>(<scope>): <subject>
|
||||
- List the most critical issues first.
|
||||
- Use an ordered list, which will be convenient for me to reference later.
|
||||
- For each issue:
|
||||
- Mark severity tag (`❌ Must fix`, `⚠️ Should fix`, `💅 Nitpick`)
|
||||
- Provode file path to the relevant file.
|
||||
- Provide recommended fix
|
||||
- End with a **git commit** command, instruct the author to run it.
|
||||
- We use gitmoji to label commit messages, format: [emoji] <type>(<scope>): <subject>
|
||||
|
||||
@@ -55,59 +55,4 @@ pnpm install
|
||||
# !: don't any build script to check weather code can work after modify
|
||||
```
|
||||
|
||||
check [testing guide](./testing-guide.mdc) to learn test scripts.
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI chat framework: lobe chat.
|
||||
|
||||
Emoji logo: 🤯
|
||||
|
||||
## Project Technologies Stack
|
||||
|
||||
read [package.json](mdc:package.json) to know all npm packages you can use. read [folder-structure.mdx](mdc:docs/development/basic/folder-structure.mdx) to learn project structure.
|
||||
|
||||
The project uses the following technologies:
|
||||
|
||||
- pnpm as package manager
|
||||
- Next.js 15 for frontend and backend, using app router instead of pages router
|
||||
- react 19, using hooks, functional components, react server components
|
||||
- TypeScript programming language
|
||||
- antd, @lobehub/ui for component framework
|
||||
- antd-style for css-in-js framework
|
||||
- react-layout-kit for flex layout
|
||||
- react-i18next for i18n
|
||||
- lucide-react, @ant-design/icons for icons
|
||||
- @lobehub/icons for AI provider/model logo icon
|
||||
- @formkit/auto-animate for react list animation
|
||||
- zustand for global state management
|
||||
- nuqs for type-safe search params state manager
|
||||
- SWR for react data fetch
|
||||
- aHooks for react hooks library
|
||||
- dayjs for date and time library
|
||||
- lodash-es for utility library
|
||||
- fast-deep-equal for deep comparison of JavaScript objects
|
||||
- zod for data validation
|
||||
- TRPC for type safe backend
|
||||
- PGLite for client DB and PostgreSQL for backend DB
|
||||
- Drizzle ORM
|
||||
- Vitest for testing, testing-library for react component test
|
||||
- Prettier for code formatting
|
||||
- ESLint for code linting
|
||||
- Cursor AI for code editing and AI coding assistance
|
||||
|
||||
Note: All tools and libraries used are the latest versions. The application only needs to be compatible with the latest browsers;
|
||||
|
||||
## Often used npm scripts
|
||||
|
||||
```bash
|
||||
# type check
|
||||
bun type-check
|
||||
|
||||
# install dependencies
|
||||
pnpm install
|
||||
|
||||
# !: don't any build script to check weather code can work after modify
|
||||
```
|
||||
|
||||
check [testing guide](./testing-guide.mdc) to learn test scripts.
|
||||
check [testing guide](./testing-guide/testing-guide.mdc) to learn test scripts.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## System Role
|
||||
|
||||
You are an expert in full-stack Web development, proficient in JavaScript, TypeScript, CSS, React, Node.js, Next.js, Postgresql, all kinds of network protocols.
|
||||
@@ -11,7 +12,6 @@ You are an expert in LLM and Ai art. In Ai image generation, you are proficient
|
||||
|
||||
You are an expert in UI/UX design, proficient in web interaction patterns, responsive design, accessibility, and user behavior optimization. You excel at improving user retention and paid conversion rates through various interaction details.
|
||||
|
||||
|
||||
## Problem Solving
|
||||
|
||||
- Before formulating any response, you must first gather context by using tools like codebase_search, grep_search, file_search, web_search, fetch_rules, context7, and read_file to avoid making assumptions.
|
||||
@@ -36,3 +36,8 @@ You are an expert in UI/UX design, proficient in web interaction patterns, respo
|
||||
- If you're unable to access or retrieve content from websites, please inform me immediately and request the specific information needed rather than making assumptions
|
||||
- You can use emojis, npm packages like `chalk`/`chalk-animation`/`terminal-link`/`gradient-string`/`log-symbols`/`boxen`/`consola`/`@clack/prompts` to create beautiful terminal output
|
||||
- Don't run `tsc --noEmit` to check ts syntax error, because our project is very large and the validate very slow
|
||||
|
||||
## Some logging rules
|
||||
|
||||
- Never log user private information like api key, etc
|
||||
- Don't use `import { log } from 'debug'` to log messages, because it will directly log the message to the console.
|
||||
|
||||
@@ -1,881 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: *.test.ts,*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
type: agent-requested
|
||||
title: 测试指南 - LobeChat Testing Guide
|
||||
description: LobeChat 项目的 Vitest 测试环境配置、运行方式、修复原则指南
|
||||
---
|
||||
|
||||
# 测试指南 - LobeChat Testing Guide
|
||||
|
||||
## 🧪 测试环境概览
|
||||
|
||||
LobeChat 项目使用 Vitest 测试库,配置了两种不同的测试环境:
|
||||
|
||||
### 客户端测试环境 (DOM Environment)
|
||||
|
||||
- **配置文件**: [vitest.config.ts](mdc:vitest.config.ts)
|
||||
- **环境**: Happy DOM (浏览器环境模拟)
|
||||
- **数据库**: PGLite (浏览器环境的 PostgreSQL)
|
||||
- **用途**: 测试前端组件、客户端逻辑、React 组件等
|
||||
- **设置文件**: [tests/setup.ts](mdc:tests/setup.ts)
|
||||
|
||||
### 服务端测试环境 (Node Environment)
|
||||
|
||||
- **配置文件**: [vitest.config.server.ts](mdc:vitest.config.server.ts)
|
||||
- **环境**: Node.js
|
||||
- **数据库**: 真实的 PostgreSQL 数据库
|
||||
- **并发限制**: 单线程运行 (`singleFork: true`)
|
||||
- **用途**: 测试数据库模型、服务端逻辑、API 端点等
|
||||
- **设置文件**: [tests/setup-db.ts](mdc:tests/setup-db.ts)
|
||||
|
||||
## 🚀 测试运行命令
|
||||
|
||||
### package.json 脚本说明
|
||||
|
||||
查看 [package.json](mdc:package.json) 中的测试相关脚本:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "npm run test-app && npm run test-server",
|
||||
"test-app": "vitest run --config vitest.config.ts",
|
||||
"test-app:coverage": "vitest run --config vitest.config.ts --coverage",
|
||||
"test-server": "vitest run --config vitest.config.server.ts",
|
||||
"test-server:coverage": "vitest run --config vitest.config.server.ts --coverage"
|
||||
}
|
||||
```
|
||||
|
||||
### 推荐的测试运行方式
|
||||
|
||||
#### ✅ 正确的命令格式
|
||||
|
||||
```bash
|
||||
# 运行所有客户端测试
|
||||
npx vitest run --config vitest.config.ts
|
||||
|
||||
# 运行所有服务端测试
|
||||
npx vitest run --config vitest.config.server.ts
|
||||
|
||||
# 运行特定测试文件 (支持模糊匹配)
|
||||
npx vitest run --config vitest.config.ts basic
|
||||
npx vitest run --config vitest.config.ts user.test.ts
|
||||
|
||||
# 运行特定文件的特定行号
|
||||
npx vitest run --config vitest.config.ts src/utils/helper.test.ts:25
|
||||
npx vitest run --config vitest.config.ts basic/foo.test.ts:10,basic/foo.test.ts:25
|
||||
|
||||
# 过滤特定测试用例名称
|
||||
npx vitest -t "test case name" --config vitest.config.ts
|
||||
|
||||
# 组合使用文件和测试名称过滤
|
||||
npx vitest run --config vitest.config.ts filename.test.ts -t "specific test"
|
||||
```
|
||||
|
||||
#### ❌ 避免的命令格式
|
||||
|
||||
```bash
|
||||
# ❌ 不要使用 pnpm test xxx (这不是有效的 vitest 命令)
|
||||
pnpm test some-file
|
||||
|
||||
# ❌ 不要使用裸 vitest (会进入 watch 模式)
|
||||
vitest test-file.test.ts
|
||||
|
||||
# ❌ 不要混淆测试环境
|
||||
npx vitest run --config vitest.config.server.ts client-component.test.ts
|
||||
```
|
||||
|
||||
### 关键运行参数说明
|
||||
|
||||
- **`vitest run`**: 运行一次测试然后退出 (避免 watch 模式)
|
||||
- **`vitest`**: 默认进入 watch 模式,持续监听文件变化
|
||||
- **`--config`**: 指定配置文件,选择正确的测试环境
|
||||
- **`-t`**: 过滤测试用例名称,支持正则表达式
|
||||
- **`--coverage`**: 生成测试覆盖率报告
|
||||
|
||||
## 🔧 测试修复原则
|
||||
|
||||
### 核心原则 ⚠️
|
||||
|
||||
1. **充分阅读测试代码**: 在修复测试之前,必须完整理解测试的意图和实现
|
||||
2. **测试优先修复**: 如果是测试本身写错了,修改测试而不是实现代码
|
||||
3. **专注单一问题**: 只修复指定的测试,不要添加额外测试或功能
|
||||
4. **不自作主张**: 不要因为发现其他问题就直接修改,先提出再讨论
|
||||
|
||||
### 测试修复流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "阶段一:分析与复现"
|
||||
A[开始:收到测试失败报告] --> B[定位并运行失败的测试];
|
||||
B --> C{是否能在本地复现?};
|
||||
C -->|否| D[检查测试环境/配置/依赖];
|
||||
C -->|是| E[分析:阅读测试代码、错误日志、Git 历史];
|
||||
end
|
||||
|
||||
subgraph "阶段二:诊断与调试"
|
||||
E --> F[建立假设:问题出在测试、代码还是环境?];
|
||||
F --> G["调试:使用 console.log 或 debugger 深入检查"];
|
||||
G --> H{假设是否被证实?};
|
||||
H -->|否, 重新假设| F;
|
||||
end
|
||||
|
||||
subgraph "阶段三:修复与验证"
|
||||
H -->|是| I{确定根本原因};
|
||||
I -->|测试逻辑错误| J[修复测试代码];
|
||||
I -->|实现代码 Bug| K[修复实现代码];
|
||||
I -->|环境/配置问题| L[修复配置或依赖];
|
||||
J --> M[验证修复:重新运行失败的测试];
|
||||
K --> M;
|
||||
L --> M;
|
||||
M --> N{测试是否通过?};
|
||||
N -->|否, 修复无效| F;
|
||||
N -->|是| O[扩大验证:运行当前文件内所有测试];
|
||||
O --> P{是否全部通过?};
|
||||
P -->|否, 引入新问题| F;
|
||||
end
|
||||
|
||||
subgraph "阶段四:总结"
|
||||
P -->|是| Q[完成:撰写修复总结];
|
||||
end
|
||||
|
||||
D --> F;
|
||||
```
|
||||
|
||||
### 修复完成后的总结
|
||||
|
||||
测试修复完成后,应该提供简要说明,包括:
|
||||
|
||||
1. **错误原因分析**: 说明测试失败的根本原因
|
||||
- 测试逻辑错误
|
||||
- 实现代码bug
|
||||
- 环境配置问题
|
||||
- 依赖变更导致的问题
|
||||
|
||||
2. **修复方法说明**: 简述采用的修复方式
|
||||
- 修改了哪些文件
|
||||
- 采用了什么解决方案
|
||||
- 为什么选择这种修复方式
|
||||
|
||||
**示例格式**:
|
||||
|
||||
```markdown
|
||||
## 测试修复总结
|
||||
|
||||
**错误原因**: 测试中的 mock 数据格式与实际 API 返回格式不匹配,导致断言失败。
|
||||
|
||||
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
|
||||
```
|
||||
|
||||
## 📂 测试文件组织
|
||||
|
||||
### 文件命名约定
|
||||
|
||||
- **客户端测试**: `*.test.ts`, `*.test.tsx` (任意位置)
|
||||
- **服务端测试**: `src/database/models/**/*.test.ts`, `src/database/server/**/*.test.ts` (限定路径)
|
||||
|
||||
### 测试文件组织风格
|
||||
|
||||
项目采用 **测试文件与源文件同目录** 的组织风格:
|
||||
|
||||
- 测试文件放在对应源文件的同一目录下
|
||||
- 命名格式:`原文件名.test.ts` 或 `原文件名.test.tsx`
|
||||
|
||||
例如:
|
||||
|
||||
```
|
||||
src/components/Button/
|
||||
├── index.tsx # 源文件
|
||||
└── index.test.tsx # 测试文件
|
||||
```
|
||||
|
||||
## 🛠️ 测试调试技巧
|
||||
|
||||
### 运行失败测试的步骤
|
||||
|
||||
1. **确定测试类型**: 查看文件路径确定使用哪个配置
|
||||
2. **运行单个测试**: 使用 `-t` 参数隔离问题
|
||||
3. **检查错误日志**: 仔细阅读错误信息和堆栈跟踪
|
||||
4. **查看最近修改记录**: 检查相关文件的最近变更情况
|
||||
5. **添加调试日志**: 在测试中添加 `console.log` 了解执行流程
|
||||
|
||||
### 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. **测试文件本身**: `path/to/component.test.ts`
|
||||
2. **对应的实现文件**: `path/to/component.ts` 或 `path/to/component/index.ts`
|
||||
3. **相关依赖文件**: 测试或实现中导入的其他模块
|
||||
|
||||
#### 第二步:检查当前工作目录状态
|
||||
|
||||
```bash
|
||||
# 查看所有未提交的修改状态
|
||||
git status
|
||||
|
||||
# 重点关注测试文件和实现文件是否有未提交的修改
|
||||
git status | grep -E "(test|spec)"
|
||||
```
|
||||
|
||||
#### 第三步:检查未提交的修改内容
|
||||
|
||||
```bash
|
||||
# 查看测试文件的未提交修改 (工作区 vs 暂存区)
|
||||
git diff path/to/component.test.ts | cat
|
||||
|
||||
# 查看对应实现文件的未提交修改
|
||||
git diff path/to/component.ts | cat
|
||||
|
||||
# 查看已暂存但未提交的修改
|
||||
git diff --cached path/to/component.test.ts | cat
|
||||
git diff --cached path/to/component.ts | cat
|
||||
```
|
||||
|
||||
#### 第四步:检查提交历史和时间相关性
|
||||
|
||||
**首先查看提交时间,判断修改的时效性**:
|
||||
|
||||
```bash
|
||||
# 查看测试文件的最近提交历史,包含提交时间
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -5 path/to/component.test.ts | cat
|
||||
|
||||
# 查看实现文件的最近提交历史,包含提交时间
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -5 path/to/component.ts | cat
|
||||
|
||||
# 查看详细的提交时间(ISO格式,便于精确判断)
|
||||
git log --pretty=format:"%h %ad %an %s" --date=iso -3 path/to/component.ts | cat
|
||||
git log --pretty=format:"%h %ad %an %s" --date=iso -3 path/to/component.test.ts | cat
|
||||
```
|
||||
|
||||
**判断提交的参考价值**:
|
||||
|
||||
1. **最近提交(24小时内)**: 🔴 **高度相关** - 很可能是导致测试失败的直接原因
|
||||
2. **近期提交(1-7天内)**: 🟡 **中等相关** - 可能相关,需要仔细分析修改内容
|
||||
3. **较早提交(超过1周)**: ⚪ **低相关性** - 除非是重大重构,否则不太可能是直接原因
|
||||
|
||||
#### 第五步:基于时间相关性查看具体修改内容
|
||||
|
||||
**根据提交时间的远近,优先查看最近的修改**:
|
||||
|
||||
```bash
|
||||
# 如果有24小时内的提交,重点查看这些修改
|
||||
git show HEAD -- path/to/component.test.ts | cat
|
||||
git show HEAD -- path/to/component.ts | cat
|
||||
|
||||
# 查看次新的提交(如果最新提交时间较远)
|
||||
git show HEAD~1 -- path/to/component.ts | cat
|
||||
git show <recent-commit-hash> -- path/to/component.ts | cat
|
||||
|
||||
# 对比最近两次提交的差异
|
||||
git diff HEAD~1 HEAD -- path/to/component.ts | cat
|
||||
```
|
||||
|
||||
#### 第六步:分析修改与测试失败的关系
|
||||
|
||||
基于修改记录和时间相关性判断:
|
||||
|
||||
1. **最近修改了实现代码**:
|
||||
|
||||
```bash
|
||||
# 重点检查实现逻辑的变化
|
||||
git diff HEAD~1 path/to/component.ts | cat
|
||||
```
|
||||
|
||||
- 很可能是实现代码的变更导致测试失败
|
||||
- 检查实现逻辑是否正确
|
||||
- 确认测试是否需要相应更新
|
||||
|
||||
2. **最近修改了测试代码**:
|
||||
|
||||
```bash
|
||||
# 重点检查测试逻辑的变化
|
||||
git diff HEAD~1 path/to/component.test.ts | cat
|
||||
```
|
||||
|
||||
- 可能是测试本身写错了
|
||||
- 检查测试逻辑和断言是否正确
|
||||
- 确认测试是否符合实现的预期行为
|
||||
|
||||
3. **两者都有最近修改**:
|
||||
|
||||
```bash
|
||||
# 对比两个文件的修改时间
|
||||
git log --pretty=format:"%ad %f" --date=iso -1 path/to/component.ts | cat
|
||||
git log --pretty=format:"%ad %f" --date=iso -1 path/to/component.test.ts | cat
|
||||
```
|
||||
|
||||
- 需要综合分析两者的修改
|
||||
- 确定哪个修改更可能导致问题
|
||||
- 优先检查时间更近的修改
|
||||
|
||||
4. **都没有最近修改**:
|
||||
- 可能是依赖变更或环境问题
|
||||
- 检查 `package.json`、配置文件等的修改
|
||||
- 查看是否有全局性的代码重构
|
||||
|
||||
#### 修改记录检查示例
|
||||
|
||||
```bash
|
||||
# 完整的检查流程示例
|
||||
echo "=== 检查文件修改状态 ==="
|
||||
git status | grep component
|
||||
|
||||
echo "=== 检查未提交修改 ==="
|
||||
git diff src/components/Button/index.test.tsx | cat
|
||||
git diff src/components/Button/index.tsx | cat
|
||||
|
||||
echo "=== 检查提交历史和时间 ==="
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -3 src/components/Button/index.test.tsx | cat
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -3 src/components/Button/index.tsx | cat
|
||||
|
||||
echo "=== 根据时间优先级查看修改内容 ==="
|
||||
# 如果有24小时内的提交,重点查看
|
||||
git show HEAD -- src/components/Button/index.tsx | cat
|
||||
```
|
||||
|
||||
## 🗃️ 数据库 Model 测试指南
|
||||
|
||||
### 测试环境选择 💡
|
||||
|
||||
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
|
||||
|
||||
### ⚠️ 双环境验证要求
|
||||
|
||||
**对于所有 Model 测试,必须在两个环境下都验证通过**:
|
||||
|
||||
#### 完整验证流程
|
||||
|
||||
```bash
|
||||
# 1. 先在客户端环境测试(快速验证)
|
||||
npx vitest run --config vitest.config.ts src/database/models/__tests__/myModel.test.ts
|
||||
|
||||
# 2. 再在服务端环境测试(兼容性验证)
|
||||
npx vitest run --config vitest.config.server.ts 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 { NewGenerationBatch, NewGeneration } 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 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
修复测试时,记住以下关键点:
|
||||
|
||||
- **使用正确的命令**: `npx vitest run --config [config-file]`
|
||||
- **理解测试意图**: 先读懂测试再修复
|
||||
- **查看最近修改**: 检查相关文件的 git 修改记录,判断问题根源
|
||||
- **选择正确环境**: 客户端测试用 `vitest.config.ts`,服务端用 `vitest.config.server.ts`
|
||||
- **专注单一问题**: 只修复当前的测试失败
|
||||
- **验证修复结果**: 确保修复后测试通过且无副作用
|
||||
- **提供修复总结**: 说明错误原因和修复方法
|
||||
- **Model 测试安全第一**: 必须包含用户权限检查和对应的安全测试
|
||||
- **Model 双环境验证**: 必须在 PGLite 和 PostgreSQL 两个环境下都验证通过
|
||||
@@ -0,0 +1,453 @@
|
||||
---
|
||||
globs: src/database/**/*.test.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## 🗃️ 数据库 Model 测试指南
|
||||
|
||||
### 测试环境选择 💡
|
||||
|
||||
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
|
||||
|
||||
### ⚠️ 双环境验证要求
|
||||
|
||||
**对于所有 Model 测试,必须在两个环境下都验证通过**:
|
||||
|
||||
#### 完整验证流程
|
||||
|
||||
```bash
|
||||
# 1. 先在客户端环境测试(快速验证)
|
||||
npx vitest run --config vitest.config.ts src/database/models/__tests__/myModel.test.ts
|
||||
|
||||
# 2. 再在服务端环境测试(兼容性验证)
|
||||
npx vitest run --config vitest.config.server.ts 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 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
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 方法是否被正确调用
|
||||
@@ -0,0 +1,496 @@
|
||||
---
|
||||
globs: *.test.ts,*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 测试指南 - LobeChat Testing Guide
|
||||
|
||||
## 🧪 测试环境概览
|
||||
|
||||
LobeChat 项目使用 Vitest 测试库,配置了两种不同的测试环境:
|
||||
|
||||
### 客户端测试环境 (DOM Environment)
|
||||
|
||||
- **配置文件**: [vitest.config.ts](mdc:vitest.config.ts)
|
||||
- **环境**: Happy DOM (浏览器环境模拟)
|
||||
- **数据库**: PGLite (浏览器环境的 PostgreSQL)
|
||||
- **用途**: 测试前端组件、客户端逻辑、React 组件等
|
||||
- **设置文件**: [tests/setup.ts](mdc:tests/setup.ts)
|
||||
|
||||
### 服务端测试环境 (Node Environment)
|
||||
|
||||
- **配置文件**: [vitest.config.server.ts](mdc:vitest.config.server.ts)
|
||||
- **环境**: Node.js
|
||||
- **数据库**: 真实的 PostgreSQL 数据库
|
||||
- **并发限制**: 单线程运行 (`singleFork: true`)
|
||||
- **用途**: 测试数据库模型、服务端逻辑、API 端点等
|
||||
- **设置文件**: [tests/setup-db.ts](mdc:tests/setup-db.ts)
|
||||
|
||||
## 🚀 测试运行命令
|
||||
|
||||
**🚨 性能警告**: 项目包含 3000+ 测试用例,完整运行需要约 10 分钟。务必使用文件过滤或测试名称过滤。
|
||||
|
||||
### ✅ 正确的命令格式
|
||||
|
||||
```bash
|
||||
# 运行所有客户端/服务端测试
|
||||
npx vitest run --config vitest.config.ts # 客户端测试
|
||||
npx vitest run --config vitest.config.server.ts # 服务端测试
|
||||
|
||||
# 运行特定测试文件 (支持模糊匹配)
|
||||
npx vitest run --config vitest.config.ts user.test.ts
|
||||
|
||||
# 运行特定测试用例名称 (使用 -t 参数)
|
||||
npx vitest run --config vitest.config.ts -t "test case name"
|
||||
|
||||
# 组合使用文件和测试名称过滤
|
||||
npx vitest run --config vitest.config.ts filename.test.ts -t "specific test"
|
||||
|
||||
# 生成覆盖率报告 (使用 --coverage 参数)
|
||||
npx vitest run --config vitest.config.ts --coverage
|
||||
```
|
||||
|
||||
### ❌ 避免的命令格式
|
||||
|
||||
```bash
|
||||
# ❌ 这些命令会运行所有 3000+ 测试用例,耗时约 10 分钟!
|
||||
npm test
|
||||
npm test some-file.test.ts
|
||||
|
||||
# ❌ 不要使用裸 vitest (会进入 watch 模式)
|
||||
vitest test-file.test.ts
|
||||
```
|
||||
|
||||
## 🔧 测试修复原则
|
||||
|
||||
### 核心原则 ⚠️
|
||||
|
||||
1. **充分阅读测试代码**: 在修复测试之前,必须完整理解测试的意图和实现
|
||||
2. **测试优先修复**: 如果是测试本身写错了,修改测试而不是实现代码
|
||||
3. **专注单一问题**: 只修复指定的测试,不要添加额外测试或功能
|
||||
4. **不自作主张**: 不要因为发现其他问题就直接修改,先提出再讨论
|
||||
|
||||
### 测试协作最佳实践 🤝
|
||||
|
||||
基于实际开发经验总结的重要协作原则:
|
||||
|
||||
#### 1. 失败处理策略
|
||||
|
||||
**核心原则**: 避免盲目重试,快速识别问题并寻求帮助。
|
||||
|
||||
- **失败阈值**: 当连续尝试修复测试 1-2 次都失败后,应立即停止继续尝试
|
||||
- **问题总结**: 分析失败原因,整理已尝试的解决方案及其失败原因
|
||||
- **寻求帮助**: 带着清晰的问题摘要和尝试记录向团队寻求帮助
|
||||
- **避免陷阱**: 不要陷入"不断尝试相同或类似方法"的循环
|
||||
|
||||
```typescript
|
||||
// ❌ 错误做法:连续失败后继续盲目尝试
|
||||
// 第3次、第4次仍在用相似的方法修复同一个问题
|
||||
|
||||
// ✅ 正确做法:失败1-2次后总结问题
|
||||
/*
|
||||
问题总结:
|
||||
1. 尝试过的方法:修改 mock 数据结构
|
||||
2. 失败原因:仍然提示类型不匹配
|
||||
3. 具体错误:Expected 'UserData' but received 'UserProfile'
|
||||
4. 需要帮助:不确定最新的 UserData 接口定义
|
||||
*/
|
||||
```
|
||||
|
||||
#### 2. 测试用例命名规范
|
||||
|
||||
**核心原则**: 测试应该关注"行为",而不是"实现细节"。
|
||||
|
||||
- **描述业务场景**: `describe` 和 `it` 的标题应该描述具体的业务场景和预期行为
|
||||
- **避免实现绑定**: 不要在测试名称中提及具体的代码行号、覆盖率目标或实现细节
|
||||
- **保持稳定性**: 测试名称应该在代码重构后仍然有意义
|
||||
|
||||
```typescript
|
||||
// ❌ 错误的测试命名
|
||||
describe('User component coverage', () => {
|
||||
it('covers line 45-50 in getUserData', () => {
|
||||
// 为了覆盖第45-50行而写的测试
|
||||
});
|
||||
|
||||
it('tests the else branch', () => {
|
||||
// 仅为了测试某个分支而存在
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 正确的测试命名
|
||||
describe('<UserAvatar />', () => {
|
||||
it('should render fallback icon when image url is not provided', () => {
|
||||
// 测试具体的业务场景,自然会覆盖相关代码分支
|
||||
});
|
||||
|
||||
it('should display user initials when avatar image fails to load', () => {
|
||||
// 描述用户行为和预期结果
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**覆盖率提升的正确思路**:
|
||||
|
||||
- ✅ 通过设计各种业务场景(正常流程、边缘情况、错误处理)来自然提升覆盖率
|
||||
- ❌ 不要为了达到覆盖率数字而写测试,更不要在测试中注释"为了覆盖 xxx 行"
|
||||
|
||||
#### 3. 测试组织结构
|
||||
|
||||
**核心原则**: 维护清晰的测试层次结构,避免冗余的顶级测试块。
|
||||
|
||||
- **复用现有结构**: 添加新测试时,优先在现有的 `describe` 块中寻找合适的位置
|
||||
- **逻辑分组**: 相关的测试用例应该组织在同一个 `describe` 块内
|
||||
- **避免碎片化**: 不要为了单个测试用例就创建新的顶级 `describe` 块
|
||||
|
||||
```typescript
|
||||
// ❌ 错误的组织方式:创建过多顶级块
|
||||
describe('<UserProfile />', () => {
|
||||
it('should render user name', () => {});
|
||||
});
|
||||
|
||||
describe('UserProfile new prop test', () => {
|
||||
// 不必要的新块
|
||||
it('should handle email display', () => {});
|
||||
});
|
||||
|
||||
describe('UserProfile edge cases', () => {
|
||||
// 不必要的新块
|
||||
it('should handle missing avatar', () => {});
|
||||
});
|
||||
|
||||
// ✅ 正确的组织方式:合并相关测试
|
||||
describe('<UserProfile />', () => {
|
||||
it('should render user name', () => {});
|
||||
|
||||
it('should handle email display', () => {});
|
||||
|
||||
it('should handle missing avatar', () => {});
|
||||
|
||||
describe('when user data is incomplete', () => {
|
||||
// 只有在有多个相关子场景时才创建子组
|
||||
it('should show placeholder for missing name', () => {});
|
||||
it('should hide email section when email is undefined', () => {});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**组织决策流程**:
|
||||
|
||||
1. 是否存在逻辑相关的现有 `describe` 块? → 如果有,添加到其中
|
||||
2. 是否有多个(3个以上)相关的测试用例? → 如果有,可以考虑创建新的子 `describe`
|
||||
3. 是否是独立的、无关联的功能模块? → 如果是,才考虑创建新的顶级 `describe`
|
||||
|
||||
### 测试修复流程
|
||||
|
||||
1. **复现问题**: 定位并运行失败的测试,确认能在本地复现
|
||||
2. **分析原因**: 阅读测试代码、错误日志和相关文件的 Git 修改历史
|
||||
3. **建立假设**: 判断问题出在测试逻辑、实现代码还是环境配置
|
||||
4. **修复验证**: 根据假设进行修复,重新运行测试确认通过
|
||||
5. **扩大验证**: 运行当前文件内所有测试,确保没有引入新问题
|
||||
6. **撰写总结**: 说明错误原因和修复方法
|
||||
|
||||
### 修复完成后的总结
|
||||
|
||||
测试修复完成后,应该提供简要说明,包括:
|
||||
|
||||
1. **错误原因分析**: 说明测试失败的根本原因
|
||||
- 测试逻辑错误
|
||||
- 实现代码bug
|
||||
- 环境配置问题
|
||||
- 依赖变更导致的问题
|
||||
|
||||
2. **修复方法说明**: 简述采用的修复方式
|
||||
- 修改了哪些文件
|
||||
- 采用了什么解决方案
|
||||
- 为什么选择这种修复方式
|
||||
|
||||
**示例格式**:
|
||||
|
||||
```markdown
|
||||
## 测试修复总结
|
||||
|
||||
**错误原因**: 测试中的 mock 数据格式与实际 API 返回格式不匹配,导致断言失败。
|
||||
|
||||
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
|
||||
```
|
||||
|
||||
## 🎯 测试编写最佳实践
|
||||
|
||||
### Mock 数据策略:追求"低成本的真实性" 📋
|
||||
|
||||
**核心原则**: 测试数据应默认追求真实性,只有在引入"高昂的测试成本"时才进行简化。
|
||||
|
||||
#### 什么是"高昂的测试成本"?
|
||||
|
||||
"高成本"指的是测试中引入了外部依赖,使测试变慢、不稳定或复杂:
|
||||
|
||||
- **文件 I/O 操作**:读写硬盘文件
|
||||
- **网络请求**:HTTP 调用、数据库连接
|
||||
- **系统调用**:获取系统时间、环境变量等
|
||||
|
||||
#### ✅ 推荐做法:Mock 依赖,保留真实数据
|
||||
|
||||
```typescript
|
||||
// ✅ 好的做法:Mock I/O 操作,但使用真实的文件内容格式
|
||||
describe('parseContentType', () => {
|
||||
beforeEach(() => {
|
||||
// Mock 文件读取操作(避免真实 I/O)
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((path) => {
|
||||
// 但返回真实的文件内容格式
|
||||
if (path.includes('.pdf')) return '%PDF-1.4\n%âãÏÓ'; // 真实 PDF 文件头
|
||||
if (path.includes('.png')) return '\x89PNG\r\n\x1a\n'; // 真实 PNG 文件头
|
||||
return '';
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect PDF content type correctly', () => {
|
||||
const result = parseContentType('/path/to/file.pdf');
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ 过度简化:使用不真实的数据
|
||||
describe('parseContentType', () => {
|
||||
it('should detect PDF content type correctly', () => {
|
||||
// 这种简化数据没有测试价值
|
||||
const result = parseContentType('fake-pdf-content');
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 🎯 真实标识符的价值
|
||||
|
||||
```typescript
|
||||
// ✅ 使用真实的提供商标识符
|
||||
it('should parse OpenAI model list correctly', () => {
|
||||
const result = parseModelString('openai', '+gpt-4,+gpt-3.5-turbo');
|
||||
expect(result.add).toHaveLength(2);
|
||||
expect(result.add[0].id).toBe('gpt-4');
|
||||
});
|
||||
|
||||
// ❌ 使用占位符标识符(价值较低)
|
||||
it('should parse model list correctly', () => {
|
||||
const result = parseModelString('test-provider', '+model1,+model2');
|
||||
expect(result.add).toHaveLength(2);
|
||||
// 这种测试对理解真实场景帮助不大
|
||||
});
|
||||
```
|
||||
|
||||
### 错误处理测试:测试"行为"而非"文本" ⚠️
|
||||
|
||||
**核心原则**: 测试应该验证程序在错误发生时的行为是可预测的,而不是验证易变的错误信息文本。
|
||||
|
||||
#### ✅ 推荐的错误测试方式
|
||||
|
||||
```typescript
|
||||
// ✅ 测试是否抛出错误
|
||||
it('should throw error when invalid input provided', () => {
|
||||
expect(() => processInput(null)).toThrow();
|
||||
});
|
||||
|
||||
// ✅ 测试错误类型(最推荐)
|
||||
it('should throw ValidationError for invalid data', () => {
|
||||
expect(() => validateUser({})).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
// ✅ 测试错误属性而非消息文本
|
||||
it('should throw error with correct error code', () => {
|
||||
expect(() => processPayment({})).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'INVALID_PAYMENT_DATA',
|
||||
statusCode: 400,
|
||||
}),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
#### ❌ 应避免的做法
|
||||
|
||||
```typescript
|
||||
// ❌ 过度依赖具体错误信息文本
|
||||
it('should throw specific error message', () => {
|
||||
expect(() => processUser({})).toThrow('用户数据不能为空,请检查输入参数');
|
||||
// 这种测试很脆弱,错误文案稍有修改就会失败
|
||||
});
|
||||
```
|
||||
|
||||
#### 🎯 例外情况:何时可以测试错误信息
|
||||
|
||||
```typescript
|
||||
// ✅ 测试标准 API 错误(这是契约的一部分)
|
||||
it('should return proper HTTP error for API', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.error).toBe('Bad Request');
|
||||
});
|
||||
|
||||
// ✅ 测试错误信息的关键部分(使用正则)
|
||||
it('should include field name in validation error', () => {
|
||||
expect(() => validateField('email', '')).toThrow(/email/i);
|
||||
});
|
||||
```
|
||||
|
||||
### 疑难解答:警惕模块污染 🚨
|
||||
|
||||
**识别信号**: 当你的测试出现以下"灵异"现象时,优先怀疑模块污染:
|
||||
|
||||
- 单独运行某个测试通过,但和其他测试一起运行就失败
|
||||
- 测试的执行顺序影响结果
|
||||
- Mock 设置看起来正确,但实际使用的是旧的 Mock 版本
|
||||
|
||||
#### 典型场景:动态 Mock 同一模块
|
||||
|
||||
```typescript
|
||||
// ❌ 容易出现模块污染的写法
|
||||
describe('ConfigService', () => {
|
||||
it('should work in development mode', async () => {
|
||||
vi.doMock('./config', () => ({ isDev: true }));
|
||||
const { getSettings } = await import('./configService'); // 第一次加载
|
||||
expect(getSettings().debugMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should work in production mode', async () => {
|
||||
vi.doMock('./config', () => ({ isDev: false }));
|
||||
const { getSettings } = await import('./configService'); // 可能使用缓存的旧版本!
|
||||
expect(getSettings().debugMode).toBe(false); // ❌ 可能失败
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 使用 resetModules 解决模块污染
|
||||
describe('ConfigService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules(); // 清除模块缓存,确保每个测试都是干净的环境
|
||||
});
|
||||
|
||||
it('should work in development mode', async () => {
|
||||
vi.doMock('./config', () => ({ isDev: true }));
|
||||
const { getSettings } = await import('./configService');
|
||||
expect(getSettings().debugMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should work in production mode', async () => {
|
||||
vi.doMock('./config', () => ({ isDev: false }));
|
||||
const { getSettings } = await import('./configService');
|
||||
expect(getSettings().debugMode).toBe(false); // ✅ 测试通过
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 🔧 排查和解决步骤
|
||||
|
||||
1. **识别问题**: 测试失败时,首先问自己:"是否有多个测试在 Mock 同一个模块?"
|
||||
2. **添加隔离**: 在 `beforeEach` 中添加 `vi.resetModules()`
|
||||
3. **验证修复**: 重新运行测试,确认问题解决
|
||||
|
||||
**记住**: `vi.resetModules()` 是解决测试"灵异"失败的终极武器,当常规调试方法都无效时,它往往能一针见血地解决问题。
|
||||
|
||||
## 📂 测试文件组织
|
||||
|
||||
### 文件命名约定
|
||||
|
||||
- **客户端测试**: `*.test.ts`, `*.test.tsx` (任意位置)
|
||||
- **服务端测试**: `src/database/models/**/*.test.ts`, `src/database/server/**/*.test.ts` (限定路径)
|
||||
|
||||
### 测试文件组织风格
|
||||
|
||||
项目采用 **测试文件与源文件同目录** 的组织风格:
|
||||
|
||||
- 测试文件放在对应源文件的同一目录下
|
||||
- 命名格式:`原文件名.test.ts` 或 `原文件名.test.tsx`
|
||||
|
||||
例如:
|
||||
|
||||
```plaintext
|
||||
src/components/Button/
|
||||
├── index.tsx # 源文件
|
||||
└── index.test.tsx # 测试文件
|
||||
```
|
||||
|
||||
## 🛠️ 测试调试技巧
|
||||
|
||||
### 测试调试步骤
|
||||
|
||||
1. **确定测试环境**: 根据文件路径选择正确的配置文件
|
||||
2. **隔离问题**: 使用 `-t` 参数只运行失败的测试用例
|
||||
3. **分析错误**: 仔细阅读错误信息、堆栈跟踪和最近的文件修改记录
|
||||
4. **添加调试**: 在测试中添加 `console.log` 了解执行流程
|
||||
|
||||
### TypeScript 类型处理 📝
|
||||
|
||||
在测试中,为了提高编写效率和可读性,可以适当放宽 TypeScript 类型检测:
|
||||
|
||||
#### ✅ 推荐的类型放宽策略
|
||||
|
||||
```typescript
|
||||
// ✅ 使用非空断言访问测试中确定存在的属性
|
||||
const result = await someFunction();
|
||||
expect(result!.data).toBeDefined();
|
||||
expect(result!.status).toBe('success');
|
||||
|
||||
// ✅ 使用 any 类型简化复杂的 Mock 设置
|
||||
const mockStream = new ReadableStream() as any;
|
||||
mockStream.toReadableStream = () => mockStream;
|
||||
```
|
||||
|
||||
#### 🎯 适用场景
|
||||
|
||||
- **Mock 对象**: 对于测试用的 Mock 数据,使用 `as any` 避免复杂的类型定义
|
||||
- **第三方库**: 处理复杂的第三方库类型时,适当使用 `any` 提高效率
|
||||
- **测试断言**: 在确定对象存在的测试场景中,使用 `!` 非空断言
|
||||
- **临时调试**: 快速编写测试时,先用 `any` 保证功能,后续可选择性地优化类型
|
||||
|
||||
#### ⚠️ 注意事项
|
||||
|
||||
- **适度使用**: 不要过度依赖 `any`,核心业务逻辑的类型仍应保持严格
|
||||
- **文档说明**: 对于使用 `any` 的复杂场景,添加注释说明原因
|
||||
- **测试覆盖**: 确保即使使用了 `any`,测试仍能有效验证功能正确性
|
||||
|
||||
### 检查最近修改记录 🔍
|
||||
|
||||
系统性地检查相关文件的修改历史是问题定位的关键步骤。
|
||||
|
||||
#### 三步检查法
|
||||
|
||||
**Step 1: 查看当前状态**
|
||||
|
||||
```bash
|
||||
git status # 查看未提交的修改
|
||||
git diff path/to/component.test.ts | cat # 查看测试文件修改
|
||||
git diff path/to/component.ts | cat # 查看实现文件修改
|
||||
```
|
||||
|
||||
**Step 2: 查看提交历史**
|
||||
|
||||
```bash
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -3 path/to/component.ts | cat
|
||||
```
|
||||
|
||||
**Step 3: 查看具体修改内容**
|
||||
|
||||
```bash
|
||||
git show HEAD -- path/to/component.ts | cat # 查看最新提交的修改
|
||||
```
|
||||
|
||||
#### 时间相关性判断
|
||||
|
||||
- **24小时内的提交**: 🔴 **高度相关** - 很可能是直接原因
|
||||
- **1-7天内的提交**: 🟡 **中等相关** - 需要仔细分析
|
||||
- **超过1周的提交**: ⚪ **低相关性** - 除非重大重构
|
||||
|
||||
## 特殊场景的测试
|
||||
|
||||
针对一些特殊场景的测试,需要阅读相关 rules:
|
||||
|
||||
- [Electron IPC 接口测试策略](mdc:./electron-ipc-test.mdc)
|
||||
- [数据库 Model 测试指南](mdc:./db-model-test.mdc)
|
||||
|
||||
## 🎯 核心要点
|
||||
|
||||
- **命令格式**: 使用 `npx vitest run --config [config-file]` 并指定文件过滤
|
||||
- **修复原则**: 失败1-2次后寻求帮助,测试命名关注行为而非实现细节
|
||||
- **调试流程**: 复现 → 分析 → 假设 → 修复 → 验证 → 总结
|
||||
- **文件组织**: 优先在现有 `describe` 块中添加测试,避免创建冗余顶级块
|
||||
- **数据策略**: 默认追求真实性,只有高成本(I/O、网络等)时才简化
|
||||
- **错误测试**: 测试错误类型和行为,避免依赖具体的错误信息文本
|
||||
- **模块污染**: 测试"灵异"失败时,优先怀疑模块污染,使用 `vi.resetModules()` 解决
|
||||
- **安全要求**: Model 测试必须包含权限检查,并在双环境下验证通过
|
||||
@@ -16,4 +16,6 @@ TypeScript Code Style Guide:
|
||||
- Always refactor repeated logic into a reusable function
|
||||
- Don't remove meaningful code comments, be sure to keep original comments when providing applied code
|
||||
- Update the code comments when needed after you modify the related code
|
||||
- Please respect my prettier preferences when you provide code
|
||||
- Please respect my prettier preferences when you provide code
|
||||
- Prefer object destructuring when accessing and using properties
|
||||
- Prefer async version api than sync version, eg: use readFile from 'fs/promises' instead of 'fs'
|
||||
|
||||
@@ -155,9 +155,9 @@ jobs:
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
# 将 TEMP 和 TMP 目录设置到 D 盘
|
||||
TEMP: D:\temp
|
||||
TMP: D:\temp
|
||||
# 将 TEMP 和 TMP 目录设置到 C 盘
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 平台构建处理
|
||||
- name: Build artifact on Linux
|
||||
|
||||
@@ -139,9 +139,9 @@ jobs:
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# 将 TEMP 和 TMP 目录设置到 D 盘
|
||||
TEMP: D:\temp
|
||||
TMP: D:\temp
|
||||
# 将 TEMP 和 TMP 目录设置到 C 盘
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 平台构建处理
|
||||
- name: Build artifact on Linux
|
||||
|
||||
@@ -41,10 +41,6 @@ test-output
|
||||
# husky
|
||||
.husky/prepare-commit-msg
|
||||
|
||||
# misc
|
||||
# add other ignore file below
|
||||
CLAUDE.md
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
@@ -74,5 +70,8 @@ vertex-ai-key.json
|
||||
./packages/lobe-ui
|
||||
|
||||
|
||||
# for local prd docs
|
||||
docs/prd
|
||||
# local use ai coding files
|
||||
docs/.prd
|
||||
.claude
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
@@ -2,6 +2,691 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 1.105.1](https://github.com/lobehub/lobe-chat/compare/v1.105.0...v1.105.1)
|
||||
|
||||
<sup>Released on **2025-07-29**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Support more Text2Image from Qwen.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Support more Text2Image from Qwen, closes [#8574](https://github.com/lobehub/lobe-chat/issues/8574) ([b8c0e2d](https://github.com/lobehub/lobe-chat/commit/b8c0e2d))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 1.105.0](https://github.com/lobehub/lobe-chat/compare/v1.104.5...v1.105.0)
|
||||
|
||||
<sup>Released on **2025-07-28**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Implement API Key management functionality.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Implement API Key management functionality, closes [#8535](https://github.com/lobehub/lobe-chat/issues/8535) ([fdaa725](https://github.com/lobehub/lobe-chat/commit/fdaa725))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.104.5](https://github.com/lobehub/lobe-chat/compare/v1.104.4...v1.104.5)
|
||||
|
||||
<sup>Released on **2025-07-28**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Fix setting window layout when in desktop was disappear.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Fix setting window layout when in desktop was disappear, closes [#8585](https://github.com/lobehub/lobe-chat/issues/8585) ([74ab822](https://github.com/lobehub/lobe-chat/commit/74ab822))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.104.4](https://github.com/lobehub/lobe-chat/compare/v1.104.3...v1.104.4)
|
||||
|
||||
<sup>Released on **2025-07-28**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Fix setting window layout size, update i18n.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Fix setting window layout size, closes [#8483](https://github.com/lobehub/lobe-chat/issues/8483) ([4902341](https://github.com/lobehub/lobe-chat/commit/4902341))
|
||||
- **misc**: Update i18n, closes [#8579](https://github.com/lobehub/lobe-chat/issues/8579) ([2eccbc7](https://github.com/lobehub/lobe-chat/commit/2eccbc7))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.104.3](https://github.com/lobehub/lobe-chat/compare/v1.104.2...v1.104.3)
|
||||
|
||||
<sup>Released on **2025-07-26**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Add Gemini 2.5 Flash-Lite GA model.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Add Gemini 2.5 Flash-Lite GA model, closes [#8539](https://github.com/lobehub/lobe-chat/issues/8539) ([404ac21](https://github.com/lobehub/lobe-chat/commit/404ac21))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.104.2](https://github.com/lobehub/lobe-chat/compare/v1.104.1...v1.104.2)
|
||||
|
||||
<sup>Released on **2025-07-26**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix update hotkey invalid when input mod in desktop.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix update hotkey invalid when input mod in desktop, closes [#8572](https://github.com/lobehub/lobe-chat/issues/8572) ([07f3e6a](https://github.com/lobehub/lobe-chat/commit/07f3e6a))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.104.1](https://github.com/lobehub/lobe-chat/compare/v1.104.0...v1.104.1)
|
||||
|
||||
<sup>Released on **2025-07-25**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider, closes [#8557](https://github.com/lobehub/lobe-chat/issues/8557) ([d1e4a54](https://github.com/lobehub/lobe-chat/commit/d1e4a54))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 1.104.0](https://github.com/lobehub/lobe-chat/compare/v1.103.2...v1.104.0)
|
||||
|
||||
<sup>Released on **2025-07-24**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Support custom hotkey on desktop.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Support custom hotkey on desktop, closes [#8559](https://github.com/lobehub/lobe-chat/issues/8559) ([b50f121](https://github.com/lobehub/lobe-chat/commit/b50f121))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.103.2](https://github.com/lobehub/lobe-chat/compare/v1.103.1...v1.103.2)
|
||||
|
||||
<sup>Released on **2025-07-24**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix chat stream in desktop and update shortcut.
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Add cached token count to usage of GoogleAI and VertexAI, fix desktop titlebar style in window, fix sub topic width in md responsive.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix chat stream in desktop and update shortcut, closes [#8520](https://github.com/lobehub/lobe-chat/issues/8520) ([0192140](https://github.com/lobehub/lobe-chat/commit/0192140))
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Add cached token count to usage of GoogleAI and VertexAI, closes [#8545](https://github.com/lobehub/lobe-chat/issues/8545) ([66dbb24](https://github.com/lobehub/lobe-chat/commit/66dbb24))
|
||||
- **misc**: Fix desktop titlebar style in window, closes [#8439](https://github.com/lobehub/lobe-chat/issues/8439) ([fd7662c](https://github.com/lobehub/lobe-chat/commit/fd7662c))
|
||||
- **misc**: Fix sub topic width in md responsive, closes [#8443](https://github.com/lobehub/lobe-chat/issues/8443) ([9bae13b](https://github.com/lobehub/lobe-chat/commit/9bae13b))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.103.1](https://github.com/lobehub/lobe-chat/compare/v1.103.0...v1.103.1)
|
||||
|
||||
<sup>Released on **2025-07-23**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Update i18n.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Update i18n, closes [#8537](https://github.com/lobehub/lobe-chat/issues/8537) ([b16f19b](https://github.com/lobehub/lobe-chat/commit/b16f19b))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 1.103.0](https://github.com/lobehub/lobe-chat/compare/v1.102.4...v1.103.0)
|
||||
|
||||
<sup>Released on **2025-07-22**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Add Qwen image generation capabilities.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Add Qwen image generation capabilities, closes [#8534](https://github.com/lobehub/lobe-chat/issues/8534) ([7e8e5ef](https://github.com/lobehub/lobe-chat/commit/7e8e5ef))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.102.4](https://github.com/lobehub/lobe-chat/compare/v1.102.3...v1.102.4)
|
||||
|
||||
<sup>Released on **2025-07-22**</sup>
|
||||
|
||||
#### ♻ Code Refactoring
|
||||
|
||||
- **misc**: Add badge and improve document.
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Update tray icon.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Code refactoring
|
||||
|
||||
- **misc**: Add badge and improve document, closes [#8528](https://github.com/lobehub/lobe-chat/issues/8528) ([9fb4b0d](https://github.com/lobehub/lobe-chat/commit/9fb4b0d))
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Update tray icon, closes [#8530](https://github.com/lobehub/lobe-chat/issues/8530) ([2696de4](https://github.com/lobehub/lobe-chat/commit/2696de4))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.102.3](https://github.com/lobehub/lobe-chat/compare/v1.102.2...v1.102.3)
|
||||
|
||||
<sup>Released on **2025-07-22**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Remove debug logging from ModelRuntime and async caller.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Remove debug logging from ModelRuntime and async caller, closes [#8525](https://github.com/lobehub/lobe-chat/issues/8525) ([dd1a635](https://github.com/lobehub/lobe-chat/commit/dd1a635))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.102.2](https://github.com/lobehub/lobe-chat/compare/v1.102.1...v1.102.2)
|
||||
|
||||
<sup>Released on **2025-07-22**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Add notification for desktop.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Add notification for desktop, closes [#8523](https://github.com/lobehub/lobe-chat/issues/8523) ([4917d17](https://github.com/lobehub/lobe-chat/commit/4917d17))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.102.1](https://github.com/lobehub/lobe-chat/compare/v1.102.0...v1.102.1)
|
||||
|
||||
<sup>Released on **2025-07-21**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **groq**: Enable streaming for tool calls and add Kimi K2 model.
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Modal list header sticky style.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **groq**: Enable streaming for tool calls and add Kimi K2 model, closes [#8510](https://github.com/lobehub/lobe-chat/issues/8510) ([60739bc](https://github.com/lobehub/lobe-chat/commit/60739bc))
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Modal list header sticky style, closes [#8514](https://github.com/lobehub/lobe-chat/issues/8514) ([75273d5](https://github.com/lobehub/lobe-chat/commit/75273d5))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 1.102.0](https://github.com/lobehub/lobe-chat/compare/v1.101.2...v1.102.0)
|
||||
|
||||
<sup>Released on **2025-07-21**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Add image generation capabilities using Google AI Imagen API.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Add image generation capabilities using Google AI Imagen API, closes [#8503](https://github.com/lobehub/lobe-chat/issues/8503) ([cef8208](https://github.com/lobehub/lobe-chat/commit/cef8208))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.101.2](https://github.com/lobehub/lobe-chat/compare/v1.101.1...v1.101.2)
|
||||
|
||||
<sup>Released on **2025-07-21**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Fix lobehub provider `/chat` in desktop.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Fix lobehub provider `/chat` in desktop, closes [#8508](https://github.com/lobehub/lobe-chat/issues/8508) ([c801f9c](https://github.com/lobehub/lobe-chat/commit/c801f9c))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.101.1](https://github.com/lobehub/lobe-chat/compare/v1.101.0...v1.101.1)
|
||||
|
||||
<sup>Released on **2025-07-19**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Try fix authorization code exchange & pin next-auto to `beta.29`.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Try fix authorization code exchange & pin next-auto to `beta.29`, closes [#8496](https://github.com/lobehub/lobe-chat/issues/8496) ([27c4881](https://github.com/lobehub/lobe-chat/commit/27c4881))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 1.101.0](https://github.com/lobehub/lobe-chat/compare/v1.100.2...v1.101.0)
|
||||
|
||||
<sup>Released on **2025-07-19**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Add zhipu cogview4.
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Some ai image bugs.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Add zhipu cogview4, closes [#8486](https://github.com/lobehub/lobe-chat/issues/8486) ([0b1557d](https://github.com/lobehub/lobe-chat/commit/0b1557d))
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Some ai image bugs, closes [#8490](https://github.com/lobehub/lobe-chat/issues/8490) ([5d852be](https://github.com/lobehub/lobe-chat/commit/5d852be))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.100.2](https://github.com/lobehub/lobe-chat/compare/v1.100.1...v1.100.2)
|
||||
|
||||
<sup>Released on **2025-07-18**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix webapi proxy with clerk.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix webapi proxy with clerk, closes [#8479](https://github.com/lobehub/lobe-chat/issues/8479) ([7dd65f0](https://github.com/lobehub/lobe-chat/commit/7dd65f0))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.100.1](https://github.com/lobehub/lobe-chat/compare/v1.100.0...v1.100.1)
|
||||
|
||||
<sup>Released on **2025-07-17**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Use server env config image models.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Use server env config image models, closes [#8478](https://github.com/lobehub/lobe-chat/issues/8478) ([768ee2b](https://github.com/lobehub/lobe-chat/commit/768ee2b))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 1.100.0](https://github.com/lobehub/lobe-chat/compare/v1.99.6...v1.100.0)
|
||||
|
||||
<sup>Released on **2025-07-17**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Refactor desktop oauth and use JWTs token to support remote chat.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Refactor desktop oauth and use JWTs token to support remote chat, closes [#8446](https://github.com/lobehub/lobe-chat/issues/8446) ([054ca5f](https://github.com/lobehub/lobe-chat/commit/054ca5f))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.99.6](https://github.com/lobehub/lobe-chat/compare/v1.99.5...v1.99.6)
|
||||
|
||||
<sup>Released on **2025-07-16**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Desktop local db can't upload image.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Desktop local db can't upload image, closes [#8459](https://github.com/lobehub/lobe-chat/issues/8459) ([25bfc80](https://github.com/lobehub/lobe-chat/commit/25bfc80))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.99.5](https://github.com/lobehub/lobe-chat/compare/v1.99.4...v1.99.5)
|
||||
|
||||
<sup>Released on **2025-07-16**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix page error when url is not defined in web search plugin.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix page error when url is not defined in web search plugin, closes [#8441](https://github.com/lobehub/lobe-chat/issues/8441) ([a55b65b](https://github.com/lobehub/lobe-chat/commit/a55b65b))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.99.4](https://github.com/lobehub/lobe-chat/compare/v1.99.3...v1.99.4)
|
||||
|
||||
<sup>Released on **2025-07-16**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix apikey issue on server log.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix apikey issue on server log, closes [#8457](https://github.com/lobehub/lobe-chat/issues/8457) ([43be2d1](https://github.com/lobehub/lobe-chat/commit/43be2d1))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.99.3](https://github.com/lobehub/lobe-chat/compare/v1.99.2...v1.99.3)
|
||||
|
||||
<sup>Released on **2025-07-16**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Chat model list should not show image model.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Chat model list should not show image model, closes [#8448](https://github.com/lobehub/lobe-chat/issues/8448) ([2bb1506](https://github.com/lobehub/lobe-chat/commit/2bb1506))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 1.99.2](https://github.com/lobehub/lobe-chat/compare/v1.99.1...v1.99.2)
|
||||
|
||||
<sup>Released on **2025-07-15**</sup>
|
||||
|
||||
@@ -53,6 +53,8 @@ ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
|
||||
SENTRY_ORG="" \
|
||||
SENTRY_PROJECT=""
|
||||
|
||||
ENV APP_URL="http://app.com"
|
||||
|
||||
# Posthog
|
||||
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
|
||||
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
|
||||
|
||||
@@ -55,6 +55,8 @@ ENV NEXT_PUBLIC_SENTRY_DSN="${NEXT_PUBLIC_SENTRY_DSN}" \
|
||||
SENTRY_ORG="" \
|
||||
SENTRY_PROJECT=""
|
||||
|
||||
ENV APP_URL="http://app.com"
|
||||
|
||||
# Posthog
|
||||
ENV NEXT_PUBLIC_ANALYTICS_POSTHOG="${NEXT_PUBLIC_ANALYTICS_POSTHOG}" \
|
||||
NEXT_PUBLIC_POSTHOG_HOST="${NEXT_PUBLIC_POSTHOG_HOST}" \
|
||||
|
||||
@@ -383,12 +383,12 @@ In addition, these plugins are not limited to news aggregation, but can also ext
|
||||
|
||||
| Recent Submits | Description |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-05-27**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-07-21**</sup> | Analyze stocks and get comprehensive real-time investment data and analytics.<br/>`stock` |
|
||||
| [Speak](https://lobechat.com/discover/plugin/speak)<br/><sup>By **speak** on **2025-07-18**</sup> | Learn how to say anything in another language with Speak, your AI-powered language tutor.<br/>`education` `language` |
|
||||
| [Web](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | Smart web search that reads and analyzes pages to deliver comprehensive answers from Google results.<br/>`web` `search` |
|
||||
| [Bing_websearch](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | Search for information from the internet base BingApi<br/>`bingsearch` |
|
||||
| [Google CSE](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | Searches Google through their official CSE API.<br/>`web` `search` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
> 📊 Total plugins: [<kbd>**43**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
|
||||
@@ -376,12 +376,12 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
|
||||
|
||||
| 最近新增 | 描述 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-05-27**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [PortfolioMeta](https://lobechat.com/discover/plugin/StockData)<br/><sup>By **portfoliometa** on **2025-07-21**</sup> | 分析股票并获取全面的实时投资数据和分析。<br/>`股票` |
|
||||
| [Speak](https://lobechat.com/discover/plugin/speak)<br/><sup>By **speak** on **2025-07-18**</sup> | 使用 Speak,您的 AI 语言导师,学习如何用另一种语言说任何事情。<br/>`教育` `语言` |
|
||||
| [网页](https://lobechat.com/discover/plugin/web)<br/><sup>By **Proghit** on **2025-01-24**</sup> | 智能网页搜索,读取和分析页面,以提供来自 Google 结果的全面答案。<br/>`网页` `搜索` |
|
||||
| [必应网页搜索](https://lobechat.com/discover/plugin/Bingsearch-identifier)<br/><sup>By **FineHow** on **2024-12-22**</sup> | 通过 BingApi 搜索互联网上的信息<br/>`bingsearch` |
|
||||
| [谷歌自定义搜索引擎](https://lobechat.com/discover/plugin/google-cse)<br/><sup>By **vsnthdev** on **2024-12-02**</sup> | 通过他们的官方自定义搜索引擎 API 搜索谷歌。<br/>`网络` `搜索` |
|
||||
|
||||
> 📊 Total plugins: [<kbd>**42**</kbd>](https://lobechat.com/discover/plugins)
|
||||
> 📊 Total plugins: [<kbd>**43**</kbd>](https://lobechat.com/discover/plugins)
|
||||
|
||||
<!-- PLUGIN LIST -->
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
lockfile=false
|
||||
shamefully-hoist=true
|
||||
ignore-workspace-root-check=true
|
||||
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
@@ -1,67 +1,353 @@
|
||||
# LobeHub Desktop Application
|
||||
# 🤯 LobeHub Desktop Application
|
||||
|
||||
LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平台桌面应用程序,使用 Electron 构建,提供了更加原生的桌面体验和功能。
|
||||
LobeHub Desktop is a cross-platform desktop application for [LobeChat](https://github.com/lobehub/lobe-chat), built with Electron, providing a more native desktop experience and functionality.
|
||||
|
||||
## 功能特点
|
||||
## ✨ Features
|
||||
|
||||
- **跨平台支持**:支持 macOS (Intel/Apple Silicon)、Windows 和 Linux 系统
|
||||
- **自动更新**:内置更新机制,确保您始终使用最新版本
|
||||
- **多语言支持**:完整的国际化支持,包括中文、英文等多种语言
|
||||
- **原生集成**:与操作系统深度集成,提供原生菜单、快捷键和通知
|
||||
- **安全可靠**:macOS 版本经过公证,确保安全性
|
||||
- **多渠道发布**:提供稳定版、测试版和每日构建版本
|
||||
- **🌍 Cross-platform Support**: Supports macOS (Intel/Apple Silicon), Windows, and Linux systems
|
||||
- **🔄 Auto Updates**: Built-in update mechanism ensures you always have the latest version
|
||||
- **🌐 Multi-language Support**: Complete i18n support for 18+ languages with lazy loading
|
||||
- **🎨 Native Integration**: Deep OS integration with native menus, shortcuts, and notifications
|
||||
- **🔒 Secure & Reliable**: macOS notarized, encrypted token storage, secure OAuth flow
|
||||
- **📦 Multiple Release Channels**: Stable, beta, and nightly build versions
|
||||
- **⚡ Advanced Window Management**: Multi-window architecture with theme synchronization
|
||||
- **🔗 Remote Server Sync**: Secure data synchronization with remote LobeChat instances
|
||||
- **🎯 Developer Tools**: Built-in development panel and comprehensive debugging tools
|
||||
|
||||
## 开发环境设置
|
||||
## 🚀 Development Setup
|
||||
|
||||
### 前提条件
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- pnpm 10+
|
||||
- **Node.js** 22+
|
||||
- **pnpm** 10+
|
||||
- **Electron** compatible development environment
|
||||
|
||||
### 安装依赖
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install-isolated
|
||||
```
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
复制 `.env.desktop` 到 `.env`。
|
||||
|
||||
> [!WARNING]
|
||||
> 注意提前备份好 `.env` 文件,避免丢失配置。
|
||||
|
||||
### 开发模式运行
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
pnpm electron:dev
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### 构建应用
|
||||
### Environment Configuration
|
||||
|
||||
构建所有平台:
|
||||
Copy `.env.desktop` to `.env` and configure as needed:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
cp .env.desktop .env
|
||||
```
|
||||
|
||||
构建特定平台:
|
||||
> \[!WARNING]
|
||||
> Backup your `.env` file before making changes to avoid losing configurations.
|
||||
|
||||
### Build Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `pnpm build` | Build for all platforms |
|
||||
| `pnpm build:mac` | Build for macOS (Intel + Apple Silicon) |
|
||||
| `pnpm build:win` | Build for Windows |
|
||||
| `pnpm build:linux` | Build for Linux |
|
||||
| `pnpm build-local` | Local development build |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
pnpm build:mac
|
||||
# 1. Development
|
||||
pnpm electron:dev # Start with hot reload
|
||||
|
||||
# Windows
|
||||
pnpm build:win
|
||||
# 2. Code Quality
|
||||
pnpm lint # ESLint checking
|
||||
pnpm format # Prettier formatting
|
||||
pnpm typecheck # TypeScript validation
|
||||
|
||||
# Linux
|
||||
pnpm build:linux
|
||||
# 3. Testing
|
||||
pnpm test # Run Vitest tests
|
||||
|
||||
# 4. Build & Package
|
||||
pnpm build # Production build
|
||||
pnpm build-local # Local testing build
|
||||
```
|
||||
|
||||
## 发布渠道
|
||||
## 🎯 Release Channels
|
||||
|
||||
应用提供三个发布渠道:
|
||||
| Channel | Description | Stability | Auto-Updates |
|
||||
| ----------- | -------------------------------- | --------- | ------------ |
|
||||
| **Stable** | Thoroughly tested releases | 🟢 High | ✅ Yes |
|
||||
| **Beta** | Pre-release with new features | 🟡 Medium | ✅ Yes |
|
||||
| **Nightly** | Daily builds with latest changes | 🟠 Low | ✅ Yes |
|
||||
|
||||
- **稳定版**:经过充分测试的正式版本
|
||||
- **测试版 (Beta)**:预发布版本,包含即将发布的新功能
|
||||
- **每日构建版 (Nightly)**:包含最新开发进展的构建版本
|
||||
## 🛠 Technology Stack
|
||||
|
||||
### Core Framework
|
||||
|
||||
- **Electron** `37.1.0` - Cross-platform desktop framework
|
||||
- **Node.js** `22+` - Backend runtime
|
||||
- **TypeScript** `5.7+` - Type-safe development
|
||||
- **Vite** `6.2+` - Build tooling
|
||||
|
||||
### Architecture & Patterns
|
||||
|
||||
- **Dependency Injection** - IoC container with decorator-based registration
|
||||
- **Event-Driven Architecture** - IPC communication between processes
|
||||
- **Module Federation** - Dynamic controller and service loading
|
||||
- **Observer Pattern** - State management and UI synchronization
|
||||
|
||||
### Development Tools
|
||||
|
||||
- **Vitest** - Unit testing framework
|
||||
- **ESLint** - Code linting
|
||||
- **Prettier** - Code formatting
|
||||
- **electron-builder** - Application packaging
|
||||
- **electron-updater** - Auto-update mechanism
|
||||
|
||||
### Security & Storage
|
||||
|
||||
- **Electron Safe Storage** - Encrypted token storage
|
||||
- **OAuth 2.0 + PKCE** - Secure authentication flow
|
||||
- **electron-store** - Persistent configuration
|
||||
- **Custom Protocol Handler** - Secure callback handling
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
The desktop application uses a sophisticated dependency injection and event-driven architecture:
|
||||
|
||||
### 📁 Core Structure
|
||||
|
||||
```
|
||||
src/main/core/
|
||||
├── App.ts # 🎯 Main application orchestrator
|
||||
├── IoCContainer.ts # 🔌 Dependency injection container
|
||||
├── window/ # 🪟 Window management modules
|
||||
│ ├── WindowThemeManager.ts # 🎨 Theme synchronization
|
||||
│ ├── WindowPositionManager.ts # 📐 Position persistence
|
||||
│ ├── WindowErrorHandler.ts # ⚠️ Error boundaries
|
||||
│ └── WindowConfigBuilder.ts # ⚙️ Configuration builder
|
||||
├── browser/ # 🌐 Browser management modules
|
||||
│ ├── Browser.ts # 🪟 Individual window instances
|
||||
│ └── BrowserManager.ts # 👥 Multi-window coordinator
|
||||
├── ui/ # 🎨 UI system modules
|
||||
│ ├── Tray.ts # 📍 System tray integration
|
||||
│ ├── TrayManager.ts # 🔧 Tray management
|
||||
│ ├── MenuManager.ts # 📋 Native menu system
|
||||
│ └── ShortcutManager.ts # ⌨️ Global shortcuts
|
||||
└── infrastructure/ # 🔧 Infrastructure services
|
||||
├── StoreManager.ts # 💾 Configuration storage
|
||||
├── I18nManager.ts # 🌍 Internationalization
|
||||
├── UpdaterManager.ts # 📦 Auto-update system
|
||||
└── StaticFileServerManager.ts # 🗂️ Local file serving
|
||||
```
|
||||
|
||||
### 🔄 Application Lifecycle
|
||||
|
||||
The `App.ts` class orchestrates the entire application lifecycle through key phases:
|
||||
|
||||
#### 1. 🚀 Initialization Phase
|
||||
|
||||
- **System Information Logging** - Captures OS, CPU, RAM, and locale details
|
||||
- **Store Manager Setup** - Initializes persistent configuration storage
|
||||
- **Dynamic Module Loading** - Auto-discovers controllers and services via glob imports
|
||||
- **IPC Event Registration** - Sets up inter-process communication channels
|
||||
|
||||
#### 2. 🏃 Bootstrap Phase
|
||||
|
||||
- **Single Instance Check** - Ensures only one application instance runs
|
||||
- **IPC Server Launch** - Starts the communication server
|
||||
- **Core Manager Initialization** - Sequential initialization of all managers:
|
||||
- 🌍 I18n for internationalization
|
||||
- 📋 Menu system for native menus
|
||||
- 🗂️ Static file server for local assets
|
||||
- ⌨️ Global shortcuts registration
|
||||
- 🪟 Browser window management
|
||||
- 📍 System tray (Windows only)
|
||||
- 📦 Auto-updater system
|
||||
|
||||
### 🔧 Core Components Deep Dive
|
||||
|
||||
#### 🌐 Browser Management System
|
||||
|
||||
- **Multi-Window Architecture** - Supports chat, settings, and devtools windows
|
||||
- **Window State Management** - Handles positioning, theming, and lifecycle
|
||||
- **WebContents Mapping** - Bidirectional mapping between WebContents and identifiers
|
||||
- **Event Broadcasting** - Centralized event distribution to all or specific windows
|
||||
|
||||
#### 🔌 Dependency Injection & Event System
|
||||
|
||||
- **IoC Container** - WeakMap-based container for decorated controller methods
|
||||
- **Decorator Registration** - `@ipcClientEvent` and `@ipcServerEvent` decorators
|
||||
- **Automatic Event Mapping** - Events registered during controller loading
|
||||
- **Service Locator** - Type-safe service and controller retrieval
|
||||
|
||||
#### 🪟 Window Management
|
||||
|
||||
- **Theme-Aware Windows** - Automatic adaptation to system dark/light mode
|
||||
- **Platform-Specific Styling** - Windows title bar and overlay customization
|
||||
- **Position Persistence** - Save and restore window positions across sessions
|
||||
- **Error Boundaries** - Centralized error handling for window operations
|
||||
|
||||
#### 🔧 Infrastructure Services
|
||||
|
||||
##### 🌍 I18n Manager
|
||||
|
||||
- **18+ Language Support** with lazy loading and namespace organization
|
||||
- **System Integration** with Electron's locale detection
|
||||
- **Dynamic UI Refresh** on language changes
|
||||
- **Resource Management** with efficient loading strategies
|
||||
|
||||
##### 📦 Update Manager
|
||||
|
||||
- **Multi-Channel Support** (stable, beta, nightly) with configurable intervals
|
||||
- **Background Downloads** with progress tracking and user notifications
|
||||
- **Rollback Protection** with error handling and recovery mechanisms
|
||||
- **Channel Management** with automatic channel switching
|
||||
|
||||
##### 💾 Store Manager
|
||||
|
||||
- **Type-Safe Storage** using electron-store with TypeScript interfaces
|
||||
- **Encrypted Secrets** via Electron's Safe Storage API
|
||||
- **Configuration Validation** with default value management
|
||||
- **File System Integration** with automatic directory creation
|
||||
|
||||
##### 🗂️ Static File Server
|
||||
|
||||
- **Local HTTP Server** for serving application assets and user files
|
||||
- **Security Controls** with request filtering and access validation
|
||||
- **File Management** with upload, download, and deletion capabilities
|
||||
- **Path Resolution** with intelligent routing between storage locations
|
||||
|
||||
#### 🎨 UI System Integration
|
||||
|
||||
- **Global Shortcuts** - Platform-aware keyboard shortcut registration with conflict detection
|
||||
- **System Tray** - Native integration with context menus and notifications
|
||||
- **Native Menus** - Platform-specific application and context menus with i18n
|
||||
- **Theme Synchronization** - Automatic theme updates across all UI components
|
||||
|
||||
### 🏛 Controller & Service Architecture
|
||||
|
||||
#### 🎮 Controller Pattern
|
||||
|
||||
- **IPC Event Handling** - Processes events from renderer with decorator-based registration
|
||||
- **Lifecycle Hooks** - `beforeAppReady` and `afterAppReady` for initialization phases
|
||||
- **Type-Safe Communication** - Strong typing for all IPC events and responses
|
||||
- **Error Boundaries** - Comprehensive error handling with proper propagation
|
||||
|
||||
#### 🔧 Service Pattern
|
||||
|
||||
- **Business Logic Encapsulation** - Clean separation of concerns
|
||||
- **Dependency Management** - Managed through IoC container
|
||||
- **Cross-Controller Sharing** - Services accessible via service locator pattern
|
||||
- **Resource Management** - Proper initialization and cleanup
|
||||
|
||||
### 🔗 Inter-Process Communication
|
||||
|
||||
#### 📡 IPC System Features
|
||||
|
||||
- **Bidirectional Communication** - Main↔Renderer and Main↔Next.js server
|
||||
- **Type-Safe Events** - TypeScript interfaces for all event parameters
|
||||
- **Context Awareness** - Events include sender context for window-specific operations
|
||||
- **Error Propagation** - Centralized error handling with proper status codes
|
||||
|
||||
#### 🛡️ Security Features
|
||||
|
||||
- **OAuth 2.0 + PKCE** - Secure authentication with state parameter validation
|
||||
- **Encrypted Token Storage** - Using Electron's Safe Storage API when available
|
||||
- **Custom Protocol Handler** - Secure callback handling for OAuth flows
|
||||
- **Request Filtering** - Security controls for web requests and external links
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Structure
|
||||
|
||||
```bash
|
||||
apps/desktop/src/main/controllers/__tests__/ # Controller unit tests
|
||||
tests/ # Integration tests
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test # Run all tests
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm typecheck # Type validation
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Controller Tests** - IPC event handling validation
|
||||
- **Service Tests** - Business logic verification
|
||||
- **Integration Tests** - End-to-end workflow testing
|
||||
- **Type Tests** - TypeScript interface validation
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **OAuth 2.0 Flow** with PKCE for secure token exchange
|
||||
- **State Parameter Validation** to prevent CSRF attacks
|
||||
- **Encrypted Token Storage** using platform-native secure storage
|
||||
- **Automatic Token Refresh** with fallback to re-authentication
|
||||
|
||||
### Application Security
|
||||
|
||||
- **Code Signing** - macOS notarization for enhanced security
|
||||
- **Sandboxing** - Controlled access to system resources
|
||||
- **CSP Controls** - Content Security Policy management
|
||||
- **Request Filtering** - Security controls for external requests
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Encrypted Configuration** - Sensitive data encrypted at rest
|
||||
- **Secure IPC** - Type-safe communication channels
|
||||
- **Path Validation** - Secure file system access controls
|
||||
- **Network Security** - HTTPS enforcement and proxy support
|
||||
|
||||
## 🤝 Contribution
|
||||
|
||||
Desktop application development involves complex cross-platform considerations and native integrations. We welcome community contributions to improve functionality, performance, and user experience. You can participate in improvements through:
|
||||
|
||||
### How to Contribute
|
||||
|
||||
1. **Platform Support**: Enhance cross-platform compatibility and native integrations
|
||||
2. **Performance Optimization**: Improve application startup time, memory usage, and responsiveness
|
||||
3. **Feature Development**: Add new desktop-specific features and capabilities
|
||||
4. **Bug Fixes**: Fix platform-specific issues and edge cases
|
||||
5. **Security Improvements**: Enhance security measures and authentication flows
|
||||
6. **UI/UX Enhancements**: Improve desktop user interface and experience
|
||||
|
||||
### Contribution Process
|
||||
|
||||
1. Fork the [LobeChat repository](https://github.com/lobehub/lobe-chat)
|
||||
2. Set up the desktop development environment following our setup guide
|
||||
3. Make your changes to the desktop application
|
||||
4. Submit a Pull Request describing:
|
||||
|
||||
- Platform compatibility testing results
|
||||
- Performance impact analysis
|
||||
- Security considerations
|
||||
- User experience improvements
|
||||
- Breaking changes (if any)
|
||||
|
||||
### Development Areas
|
||||
|
||||
- **Core Architecture**: Dependency injection, event system, and lifecycle management
|
||||
- **Window Management**: Multi-window support, theme synchronization, and state persistence
|
||||
- **IPC Communication**: Type-safe inter-process communication between main and renderer
|
||||
- **Platform Integration**: Native menus, shortcuts, notifications, and system tray
|
||||
- **Security Features**: OAuth flows, token encryption, and secure storage
|
||||
- **Auto-Update System**: Multi-channel updates and rollback mechanisms
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- **Development Guide**: [`Development.md`](./Development.md) - Comprehensive development documentation
|
||||
- **Architecture Docs**: [`/docs`](../../docs/) - Detailed technical specifications
|
||||
- **Contributing**: [`CONTRIBUTING.md`](../../CONTRIBUTING.md) - Contribution guidelines
|
||||
- **Issues & Support**: [GitHub Issues](https://github.com/lobehub/lobe-chat/issues)
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# 🤯 LobeHub 桌面应用程序
|
||||
|
||||
LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平台桌面应用程序,使用 Electron 构建,提供了更加原生的桌面体验和功能。
|
||||
|
||||
## ✨ 功能特点
|
||||
|
||||
- **🌍 跨平台支持**:支持 macOS (Intel/Apple Silicon)、Windows 和 Linux 系统
|
||||
- **🔄 自动更新**:内置更新机制,确保您始终使用最新版本
|
||||
- **🌐 多语言支持**:完整的 i18n 支持,包含 18+ 种语言的懒加载
|
||||
- **🎨 原生集成**:与操作系统深度集成,提供原生菜单、快捷键和通知
|
||||
- **🔒 安全可靠**:macOS 公证认证,加密令牌存储,安全的 OAuth 流程
|
||||
- **📦 多渠道发布**:提供稳定版、测试版和每日构建版本
|
||||
- **⚡ 高级窗口管理**:多窗口架构,支持主题同步
|
||||
- **🔗 远程服务器同步**:与远程 LobeChat 实例的安全数据同步
|
||||
- **🎯 开发者工具**:内置开发面板和全面的调试工具
|
||||
|
||||
## 🚀 开发环境设置
|
||||
|
||||
### 前提条件
|
||||
|
||||
- **Node.js** 22+
|
||||
- **pnpm** 10+
|
||||
- **Electron** 兼容的开发环境
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install-isolated
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm electron:dev
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
|
||||
# 运行测试
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
复制 `.env.desktop` 到 `.env` 并根据需要配置:
|
||||
|
||||
```bash
|
||||
cp .env.desktop .env
|
||||
```
|
||||
|
||||
> \[!WARNING]
|
||||
> 在进行更改之前请备份您的 `.env` 文件,避免丢失配置。
|
||||
|
||||
### 构建命令
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| `pnpm build` | 构建所有平台 |
|
||||
| `pnpm build:mac` | 构建 macOS (Intel + Apple Silicon) |
|
||||
| `pnpm build:win` | 构建 Windows |
|
||||
| `pnpm build:linux` | 构建 Linux |
|
||||
| `pnpm build-local` | 本地开发构建 |
|
||||
|
||||
### 开发工作流
|
||||
|
||||
```bash
|
||||
# 1. 开发
|
||||
pnpm electron:dev # 启动热重载开发服务器
|
||||
|
||||
# 2. 代码质量
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm format # Prettier 格式化
|
||||
pnpm typecheck # TypeScript 验证
|
||||
|
||||
# 3. 测试
|
||||
pnpm test # 运行 Vitest 测试
|
||||
|
||||
# 4. 构建和打包
|
||||
pnpm build # 生产构建
|
||||
pnpm build-local # 本地测试构建
|
||||
```
|
||||
|
||||
## 🎯 发布渠道
|
||||
|
||||
| 渠道 | 描述 | 稳定性 | 自动更新 |
|
||||
| ------------------------ | ---------------------- | ------ | -------- |
|
||||
| **稳定版** | 经过充分测试的正式版本 | 🟢 高 | ✅ 是 |
|
||||
| **测试版 (Beta)** | 包含新功能的预发布版本 | 🟡 中 | ✅ 是 |
|
||||
| **每日构建版 (Nightly)** | 包含最新更改的每日构建 | 🟠 低 | ✅ 是 |
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
### 核心框架
|
||||
|
||||
- **Electron** `37.1.0` - 跨平台桌面框架
|
||||
- **Node.js** `22+` - 后端运行时
|
||||
- **TypeScript** `5.7+` - 类型安全开发
|
||||
- **Vite** `6.2+` - 构建工具
|
||||
|
||||
### 架构和模式
|
||||
|
||||
- **依赖注入** - 基于装饰器注册的 IoC 容器
|
||||
- **事件驱动架构** - 进程间 IPC 通信
|
||||
- **模块联邦** - 动态控制器和服务加载
|
||||
- **观察者模式** - 状态管理和 UI 同步
|
||||
|
||||
### 开发工具
|
||||
|
||||
- **Vitest** - 单元测试框架
|
||||
- **ESLint** - 代码检查
|
||||
- **Prettier** - 代码格式化
|
||||
- **electron-builder** - 应用程序打包
|
||||
- **electron-updater** - 自动更新机制
|
||||
|
||||
### 安全和存储
|
||||
|
||||
- **Electron Safe Storage** - 加密令牌存储
|
||||
- **OAuth 2.0 + PKCE** - 安全认证流程
|
||||
- **electron-store** - 持久化配置
|
||||
- **自定义协议处理器** - 安全回调处理
|
||||
|
||||
## 🏗 架构设计
|
||||
|
||||
桌面应用程序采用了复杂的依赖注入和事件驱动架构:
|
||||
|
||||
### 📁 核心结构
|
||||
|
||||
```
|
||||
src/main/core/
|
||||
├── App.ts # 🎯 主应用程序协调器
|
||||
├── IoCContainer.ts # 🔌 依赖注入容器
|
||||
├── window/ # 🪟 窗口管理模块
|
||||
│ ├── WindowThemeManager.ts # 🎨 主题同步
|
||||
│ ├── WindowPositionManager.ts # 📐 位置持久化
|
||||
│ ├── WindowErrorHandler.ts # ⚠️ 错误边界
|
||||
│ └── WindowConfigBuilder.ts # ⚙️ 配置构建器
|
||||
├── browser/ # 🌐 浏览器管理模块
|
||||
│ ├── Browser.ts # 🪟 单个窗口实例
|
||||
│ └── BrowserManager.ts # 👥 多窗口协调器
|
||||
├── ui/ # 🎨 UI 系统模块
|
||||
│ ├── Tray.ts # 📍 系统托盘集成
|
||||
│ ├── TrayManager.ts # 🔧 托盘管理
|
||||
│ ├── MenuManager.ts # 📋 原生菜单系统
|
||||
│ └── ShortcutManager.ts # ⌨️ 全局快捷键
|
||||
└── infrastructure/ # 🔧 基础设施服务
|
||||
├── StoreManager.ts # 💾 配置存储
|
||||
├── I18nManager.ts # 🌍 国际化
|
||||
├── UpdaterManager.ts # 📦 自动更新系统
|
||||
└── StaticFileServerManager.ts # 🗂️ 本地文件服务
|
||||
```
|
||||
|
||||
### 🔄 应用程序生命周期
|
||||
|
||||
`App.ts` 类通过几个关键阶段协调整个应用程序的生命周期:
|
||||
|
||||
#### 1. 🚀 初始化阶段
|
||||
|
||||
- **系统信息记录** - 捕获操作系统、CPU、内存和区域设置详细信息
|
||||
- **存储管理器设置** - 初始化持久配置存储
|
||||
- **动态模块加载** - 通过 glob 导入自动发现控制器和服务
|
||||
- **IPC 事件注册** - 设置进程间通信通道
|
||||
|
||||
#### 2. 🏃 引导阶段
|
||||
|
||||
- **单实例检查** - 确保只运行一个应用程序实例
|
||||
- **IPC 服务器启动** - 启动通信服务器
|
||||
- **核心管理器初始化** - 按顺序初始化所有管理器:
|
||||
- 🌍 国际化管理器
|
||||
- 📋 原生菜单系统
|
||||
- 🗂️ 本地资源服务器
|
||||
- ⌨️ 全局快捷键注册
|
||||
- 🪟 浏览器窗口管理
|
||||
- 📍 系统托盘(仅 Windows)
|
||||
- 📦 自动更新系统
|
||||
|
||||
### 🔧 核心组件深度解析
|
||||
|
||||
#### 🌐 浏览器管理系统
|
||||
|
||||
- **多窗口架构** - 支持聊天、设置和开发工具窗口
|
||||
- **窗口状态管理** - 处理定位、主题和生命周期
|
||||
- **WebContents 映射** - WebContents 和标识符之间的双向映射
|
||||
- **事件广播** - 向所有或特定窗口的集中事件分发
|
||||
|
||||
#### 🔌 依赖注入和事件系统
|
||||
|
||||
- **IoC 容器** - 基于 WeakMap 的装饰控制器方法容器
|
||||
- **装饰器注册** - `@ipcClientEvent` 和 `@ipcServerEvent` 装饰器
|
||||
- **自动事件映射** - 控制器加载期间注册的事件
|
||||
- **服务定位器** - 类型安全的服务和控制器检索
|
||||
|
||||
#### 🪟 窗口管理
|
||||
|
||||
- **主题感知窗口** - 自动适应系统深色 / 浅色模式
|
||||
- **平台特定样式** - Windows 标题栏和覆盖自定义
|
||||
- **位置持久化** - 跨会话保存和恢复窗口位置
|
||||
- **错误边界** - 窗口操作的集中错误处理
|
||||
|
||||
#### 🔧 基础设施服务
|
||||
|
||||
##### 🌍 国际化管理器
|
||||
|
||||
- **18+ 语言支持** 懒加载和命名空间组织
|
||||
- **系统集成** 与 Electron 的区域检测集成
|
||||
- **动态 UI 刷新** 语言更改时的 UI 更新
|
||||
- **资源管理** 高效的加载策略
|
||||
|
||||
##### 📦 更新管理器
|
||||
|
||||
- **多渠道支持** (稳定版、测试版、每日构建)可配置间隔
|
||||
- **后台下载** 进度跟踪和用户通知
|
||||
- **回滚保护** 错误处理和恢复机制
|
||||
- **渠道管理** 自动渠道切换
|
||||
|
||||
##### 💾 存储管理器
|
||||
|
||||
- **类型安全存储** 使用带有 TypeScript 接口的 electron-store
|
||||
- **加密机密** 通过 Electron 的安全存储 API
|
||||
- **配置验证** 默认值管理
|
||||
- **文件系统集成** 自动目录创建
|
||||
|
||||
##### 🗂️ 静态文件服务器
|
||||
|
||||
- **本地 HTTP 服务器** 用于提供应用程序资源和用户文件
|
||||
- **安全控制** 请求过滤和访问验证
|
||||
- **文件管理** 上传、下载和删除功能
|
||||
- **路径解析** 存储位置之间的智能路由
|
||||
|
||||
#### 🎨 UI 系统集成
|
||||
|
||||
- **全局快捷键** - 平台感知的键盘快捷键注册与冲突检测
|
||||
- **系统托盘** - 带有上下文菜单和通知的原生集成
|
||||
- **原生菜单** - 带有 i18n 的平台特定应用程序和上下文菜单
|
||||
- **主题同步** - 所有 UI 组件的自动主题更新
|
||||
|
||||
### 🏛 控制器和服务架构
|
||||
|
||||
#### 🎮 控制器模式
|
||||
|
||||
- **IPC 事件处理** - 通过基于装饰器的注册处理来自渲染器的事件
|
||||
- **生命周期钩子** - 初始化阶段的 `beforeAppReady` 和 `afterAppReady`
|
||||
- **类型安全通信** - 所有 IPC 事件和响应的强类型
|
||||
- **错误边界** - 具有适当传播的全面错误处理
|
||||
|
||||
#### 🔧 服务模式
|
||||
|
||||
- **业务逻辑封装** - 关注点的清晰分离
|
||||
- **依赖管理** - 通过 IoC 容器管理
|
||||
- **跨控制器共享** - 通过服务定位器模式访问的服务
|
||||
- **资源管理** - 适当的初始化和清理
|
||||
|
||||
### 🔗 进程间通信
|
||||
|
||||
#### 📡 IPC 系统功能
|
||||
|
||||
- **双向通信** - Main↔Renderer 和 Main↔Next.js 服务器
|
||||
- **类型安全事件** - 所有事件参数的 TypeScript 接口
|
||||
- **上下文感知** - 事件包含用于窗口特定操作的发送者上下文
|
||||
- **错误传播** - 具有适当状态码的集中错误处理
|
||||
|
||||
#### 🛡️ 安全功能
|
||||
|
||||
- **OAuth 2.0 + PKCE** - 具有状态参数验证的安全认证
|
||||
- **加密令牌存储** - 在可用时使用 Electron 的安全存储 API
|
||||
- **自定义协议处理器** - OAuth 流程的安全回调处理
|
||||
- **请求过滤** - 网络请求和外部链接的安全控制
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 测试结构
|
||||
|
||||
```bash
|
||||
apps/desktop/src/main/controllers/__tests__/ # 控制器单元测试
|
||||
tests/ # 集成测试
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
pnpm test # 运行所有测试
|
||||
pnpm test:watch # 监视模式
|
||||
pnpm typecheck # 类型验证
|
||||
```
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
- **控制器测试** - IPC 事件处理验证
|
||||
- **服务测试** - 业务逻辑验证
|
||||
- **集成测试** - 端到端工作流测试
|
||||
- **类型测试** - TypeScript 接口验证
|
||||
|
||||
## 🔒 安全功能
|
||||
|
||||
### 认证和授权
|
||||
|
||||
- **OAuth 2.0 流程** 使用 PKCE 进行安全令牌交换
|
||||
- **状态参数验证** 防止 CSRF 攻击
|
||||
- **加密令牌存储** 使用平台原生安全存储
|
||||
- **自动令牌刷新** 在失败时回退到重新认证
|
||||
|
||||
### 应用程序安全
|
||||
|
||||
- **代码签名** - macOS 公证认证以增强安全性
|
||||
- **沙盒** - 对系统资源的受控访问
|
||||
- **CSP 控制** - 内容安全策略管理
|
||||
- **请求过滤** - 外部请求的安全控制
|
||||
|
||||
### 数据保护
|
||||
|
||||
- **加密配置** - 敏感数据静态加密
|
||||
- **安全 IPC** - 类型安全的通信通道
|
||||
- **路径验证** - 安全的文件系统访问控制
|
||||
- **网络安全** - HTTPS 强制和代理支持
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
桌面应用程序开发涉及复杂的跨平台考虑和原生集成。我们欢迎社区贡献来改进功能、性能和用户体验。您可以通过以下方式参与改进:
|
||||
|
||||
### 如何贡献
|
||||
|
||||
1. **平台支持**:增强跨平台兼容性和原生集成
|
||||
2. **性能优化**:改进应用程序启动时间、内存使用和响应性
|
||||
3. **功能开发**:添加新的桌面特定功能和能力
|
||||
4. **错误修复**:修复平台特定问题和边缘情况
|
||||
5. **安全改进**:增强安全措施和认证流程
|
||||
6. **UI/UX 增强**:改进桌面用户界面和体验
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork [LobeChat 仓库](https://github.com/lobehub/lobe-chat)
|
||||
2. 按照我们的设置指南建立桌面开发环境
|
||||
3. 对桌面应用程序进行修改
|
||||
4. 提交 Pull Request 并描述:
|
||||
|
||||
- 平台兼容性测试结果
|
||||
- 性能影响分析
|
||||
- 安全考虑
|
||||
- 用户体验改进
|
||||
- 破坏性更改(如有)
|
||||
|
||||
### 开发领域
|
||||
|
||||
- **核心架构**:依赖注入、事件系统和生命周期管理
|
||||
- **窗口管理**:多窗口支持、主题同步和状态持久化
|
||||
- **IPC 通信**:主进程和渲染进程之间的类型安全进程间通信
|
||||
- **平台集成**:原生菜单、快捷键、通知和系统托盘
|
||||
- **安全功能**:OAuth 流程、令牌加密和安全存储
|
||||
- **自动更新系统**:多渠道更新和回滚机制
|
||||
|
||||
## 📚 其他资源
|
||||
|
||||
- **开发指南**:[`Development.md`](./Development.md) - 全面的开发文档
|
||||
- **架构文档**:[`/docs`](../../docs/) - 详细的技术规范
|
||||
- **贡献指南**:[`CONTRIBUTING.md`](../../CONTRIBUTING.md) - 贡献指导
|
||||
- **问题和支持**:[GitHub Issues](https://github.com/lobehub/lobe-chat/issues)
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 171 KiB |
@@ -11,8 +11,9 @@ console.log(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
minify: !isDev,
|
||||
outDir: 'dist/main',
|
||||
sourcemap: isDev,
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
// 这里是关键:在构建时进行文本替换
|
||||
define: {
|
||||
@@ -30,8 +31,9 @@ export default defineConfig({
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
minify: !isDev,
|
||||
outDir: 'dist/preload',
|
||||
sourcemap: isDev,
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
plugins: [externalizeDepsPlugin({})],
|
||||
resolve: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "lobehub-desktop-dev",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "LobeHub Desktop Application",
|
||||
"homepage": "https://lobehub.com",
|
||||
"repository": {
|
||||
@@ -20,16 +21,17 @@
|
||||
"electron:run-unpack": "electron .",
|
||||
"format": "prettier --write ",
|
||||
"i18n": "bun run scripts/i18nWorkflow/index.ts && lobe-i18n",
|
||||
"postinstall": "electron-builder install-app-deps && pnpm rebuild sharp",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"install-isolated": "pnpm install",
|
||||
"lint": "eslint --cache ",
|
||||
"pg-server": "bun run scripts/pglite-server.ts",
|
||||
"start": "electron-vite preview",
|
||||
"test": "vite --run",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"get-port-please": "^3.1.2",
|
||||
"pdfjs-dist": "4.10.38"
|
||||
},
|
||||
@@ -58,6 +60,8 @@
|
||||
"electron-vite": "^3.0.0",
|
||||
"execa": "^9.5.2",
|
||||
"fix-path": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"just-diff": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -67,7 +71,8 @@
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3",
|
||||
"undici": "^7.9.0",
|
||||
"vite": "^6.2.5"
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
|
After Width: | Height: | Size: 807 B |
|
Before Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +1,4 @@
|
||||
import type { BrowserWindowOpts } from './core/Browser';
|
||||
import type { BrowserWindowOpts } from './core/browser/Browser';
|
||||
|
||||
export const BrowsersIdentifiers = {
|
||||
chat: 'chat',
|
||||
@@ -36,7 +36,7 @@ export const appBrowsers = {
|
||||
autoHideMenuBar: true,
|
||||
height: 800,
|
||||
identifier: 'settings',
|
||||
// keepAlive: true,
|
||||
keepAlive: true,
|
||||
minWidth: 600,
|
||||
parentIdentifier: 'chat',
|
||||
path: '/settings',
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
export const isDev = process.env.NODE_ENV === 'development';
|
||||
import { dev, linux, macOS, windows } from 'electron-is';
|
||||
import os from 'node:os';
|
||||
|
||||
export const isDev = dev();
|
||||
|
||||
export const OFFICIAL_CLOUD_SERVER = process.env.OFFICIAL_CLOUD_SERVER || 'https://lobechat.com';
|
||||
|
||||
export const isMac = macOS();
|
||||
export const isWindows = windows();
|
||||
export const isLinux = linux();
|
||||
|
||||
function getIsWindows11() {
|
||||
if (!isWindows) return false;
|
||||
// 获取操作系统版本(如 "10.0.22621")
|
||||
const release = os.release();
|
||||
const parts = release.split('.');
|
||||
|
||||
// 主版本和次版本
|
||||
const majorVersion = parseInt(parts[0], 10);
|
||||
const minorVersion = parseInt(parts[1], 10);
|
||||
|
||||
// 构建号是第三部分
|
||||
const buildNumber = parseInt(parts[2], 10);
|
||||
|
||||
// Windows 11 的构建号从 22000 开始
|
||||
return majorVersion === 10 && minorVersion === 0 && buildNumber >= 22_000;
|
||||
}
|
||||
|
||||
export const isWindows11 = getIsWindows11();
|
||||
|
||||
@@ -31,4 +31,5 @@ export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
networkProxy: defaultProxySettings,
|
||||
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
|
||||
storagePath: appStorageDir,
|
||||
themeMode: 'auto',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Theme colors
|
||||
export const BACKGROUND_DARK = '#000';
|
||||
export const BACKGROUND_LIGHT = '#f8f8f8';
|
||||
export const SYMBOL_COLOR_DARK = '#ffffff80';
|
||||
export const SYMBOL_COLOR_LIGHT = '#00000080';
|
||||
|
||||
// Window dimensions and constraints
|
||||
export const TITLE_BAR_HEIGHT = 29;
|
||||
|
||||
// Default window configuration
|
||||
export const THEME_CHANGE_DELAY = 100;
|
||||
@@ -1,10 +1,9 @@
|
||||
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import querystring from 'node:querystring';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
@@ -13,10 +12,9 @@ import { ControllerModule, ipcClientEvent } from './index';
|
||||
// Create logger
|
||||
const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
const protocolPrefix = `com.lobehub.${name}`;
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Used to implement the OAuth authorization flow
|
||||
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
|
||||
*/
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
@@ -32,9 +30,29 @@ export default class AuthCtr extends ControllerModule {
|
||||
private codeVerifier: string | null = null;
|
||||
private authRequestState: string | null = null;
|
||||
|
||||
beforeAppReady = () => {
|
||||
this.registerProtocolHandler();
|
||||
};
|
||||
/**
|
||||
* 轮询相关参数
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
private pollingInterval: NodeJS.Timeout | null = null;
|
||||
private cachedRemoteUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* 自动刷新定时器
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
private autoRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* 构造 redirect_uri,确保授权和令牌交换时使用相同的 URI
|
||||
* @param remoteUrl 远程服务器 URL
|
||||
* @param includeHandoffId 是否包含 handoff ID(仅在授权时需要)
|
||||
*/
|
||||
private constructRedirectUri(remoteUrl: string): string {
|
||||
const callbackUrl = new URL('/oidc/callback/desktop', remoteUrl);
|
||||
|
||||
return callbackUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request OAuth authorization
|
||||
@@ -43,6 +61,9 @@ export default class AuthCtr extends ControllerModule {
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
|
||||
|
||||
// 缓存远程服务器 URL 用于后续轮询
|
||||
this.cachedRemoteUrl = remoteUrl;
|
||||
|
||||
logger.info(
|
||||
`Requesting OAuth authorization, storageMode:${config.storageMode} server URL: ${remoteUrl}`,
|
||||
);
|
||||
@@ -57,8 +78,11 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.authRequestState = crypto.randomBytes(16).toString('hex');
|
||||
logger.debug(`Generated state parameter: ${this.authRequestState}`);
|
||||
|
||||
// Construct authorization URL
|
||||
// Construct authorization URL with new redirect_uri
|
||||
const authUrl = new URL('/oidc/auth', remoteUrl);
|
||||
const redirectUri = this.constructRedirectUri(remoteUrl);
|
||||
|
||||
logger.info('redirectUri', redirectUri);
|
||||
|
||||
// Add query parameters
|
||||
authUrl.search = querystring.stringify({
|
||||
@@ -66,7 +90,9 @@ export default class AuthCtr extends ControllerModule {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
prompt: 'consent',
|
||||
redirect_uri: `${protocolPrefix}://auth/callback`,
|
||||
redirect_uri: redirectUri,
|
||||
// https://github.com/lobehub/lobe-chat/pull/8450
|
||||
resource: 'urn:lobehub:chat',
|
||||
response_type: 'code',
|
||||
scope: 'profile email offline_access',
|
||||
state: this.authRequestState,
|
||||
@@ -78,6 +104,9 @@ export default class AuthCtr extends ControllerModule {
|
||||
await shell.openExternal(authUrl.toString());
|
||||
logger.debug('Opening authorization URL in default browser');
|
||||
|
||||
// Start polling for credentials
|
||||
this.startPolling();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Authorization request failed:', error);
|
||||
@@ -86,85 +115,188 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authorization callback
|
||||
* This method is called when the browser redirects to our custom protocol
|
||||
* 启动轮询机制获取凭证
|
||||
*/
|
||||
async handleAuthCallback(callbackUrl: string) {
|
||||
logger.info(`Handling authorization callback: ${callbackUrl}`);
|
||||
private startPolling() {
|
||||
if (!this.authRequestState) {
|
||||
logger.error('No handoff ID available for polling');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Starting credential polling');
|
||||
const pollInterval = 3000; // 3 seconds
|
||||
const maxPollTime = 5 * 60 * 1000; // 5 minutes
|
||||
const startTime = Date.now();
|
||||
|
||||
this.pollingInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check if polling has timed out
|
||||
if (Date.now() - startTime > maxPollTime) {
|
||||
logger.warn('Credential polling timed out');
|
||||
this.stopPolling();
|
||||
this.broadcastAuthorizationFailed('Authorization timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for credentials
|
||||
const result = await this.pollForCredentials();
|
||||
|
||||
if (result) {
|
||||
logger.info('Successfully received credentials from polling');
|
||||
this.stopPolling();
|
||||
|
||||
// Validate state parameter
|
||||
if (result.state !== this.authRequestState) {
|
||||
logger.error(
|
||||
`Invalid state parameter: expected ${this.authRequestState}, received ${result.state}`,
|
||||
);
|
||||
this.broadcastAuthorizationFailed('Invalid state parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const exchangeResult = await this.exchangeCodeForToken(result.code, this.codeVerifier!);
|
||||
|
||||
if (exchangeResult.success) {
|
||||
logger.info('Authorization successful');
|
||||
this.broadcastAuthorizationSuccessful();
|
||||
} else {
|
||||
logger.warn(`Authorization failed: ${exchangeResult.error || 'Unknown error'}`);
|
||||
this.broadcastAuthorizationFailed(exchangeResult.error || 'Unknown error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during credential polling:', error);
|
||||
this.stopPolling();
|
||||
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
|
||||
}
|
||||
}, pollInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
private stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动刷新定时器
|
||||
*/
|
||||
private startAutoRefresh() {
|
||||
// 先停止现有的定时器
|
||||
this.stopAutoRefresh();
|
||||
|
||||
const checkInterval = 2 * 60 * 1000; // 每 2 分钟检查一次
|
||||
logger.debug('Starting auto-refresh timer');
|
||||
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// 检查 token 是否即将过期 (提前 5 分钟刷新)
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
`Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`,
|
||||
);
|
||||
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (result.success) {
|
||||
logger.info('Auto-refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed: ${result.error}`);
|
||||
// 如果自动刷新失败,停止定时器并清除 token
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during auto-refresh check:', error);
|
||||
}
|
||||
}, checkInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动刷新定时器
|
||||
*/
|
||||
private stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer) {
|
||||
clearInterval(this.autoRefreshTimer);
|
||||
this.autoRefreshTimer = null;
|
||||
logger.debug('Stopped auto-refresh timer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询获取凭证
|
||||
* 直接发送 HTTP 请求到远程服务器
|
||||
*/
|
||||
private async pollForCredentials(): Promise<{ code: string; state: string } | null> {
|
||||
if (!this.authRequestState || !this.cachedRemoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(callbackUrl);
|
||||
const params = new URLSearchParams(url.search);
|
||||
// 使用缓存的远程服务器 URL
|
||||
const remoteUrl = this.cachedRemoteUrl;
|
||||
|
||||
// Get authorization code
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
logger.debug(`Got parameters from callback URL: code=${code}, state=${state}`);
|
||||
// 构造请求 URL
|
||||
const url = new URL('/oidc/handoff', remoteUrl);
|
||||
url.searchParams.set('id', this.authRequestState);
|
||||
url.searchParams.set('client', 'desktop');
|
||||
|
||||
// Validate state parameter to prevent CSRF attacks
|
||||
if (state !== this.authRequestState) {
|
||||
logger.error(
|
||||
`Invalid state parameter: expected ${this.authRequestState}, received ${state}`,
|
||||
);
|
||||
throw new Error('Invalid state parameter');
|
||||
}
|
||||
logger.debug('State parameter validation passed');
|
||||
logger.debug(`Polling for credentials: ${url.toString()}`);
|
||||
|
||||
if (!code) {
|
||||
logger.error('No authorization code received');
|
||||
throw new Error('No authorization code received');
|
||||
// 直接发送 HTTP 请求
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// 检查响应状态
|
||||
if (response.status === 404) {
|
||||
// 凭证还未准备好,这是正常情况
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get configuration information
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
logger.debug(`Getting remote server configuration: url=${config.remoteServerUrl}`);
|
||||
|
||||
if (config.storageMode === 'selfHost' && !config.remoteServerUrl) {
|
||||
logger.error('Server URL not configured');
|
||||
throw new Error('No server URL configured');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get the previously saved code_verifier
|
||||
const codeVerifier = this.codeVerifier;
|
||||
if (!codeVerifier) {
|
||||
logger.error('Code verifier not found');
|
||||
throw new Error('No code verifier found');
|
||||
}
|
||||
logger.debug('Found code verifier');
|
||||
// 解析响应数据
|
||||
const data = (await response.json()) as {
|
||||
data: {
|
||||
id: string;
|
||||
payload: { code: string; state: string };
|
||||
};
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
// Exchange authorization code for token
|
||||
logger.debug('Starting to exchange authorization code for token');
|
||||
const result = await this.exchangeCodeForToken(code, codeVerifier);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Authorization successful');
|
||||
// Notify render process of successful authorization
|
||||
this.broadcastAuthorizationSuccessful();
|
||||
} else {
|
||||
logger.warn(`Authorization failed: ${result.error || 'Unknown error'}`);
|
||||
// Notify render process of failed authorization
|
||||
this.broadcastAuthorizationFailed(result.error || 'Unknown error');
|
||||
if (data.success && data.data?.payload) {
|
||||
logger.debug('Successfully retrieved credentials from handoff');
|
||||
return {
|
||||
code: data.data.payload.code,
|
||||
state: data.data.payload.state,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Handling authorization callback failed:', error);
|
||||
|
||||
// Notify render process of failed authorization
|
||||
this.broadcastAuthorizationFailed(error.message);
|
||||
|
||||
return { error: error.message, success: false };
|
||||
} finally {
|
||||
// Clear authorization request state
|
||||
logger.debug('Clearing authorization request state');
|
||||
this.authRequestState = null;
|
||||
this.codeVerifier = null;
|
||||
logger.debug('Polling attempt failed (this is normal):', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
@ipcClientEvent('refreshAccessToken')
|
||||
async refreshAccessToken() {
|
||||
logger.info('Starting to refresh access token');
|
||||
try {
|
||||
@@ -175,6 +307,8 @@ export default class AuthCtr extends ControllerModule {
|
||||
logger.info('Token refresh successful via AuthCtr call.');
|
||||
// Notify render process that token has been refreshed
|
||||
this.broadcastTokenRefreshed();
|
||||
// Restart auto-refresh timer with new expiration time
|
||||
this.startAutoRefresh();
|
||||
return { success: true };
|
||||
} else {
|
||||
// Throw an error to be caught by the catch block below
|
||||
@@ -188,6 +322,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
|
||||
// Refresh failed, clear tokens and disable remote server
|
||||
logger.warn('Refresh failed, clearing tokens and disabling remote server');
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
|
||||
@@ -198,48 +333,15 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom protocol handler
|
||||
*/
|
||||
private registerProtocolHandler() {
|
||||
logger.info(`Registering custom protocol handler ${protocolPrefix}://`);
|
||||
app.setAsDefaultProtocolClient(protocolPrefix);
|
||||
|
||||
// Register custom protocol handler
|
||||
if (process.platform === 'darwin') {
|
||||
// Handle open-url event on macOS
|
||||
logger.debug('Registering open-url event handler for macOS');
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
logger.info(`Received open-url event: ${url}`);
|
||||
this.handleAuthCallback(url);
|
||||
});
|
||||
} else {
|
||||
// Handle protocol callback via second-instance event on Windows and Linux
|
||||
logger.debug('Registering second-instance event handler for Windows/Linux');
|
||||
app.on('second-instance', async (event, commandLine) => {
|
||||
// Find the URL from command line arguments
|
||||
const url = commandLine.find((arg) => arg.startsWith(`${protocolPrefix}://`));
|
||||
if (url) {
|
||||
logger.info(`Found URL from second-instance command line arguments: ${url}`);
|
||||
const { success } = await this.handleAuthCallback(url);
|
||||
if (success) {
|
||||
this.app.browserManager.getMainWindow().show();
|
||||
}
|
||||
} else {
|
||||
logger.warn('Protocol URL not found in second-instance command line arguments');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Registered ${protocolPrefix}:// custom protocol handler`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for token
|
||||
*/
|
||||
private async exchangeCodeForToken(code: string, codeVerifier: string) {
|
||||
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
|
||||
if (!this.cachedRemoteUrl) {
|
||||
throw new Error('No cached remote URL available for token exchange');
|
||||
}
|
||||
|
||||
const remoteUrl = this.cachedRemoteUrl;
|
||||
logger.info('Starting to exchange authorization code for token');
|
||||
try {
|
||||
const tokenUrl = new URL('/oidc/token', remoteUrl);
|
||||
@@ -251,7 +353,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${protocolPrefix}://auth/callback`,
|
||||
redirect_uri: this.constructRedirectUri(remoteUrl),
|
||||
});
|
||||
|
||||
logger.debug('Sending token exchange request');
|
||||
@@ -272,10 +374,20 @@ export default class AuthCtr extends ControllerModule {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
let data;
|
||||
|
||||
// Parse response
|
||||
const data = await response.json();
|
||||
try {
|
||||
data = await response.clone().json();
|
||||
} catch {
|
||||
const status = response.status;
|
||||
|
||||
throw new Error(
|
||||
`Parse JSON failed, please check your server, response status: ${status}, detail:\n\n ${await response.text()} `,
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('Successfully received token exchange response');
|
||||
// console.log(data); // Keep original log for debugging, or remove/change to logger.debug as needed
|
||||
|
||||
// Ensure response contains necessary fields
|
||||
if (!data.access_token || !data.refresh_token) {
|
||||
@@ -285,13 +397,20 @@ export default class AuthCtr extends ControllerModule {
|
||||
|
||||
// Save tokens
|
||||
logger.debug('Starting to save exchanged tokens');
|
||||
await this.remoteServerConfigCtr.saveTokens(data.access_token, data.refresh_token);
|
||||
await this.remoteServerConfigCtr.saveTokens(
|
||||
data.access_token,
|
||||
data.refresh_token,
|
||||
data.expires_in,
|
||||
);
|
||||
logger.info('Successfully saved exchanged tokens');
|
||||
|
||||
// Set server to active state
|
||||
logger.debug(`Setting remote server to active state: ${remoteUrl}`);
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: true });
|
||||
|
||||
// Start auto-refresh timer
|
||||
this.startAutoRefresh();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Exchanging authorization code failed:', error);
|
||||
@@ -390,4 +509,84 @@ export default class AuthCtr extends ControllerModule {
|
||||
logger.debug('Generated code challenge (partial): ' + challenge.slice(0, 10) + '...'); // Avoid logging full sensitive info
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动后初始化
|
||||
*/
|
||||
afterAppReady() {
|
||||
logger.debug('AuthCtr initialized, checking for existing tokens');
|
||||
this.initializeAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
*/
|
||||
cleanup() {
|
||||
logger.debug('Cleaning up AuthCtr timers');
|
||||
this.stopPolling();
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动刷新功能
|
||||
* 在应用启动时检查是否有有效的 token,如果有就启动自动刷新定时器
|
||||
*/
|
||||
private async initializeAutoRefresh() {
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// 检查是否配置了远程服务器且处于活动状态
|
||||
if (!config.active || !config.remoteServerUrl) {
|
||||
logger.debug(
|
||||
'Remote server not active or configured, skipping auto-refresh initialization',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有有效的访问令牌
|
||||
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!accessToken) {
|
||||
logger.debug('No access token found, skipping auto-refresh initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有过期时间信息
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
if (!expiresAt) {
|
||||
logger.debug('No token expiration time found, skipping auto-refresh initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 token 是否已经过期
|
||||
const currentTime = Date.now();
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
|
||||
// 尝试刷新 token
|
||||
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (refreshResult.success) {
|
||||
logger.info('Token refresh successful during initialization');
|
||||
this.broadcastTokenRefreshed();
|
||||
// 重新启动自动刷新定时器
|
||||
this.startAutoRefresh();
|
||||
return;
|
||||
} else {
|
||||
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
|
||||
// 只有在刷新失败时才清除 token 并要求重新授权
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新定时器
|
||||
logger.info(
|
||||
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
);
|
||||
this.startAutoRefresh();
|
||||
} catch (error) {
|
||||
logger.error('Error during auto-refresh initialization:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import { ControllerModule, ipcClientEvent, shortcut } from './index';
|
||||
|
||||
export default class BrowserWindowsCtr extends ControllerModule {
|
||||
@shortcut('toggleMainWindow')
|
||||
@shortcut('showApp')
|
||||
async toggleMainWindow() {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.toggleVisible();
|
||||
|
||||
@@ -135,7 +135,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
/**
|
||||
* 应用初始代理设置
|
||||
*/
|
||||
async afterAppReady(): Promise<void> {
|
||||
async beforeAppReady(): Promise<void> {
|
||||
try {
|
||||
// 获取存储的代理设置
|
||||
const networkProxy = this.app.storeManager.get(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
DesktopNotificationResult,
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { Notification, app } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
const logger = createLogger('controllers:NotificationCtr');
|
||||
|
||||
export default class NotificationCtr extends ControllerModule {
|
||||
/**
|
||||
* 在应用准备就绪后设置桌面通知
|
||||
*/
|
||||
afterAppReady() {
|
||||
this.setupNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置桌面通知权限和配置
|
||||
*/
|
||||
private setupNotifications() {
|
||||
logger.debug('Setting up desktop notifications');
|
||||
|
||||
try {
|
||||
// 检查通知支持
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('Desktop notifications are not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
// 在 macOS 上,我们可能需要显式请求通知权限
|
||||
if (macOS()) {
|
||||
logger.debug('macOS detected, notification permissions should be handled by system');
|
||||
}
|
||||
|
||||
// 在 Windows 上设置应用用户模型 ID
|
||||
if (windows()) {
|
||||
app.setAppUserModelId('com.lobehub.chat');
|
||||
logger.debug('Set Windows App User Model ID for notifications');
|
||||
}
|
||||
|
||||
logger.info('Desktop notifications setup completed');
|
||||
} catch (error) {
|
||||
logger.error('Failed to setup desktop notifications:', error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 显示系统桌面通知(仅当窗口隐藏时)
|
||||
*/
|
||||
@ipcClientEvent('showDesktopNotification')
|
||||
async showDesktopNotification(
|
||||
params: ShowDesktopNotificationParams,
|
||||
): Promise<DesktopNotificationResult> {
|
||||
logger.debug('收到桌面通知请求:', params);
|
||||
|
||||
try {
|
||||
// 检查通知支持
|
||||
if (!Notification.isSupported()) {
|
||||
logger.warn('系统不支持桌面通知');
|
||||
return { error: 'Desktop notifications not supported', success: false };
|
||||
}
|
||||
|
||||
// 检查窗口是否隐藏
|
||||
const isWindowHidden = this.isMainWindowHidden();
|
||||
|
||||
if (!isWindowHidden) {
|
||||
logger.debug('主窗口可见,跳过桌面通知');
|
||||
return { reason: 'Window is visible', skipped: true, success: true };
|
||||
}
|
||||
|
||||
logger.info('窗口已隐藏,显示桌面通知:', params.title);
|
||||
|
||||
const notification = new Notification({
|
||||
body: params.body,
|
||||
// 添加更多配置以确保通知能正常显示
|
||||
hasReply: false,
|
||||
silent: params.silent || false,
|
||||
timeoutType: 'default',
|
||||
title: params.title,
|
||||
urgency: 'normal',
|
||||
});
|
||||
|
||||
// 添加更多事件监听来调试
|
||||
notification.on('show', () => {
|
||||
logger.info('通知已显示');
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
logger.debug('用户点击通知,显示主窗口');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
logger.debug('通知已关闭');
|
||||
});
|
||||
|
||||
notification.on('failed', (error) => {
|
||||
logger.error('通知显示失败:', error);
|
||||
});
|
||||
|
||||
// 使用 Promise 来确保通知显示
|
||||
return new Promise((resolve) => {
|
||||
notification.show();
|
||||
|
||||
// 给通知一些时间来显示,然后检查结果
|
||||
setTimeout(() => {
|
||||
logger.info('通知显示调用完成');
|
||||
resolve({ success: true });
|
||||
}, 100);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('显示桌面通知失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查主窗口是否隐藏
|
||||
*/
|
||||
@ipcClientEvent('isMainWindowHidden')
|
||||
isMainWindowHidden(): boolean {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const browserWindow = mainWindow.browserWindow;
|
||||
|
||||
// 如果窗口被销毁,认为是隐藏的
|
||||
if (browserWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查窗口是否可见和聚焦
|
||||
const isVisible = browserWindow.isVisible();
|
||||
const isFocused = browserWindow.isFocused();
|
||||
const isMinimized = browserWindow.isMinimized();
|
||||
|
||||
logger.debug('窗口状态检查:', { isFocused, isMinimized, isVisible });
|
||||
|
||||
// 窗口隐藏的条件:不可见或最小化或失去焦点
|
||||
return !isVisible || isMinimized || !isFocused;
|
||||
} catch (error) {
|
||||
logger.error('检查窗口状态失败:', error);
|
||||
return true; // 发生错误时认为窗口隐藏,确保通知能显示
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,12 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
private encryptedAccessToken?: string;
|
||||
private encryptedRefreshToken?: string;
|
||||
|
||||
/**
|
||||
* Token expiration time (timestamp in milliseconds)
|
||||
* Used for automatic token refresh
|
||||
*/
|
||||
private tokenExpiresAt?: number;
|
||||
|
||||
/**
|
||||
* Promise representing the ongoing token refresh operation.
|
||||
* Used to prevent concurrent refreshes and allow callers to wait.
|
||||
@@ -89,10 +95,19 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
* Encrypt and store tokens
|
||||
* @param accessToken Access token
|
||||
* @param refreshToken Refresh token
|
||||
* @param expiresIn Token expiration time in seconds (optional)
|
||||
*/
|
||||
async saveTokens(accessToken: string, refreshToken: string) {
|
||||
async saveTokens(accessToken: string, refreshToken: string, expiresIn?: number) {
|
||||
logger.info('Saving encrypted tokens');
|
||||
|
||||
// Calculate expiration time if provided
|
||||
if (expiresIn) {
|
||||
this.tokenExpiresAt = Date.now() + expiresIn * 1000;
|
||||
logger.debug(`Token expires at: ${new Date(this.tokenExpiresAt).toISOString()}`);
|
||||
} else {
|
||||
this.tokenExpiresAt = undefined;
|
||||
}
|
||||
|
||||
// If platform doesn't support secure storage, store raw tokens
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
logger.warn('Safe storage not available, storing tokens unencrypted');
|
||||
@@ -101,6 +116,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
// Persist unencrypted tokens (consider security implications)
|
||||
this.app.storeManager.set(this.encryptedTokensKey, {
|
||||
accessToken: this.encryptedAccessToken,
|
||||
expiresAt: this.tokenExpiresAt,
|
||||
refreshToken: this.encryptedRefreshToken,
|
||||
});
|
||||
return;
|
||||
@@ -120,6 +136,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
logger.debug(`Persisting encrypted tokens to store key: ${this.encryptedTokensKey}`);
|
||||
this.app.storeManager.set(this.encryptedTokensKey, {
|
||||
accessToken: this.encryptedAccessToken,
|
||||
expiresAt: this.tokenExpiresAt,
|
||||
refreshToken: this.encryptedRefreshToken,
|
||||
});
|
||||
}
|
||||
@@ -199,17 +216,40 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
logger.info('Clearing access and refresh tokens');
|
||||
this.encryptedAccessToken = undefined;
|
||||
this.encryptedRefreshToken = undefined;
|
||||
this.tokenExpiresAt = undefined;
|
||||
// Also clear from persistent storage
|
||||
logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`);
|
||||
this.app.storeManager.delete(this.encryptedTokensKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration time
|
||||
*/
|
||||
getTokenExpiresAt(): number | undefined {
|
||||
return this.tokenExpiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired or will expire soon
|
||||
* @param bufferTimeMs Buffer time in milliseconds (default 5 minutes)
|
||||
* @returns true if token is expired or will expire soon
|
||||
*/
|
||||
isTokenExpiringSoon(bufferTimeMs: number = 5 * 60 * 1000): boolean {
|
||||
if (!this.tokenExpiresAt) {
|
||||
return false; // No expiration time available
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const bufferTime = this.tokenExpiresAt - bufferTimeMs;
|
||||
|
||||
return currentTime >= bufferTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
* 使用存储的刷新令牌获取新的访问令牌
|
||||
* Handles concurrent requests by returning the existing refresh promise if one is in progress.
|
||||
*/
|
||||
@ipcClientEvent('refreshAccessToken')
|
||||
async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
|
||||
// If a refresh is already in progress, return the existing promise
|
||||
if (this.refreshPromise) {
|
||||
@@ -290,7 +330,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
|
||||
// 保存新令牌
|
||||
logger.info('Token refresh successful, saving new tokens.');
|
||||
await this.saveTokens(data.access_token, data.refresh_token);
|
||||
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -316,6 +356,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
logger.info('Successfully loaded tokens from store into memory.');
|
||||
this.encryptedAccessToken = storedTokens.accessToken;
|
||||
this.encryptedRefreshToken = storedTokens.refreshToken;
|
||||
this.tokenExpiresAt = storedTokens.expiresAt;
|
||||
|
||||
if (this.tokenExpiresAt) {
|
||||
logger.debug(
|
||||
`Loaded token expiration time: ${new Date(this.tokenExpiresAt).toISOString()}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.debug('No valid tokens found in store.');
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
ProxyTRPCRequestParams,
|
||||
ProxyTRPCRequestResult,
|
||||
} from '@lobechat/electron-client-ipc/src/types/proxyTRPCRequest';
|
||||
ProxyTRPCStreamRequestParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { IpcMainEvent, WebContents, ipcMain } from 'electron';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import http, { IncomingMessage, OutgoingHttpHeaders } from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import { defaultProxySettings } from '@/const/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
@@ -41,6 +46,137 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
afterAppReady() {
|
||||
logger.info('RemoteServerSyncCtr initialized (IPC based)');
|
||||
// No need to register protocol handler anymore
|
||||
ipcMain.on('stream:start', this.handleStreamRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理流式请求的 IPC 调用
|
||||
*/
|
||||
private handleStreamRequest = async (event: IpcMainEvent, args: ProxyTRPCStreamRequestParams) => {
|
||||
const { requestId } = args;
|
||||
const logPrefix = `[StreamProxy ${args.method} ${args.urlPath}][${requestId}]`;
|
||||
logger.debug(`${logPrefix} Received stream:start IPC call`);
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
|
||||
logger.warn(`${logPrefix} Remote server sync not active or configured.`);
|
||||
event.sender.send(
|
||||
`stream:error:${requestId}`,
|
||||
new Error('Remote server sync not active or configured'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
|
||||
const token = await this.remoteServerConfigCtr.getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
// 401 Unauthorized
|
||||
event.sender.send(`stream:response:${requestId}`, {
|
||||
headers: {},
|
||||
status: 401,
|
||||
statusText: 'Authentication required, missing token',
|
||||
});
|
||||
event.sender.send(`stream:end:${requestId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用新的流式转发方法
|
||||
await this.forwardStreamRequest(event.sender, {
|
||||
...args,
|
||||
accessToken: token,
|
||||
remoteServerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Unhandled error processing stream request:`, error);
|
||||
event.sender.send(
|
||||
`stream:error:${requestId}`,
|
||||
error instanceof Error ? error : new Error('Unknown error'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行实际的流式请求转发
|
||||
*/
|
||||
private async forwardStreamRequest(
|
||||
sender: WebContents,
|
||||
args: ProxyTRPCStreamRequestParams & { accessToken: string; remoteServerUrl: string },
|
||||
) {
|
||||
const {
|
||||
urlPath,
|
||||
method,
|
||||
headers: originalHeaders,
|
||||
body: requestBody,
|
||||
accessToken,
|
||||
remoteServerUrl,
|
||||
requestId,
|
||||
} = args;
|
||||
const targetUrl = new URL(urlPath, remoteServerUrl);
|
||||
const logPrefix = `[ForwardStream ${method} ${targetUrl.pathname}][${requestId}]`;
|
||||
|
||||
const { requestOptions, requester } = this.createRequester({
|
||||
accessToken,
|
||||
headers: originalHeaders,
|
||||
method,
|
||||
url: targetUrl,
|
||||
});
|
||||
|
||||
const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => {
|
||||
logger.debug(`${logPrefix} Received response with status ${clientRes.statusCode}`);
|
||||
|
||||
// 添加调试信息
|
||||
logger.debug(`${logPrefix} Response details:`, {
|
||||
headers: clientRes.headers,
|
||||
statusCode: clientRes.statusCode,
|
||||
statusMessage: clientRes.statusMessage,
|
||||
});
|
||||
|
||||
// 1. 立刻发送响应头和状态码
|
||||
const responseData = {
|
||||
headers: clientRes.headers || {},
|
||||
status: clientRes.statusCode || 500,
|
||||
statusText: clientRes.statusMessage || 'Unknown Status',
|
||||
};
|
||||
|
||||
logger.debug(`${logPrefix} Sending response data:`, responseData);
|
||||
sender.send(`stream:response:${requestId}`, responseData);
|
||||
|
||||
// 2. 监听数据块并转发
|
||||
clientRes.on('data', (chunk: Buffer) => {
|
||||
if (sender.isDestroyed()) return;
|
||||
logger.debug(`${logPrefix} Received data chunk, size: ${chunk.length}. Forwarding...`);
|
||||
sender.send(`stream:data:${requestId}`, chunk);
|
||||
});
|
||||
|
||||
// 3. 监听结束信号并转发
|
||||
clientRes.on('end', () => {
|
||||
logger.debug(`${logPrefix} Stream ended. Forwarding end signal...`);
|
||||
if (sender.isDestroyed()) return;
|
||||
sender.send(`stream:end:${requestId}`);
|
||||
});
|
||||
|
||||
// 4. 监听响应流错误并转发
|
||||
clientRes.on('error', (error) => {
|
||||
logger.error(`${logPrefix} Error reading response stream:`, error);
|
||||
if (sender.isDestroyed()) return;
|
||||
sender.send(`stream:error:${requestId}`, error);
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 监听请求本身的错误(如 DNS 解析失败)
|
||||
clientReq.on('error', (error) => {
|
||||
logger.error(`${logPrefix} Error forwarding request:`, error);
|
||||
if (sender.isDestroyed()) return;
|
||||
sender.send(`stream:error:${requestId}`, error);
|
||||
});
|
||||
|
||||
if (requestBody) {
|
||||
clientReq.write(Buffer.from(requestBody));
|
||||
}
|
||||
|
||||
clientReq.end();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,28 +221,12 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
|
||||
// 1. Determine target URL and prepare request options
|
||||
const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path
|
||||
|
||||
// Prepare headers, cloning and adding Authorization
|
||||
const requestHeaders: OutgoingHttpHeaders = { ...originalHeaders }; // Use OutgoingHttpHeaders
|
||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
|
||||
// Let node handle Host, Content-Length etc. Remove potentially problematic headers
|
||||
delete requestHeaders['host'];
|
||||
delete requestHeaders['connection']; // Often causes issues
|
||||
// delete requestHeaders['content-length']; // Let node handle it based on body
|
||||
|
||||
const requestOptions: https.RequestOptions | http.RequestOptions = {
|
||||
// Use union type
|
||||
headers: requestHeaders,
|
||||
hostname: targetUrl.hostname,
|
||||
method: method,
|
||||
path: targetUrl.pathname + targetUrl.search,
|
||||
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
|
||||
protocol: targetUrl.protocol,
|
||||
// agent: false, // Consider for keep-alive issues if they arise
|
||||
};
|
||||
|
||||
const requester = targetUrl.protocol === 'https:' ? https : http;
|
||||
const { requestOptions, requester } = this.createRequester({
|
||||
accessToken,
|
||||
headers: originalHeaders,
|
||||
method,
|
||||
url: targetUrl,
|
||||
});
|
||||
|
||||
// 2. Make the request and capture response
|
||||
return new Promise((resolve) => {
|
||||
@@ -176,6 +296,51 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
});
|
||||
}
|
||||
|
||||
private createRequester({
|
||||
headers,
|
||||
accessToken,
|
||||
method,
|
||||
url,
|
||||
}: {
|
||||
accessToken: string;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
url: URL;
|
||||
}) {
|
||||
// Prepare headers, cloning and adding Oidc-Auth
|
||||
const requestHeaders: OutgoingHttpHeaders = { ...headers }; // Use OutgoingHttpHeaders
|
||||
requestHeaders['Oidc-Auth'] = accessToken;
|
||||
|
||||
// Let node handle Host, Content-Length etc. Remove potentially problematic headers
|
||||
delete requestHeaders['host'];
|
||||
delete requestHeaders['connection']; // Often causes issues
|
||||
// delete requestHeaders['content-length']; // Let node handle it based on body
|
||||
|
||||
// 读取代理配置
|
||||
const proxyConfig = this.app.storeManager.get('networkProxy', defaultProxySettings);
|
||||
|
||||
let agent;
|
||||
if (proxyConfig?.enableProxy && proxyConfig.proxyServer) {
|
||||
const proxyUrl = `${proxyConfig.proxyType}://${proxyConfig.proxyServer}${proxyConfig.proxyPort ? `:${proxyConfig.proxyPort}` : ''}`;
|
||||
agent =
|
||||
url.protocol === 'https:' ? new HttpsProxyAgent(proxyUrl) : new HttpProxyAgent(proxyUrl);
|
||||
}
|
||||
|
||||
const requestOptions: https.RequestOptions | http.RequestOptions = {
|
||||
agent,
|
||||
// Use union type
|
||||
headers: requestHeaders,
|
||||
hostname: url.hostname,
|
||||
method: method,
|
||||
path: url.pathname + url.search,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
protocol: url.protocol,
|
||||
};
|
||||
|
||||
const requester = url.protocol === 'https:' ? https : http;
|
||||
return { requestOptions, requester };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the 'proxy-trpc-request' IPC call from the renderer process.
|
||||
* This method should be invoked by the ipcMain.handle setup in your main process entry point.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ShortcutUpdateResult } from '@/core/ui/ShortcutManager';
|
||||
|
||||
import { ControllerModule, ipcClientEvent } from '.';
|
||||
|
||||
export default class ShortcutController extends ControllerModule {
|
||||
@@ -13,7 +15,13 @@ export default class ShortcutController extends ControllerModule {
|
||||
* 更新单个快捷键配置
|
||||
*/
|
||||
@ipcClientEvent('updateShortcutConfig')
|
||||
updateShortcutConfig(id: string, accelerator: string): boolean {
|
||||
updateShortcutConfig({
|
||||
id,
|
||||
accelerator,
|
||||
}: {
|
||||
accelerator: string;
|
||||
id: string;
|
||||
}): ShortcutUpdateResult {
|
||||
return this.app.shortcutManager.updateShortcutConfig(id, accelerator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,11 @@ export default class SystemController extends ControllerModule {
|
||||
|
||||
@ipcClientEvent('updateThemeMode')
|
||||
async updateThemeModeHandler(themeMode: ThemeMode) {
|
||||
this.app.storeManager.set('themeMode', themeMode);
|
||||
this.app.browserManager.broadcastToAllWindows('themeChanged', { themeMode });
|
||||
|
||||
// Apply visual effects to all browser windows when theme mode changes
|
||||
this.app.browserManager.handleAppThemeChange();
|
||||
}
|
||||
|
||||
@ipcServerEvent('getDatabasePath')
|
||||
|
||||
@@ -6,16 +6,12 @@ import {
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, ipcClientEvent, shortcut } from './index';
|
||||
import { ControllerModule, ipcClientEvent } from './index';
|
||||
|
||||
// 创建日志记录器
|
||||
const logger = createLogger('controllers:TrayMenuCtr');
|
||||
|
||||
export default class TrayMenuCtr extends ControllerModule {
|
||||
/**
|
||||
* 使用快捷键切换窗口可见性
|
||||
*/
|
||||
@shortcut('toggleMainWindow')
|
||||
async toggleMainWindow() {
|
||||
logger.debug('通过快捷键切换主窗口可见性');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
@@ -47,7 +43,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
|
||||
return {
|
||||
error: '托盘通知仅在 Windows 平台支持',
|
||||
success: false
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +67,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
logger.error('更新托盘图标失败:', error);
|
||||
return {
|
||||
error: String(error),
|
||||
success: false
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -79,7 +75,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
|
||||
return {
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
success: false
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,7 +99,7 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
|
||||
return {
|
||||
error: '托盘功能仅在 Windows 平台支持',
|
||||
success: false
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,12 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// 模拟 undici
|
||||
// 模拟 undici - 使用 vi.fn() 直接在 Mock 中创建
|
||||
vi.mock('undici', () => ({
|
||||
fetch: vi.fn(),
|
||||
getGlobalDispatcher: vi.fn(),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
Agent: vi.fn(),
|
||||
ProxyAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -35,9 +36,6 @@ vi.mock('@/const/store', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// 模拟 fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockStoreManager = {
|
||||
get: vi.fn(),
|
||||
@@ -51,12 +49,31 @@ const mockApp = {
|
||||
describe('NetworkProxyCtr', () => {
|
||||
let networkProxyCtr: NetworkProxyCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
// 动态导入 undici 的 Mock
|
||||
let mockUndici: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// 动态导入 undici Mock
|
||||
mockUndici = await import('undici');
|
||||
|
||||
networkProxyCtr = new NetworkProxyCtr(mockApp);
|
||||
|
||||
// 重置全局 fetch mock
|
||||
(global.fetch as any).mockReset();
|
||||
// 设置 undici mocks 的默认返回值
|
||||
vi.mocked(mockUndici.Agent).mockReturnValue({});
|
||||
vi.mocked(mockUndici.ProxyAgent).mockReturnValue({});
|
||||
vi.mocked(mockUndici.getGlobalDispatcher).mockReturnValue({
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
vi.mocked(mockUndici.setGlobalDispatcher).mockReturnValue(undefined);
|
||||
|
||||
// 设置 fetch mock 的默认返回值
|
||||
vi.mocked(mockUndici.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxyConfigValidator', () => {
|
||||
@@ -213,12 +230,12 @@ describe('NetworkProxyCtr', () => {
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce(mockResponse);
|
||||
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await networkProxyCtr.testProxyConnection('https://www.google.com');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://www.google.com', expect.any(Object));
|
||||
expect(mockUndici.fetch).toHaveBeenCalledWith('https://www.google.com', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should throw error for failed connection', async () => {
|
||||
@@ -228,13 +245,13 @@ describe('NetworkProxyCtr', () => {
|
||||
statusText: 'Not Found',
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce(mockResponse);
|
||||
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for network error', async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
|
||||
});
|
||||
@@ -257,7 +274,7 @@ describe('NetworkProxyCtr', () => {
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce(mockResponse);
|
||||
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
|
||||
|
||||
@@ -289,7 +306,7 @@ describe('NetworkProxyCtr', () => {
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce(mockResponse);
|
||||
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await networkProxyCtr.testProxyConfig({ config: disabledConfig });
|
||||
|
||||
@@ -297,7 +314,7 @@ describe('NetworkProxyCtr', () => {
|
||||
});
|
||||
|
||||
it('should return failure for connection error', async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
|
||||
|
||||
@@ -306,7 +323,7 @@ describe('NetworkProxyCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady', () => {
|
||||
describe('beforeAppReady', () => {
|
||||
it('should apply stored proxy settings on app ready', async () => {
|
||||
const storedConfig: NetworkProxySettings = {
|
||||
enableProxy: true,
|
||||
@@ -319,7 +336,7 @@ describe('NetworkProxyCtr', () => {
|
||||
|
||||
mockStoreManager.get.mockReturnValue(storedConfig);
|
||||
|
||||
await networkProxyCtr.afterAppReady();
|
||||
await networkProxyCtr.beforeAppReady();
|
||||
|
||||
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
|
||||
});
|
||||
@@ -336,7 +353,7 @@ describe('NetworkProxyCtr', () => {
|
||||
|
||||
mockStoreManager.get.mockReturnValue(invalidConfig);
|
||||
|
||||
await networkProxyCtr.afterAppReady();
|
||||
await networkProxyCtr.beforeAppReady();
|
||||
|
||||
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
|
||||
});
|
||||
@@ -347,7 +364,9 @@ describe('NetworkProxyCtr', () => {
|
||||
});
|
||||
|
||||
// 不应该抛出错误
|
||||
await expect(networkProxyCtr.afterAppReady()).resolves.not.toThrow();
|
||||
await expect(networkProxyCtr.beforeAppReady()).resolves.not.toThrow();
|
||||
|
||||
mockStoreManager.get.mockReset();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { App } from '@/core/App';
|
||||
import ShortcutController from '../ShortcutCtr';
|
||||
|
||||
// 模拟 App 及其依赖项
|
||||
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
|
||||
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
|
||||
toggleMainWindow: 'CommandOrControl+Shift+L',
|
||||
openSettings: 'CommandOrControl+,'
|
||||
openSettings: 'CommandOrControl+,',
|
||||
});
|
||||
const mockUpdateShortcutConfig = vi.fn().mockImplementation((id, accelerator) => {
|
||||
// 简单模拟更新成功
|
||||
@@ -32,11 +32,11 @@ describe('ShortcutController', () => {
|
||||
describe('getShortcutsConfig', () => {
|
||||
it('should return shortcuts config from shortcutManager', () => {
|
||||
const result = shortcutController.getShortcutsConfig();
|
||||
|
||||
|
||||
expect(mockGetShortcutsConfig).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
toggleMainWindow: 'CommandOrControl+Shift+L',
|
||||
openSettings: 'CommandOrControl+,'
|
||||
openSettings: 'CommandOrControl+,',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,9 +45,9 @@ describe('ShortcutController', () => {
|
||||
it('should call shortcutManager.updateShortcutConfig with correct parameters', () => {
|
||||
const id = 'toggleMainWindow';
|
||||
const accelerator = 'CommandOrControl+Alt+L';
|
||||
|
||||
const result = shortcutController.updateShortcutConfig(id, accelerator);
|
||||
|
||||
|
||||
const result = shortcutController.updateShortcutConfig({ id, accelerator });
|
||||
|
||||
expect(mockUpdateShortcutConfig).toHaveBeenCalledWith(id, accelerator);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -55,10 +55,13 @@ describe('ShortcutController', () => {
|
||||
it('should return the result from shortcutManager.updateShortcutConfig', () => {
|
||||
// 模拟更新失败的情况
|
||||
mockUpdateShortcutConfig.mockReturnValueOnce(false);
|
||||
|
||||
const result = shortcutController.updateShortcutConfig('invalidKey', 'invalid+combo');
|
||||
|
||||
|
||||
const result = shortcutController.updateShortcutConfig({
|
||||
id: 'invalidKey',
|
||||
accelerator: 'invalid+combo',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc';
|
||||
import type { ServerDispatchEvents } from '@lobechat/electron-server-ipc';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IoCContainer } from '@/core/IoCContainer';
|
||||
import { IoCContainer } from '@/core/infrastructure/IoCContainer';
|
||||
import { ShortcutActionType } from '@/shortcuts';
|
||||
|
||||
const ipcDecorator =
|
||||
|
||||
@@ -9,20 +9,19 @@ import { buildDir, nextStandaloneDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import { IServiceModule } from '@/services';
|
||||
import FileService from '@/services/fileSrv';
|
||||
import { IpcClientEventSender } from '@/types/ipcClientEvent';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
|
||||
|
||||
import BrowserManager from './BrowserManager';
|
||||
import { I18nManager } from './I18nManager';
|
||||
import { IoCContainer } from './IoCContainer';
|
||||
import MenuManager from './MenuManager';
|
||||
import { ShortcutManager } from './ShortcutManager';
|
||||
import { StaticFileServerManager } from './StaticFileServerManager';
|
||||
import { StoreManager } from './StoreManager';
|
||||
import TrayManager from './TrayManager';
|
||||
import { UpdaterManager } from './UpdaterManager';
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
import { StoreManager } from './infrastructure/StoreManager';
|
||||
import { UpdaterManager } from './infrastructure/UpdaterManager';
|
||||
import { MenuManager } from './ui/MenuManager';
|
||||
import { ShortcutManager } from './ui/ShortcutManager';
|
||||
import { TrayManager } from './ui/TrayManager';
|
||||
|
||||
const logger = createLogger('core:App');
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { globalShortcut } from 'electron';
|
||||
|
||||
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from './App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:ShortcutManager');
|
||||
|
||||
export class ShortcutManager {
|
||||
private app: App;
|
||||
private shortcuts: Map<string, () => void> = new Map();
|
||||
private shortcutsConfig: Record<string, string> = {};
|
||||
|
||||
constructor(app: App) {
|
||||
logger.debug('Initializing ShortcutManager');
|
||||
this.app = app;
|
||||
|
||||
app.shortcutMethodMap.forEach((method, key) => {
|
||||
this.shortcuts.set(key, method);
|
||||
});
|
||||
}
|
||||
|
||||
initialize() {
|
||||
logger.info('Initializing global shortcuts');
|
||||
// Load shortcuts configuration from storage
|
||||
this.loadShortcutsConfig();
|
||||
// Register configured shortcuts
|
||||
this.registerConfiguredShortcuts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shortcuts configuration
|
||||
*/
|
||||
getShortcutsConfig(): Record<string, string> {
|
||||
return this.shortcutsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single shortcut configuration
|
||||
*/
|
||||
updateShortcutConfig(id: string, accelerator: string): boolean {
|
||||
try {
|
||||
logger.debug(`Updating shortcut ${id} to ${accelerator}`);
|
||||
// Update configuration
|
||||
this.shortcutsConfig[id] = accelerator;
|
||||
|
||||
this.saveShortcutsConfig();
|
||||
this.registerConfiguredShortcuts();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Error updating shortcut ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register global shortcut
|
||||
* @param accelerator Shortcut key combination
|
||||
* @param callback Callback function
|
||||
* @returns Whether registration was successful
|
||||
*/
|
||||
registerShortcut(accelerator: string, callback: () => void): boolean {
|
||||
try {
|
||||
// If already registered, unregister first
|
||||
if (this.shortcuts.has(accelerator)) {
|
||||
this.unregisterShortcut(accelerator);
|
||||
}
|
||||
|
||||
// Register new shortcut
|
||||
const success = globalShortcut.register(accelerator, callback);
|
||||
|
||||
if (success) {
|
||||
this.shortcuts.set(accelerator, callback);
|
||||
logger.debug(`Registered shortcut: ${accelerator}`);
|
||||
} else {
|
||||
logger.error(`Failed to register shortcut: ${accelerator}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
logger.error(`Error registering shortcut: ${accelerator}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister global shortcut
|
||||
* @param accelerator Shortcut key combination
|
||||
*/
|
||||
unregisterShortcut(accelerator: string): void {
|
||||
try {
|
||||
globalShortcut.unregister(accelerator);
|
||||
this.shortcuts.delete(accelerator);
|
||||
logger.debug(`Unregistered shortcut: ${accelerator}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error unregistering shortcut: ${accelerator}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shortcut is already registered
|
||||
* @param accelerator Shortcut key combination
|
||||
* @returns Whether it is registered
|
||||
*/
|
||||
isRegistered(accelerator: string): boolean {
|
||||
return globalShortcut.isRegistered(accelerator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister all shortcuts
|
||||
*/
|
||||
unregisterAll(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
logger.info('Unregistered all shortcuts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load shortcuts configuration from storage
|
||||
*/
|
||||
private loadShortcutsConfig() {
|
||||
try {
|
||||
// Try to get configuration from storage
|
||||
const config = this.app.storeManager.get('shortcuts');
|
||||
|
||||
// If no configuration, use default configuration
|
||||
if (!config || Object.keys(config).length === 0) {
|
||||
logger.debug('No shortcuts config found, using defaults');
|
||||
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
|
||||
this.saveShortcutsConfig();
|
||||
} else {
|
||||
this.shortcutsConfig = config;
|
||||
}
|
||||
|
||||
logger.debug('Loaded shortcuts config:', this.shortcutsConfig);
|
||||
} catch (error) {
|
||||
logger.error('Error loading shortcuts config:', error);
|
||||
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
|
||||
this.saveShortcutsConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save shortcuts configuration to storage
|
||||
*/
|
||||
private saveShortcutsConfig() {
|
||||
try {
|
||||
this.app.storeManager.set('shortcuts', this.shortcutsConfig);
|
||||
logger.debug('Saved shortcuts config');
|
||||
} catch (error) {
|
||||
logger.error('Error saving shortcuts config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register configured shortcuts
|
||||
*/
|
||||
private registerConfiguredShortcuts() {
|
||||
// Unregister all shortcuts first
|
||||
this.unregisterAll();
|
||||
|
||||
// Register each enabled shortcut
|
||||
Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => {
|
||||
logger.debug(`Registering shortcut '${id}' with ${accelerator}`);
|
||||
|
||||
const method = this.shortcuts.get(id);
|
||||
if (accelerator && method) {
|
||||
this.registerShortcut(accelerator, method);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
nativeTheme,
|
||||
screen,
|
||||
} from 'electron';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isDev, isWindows } from '@/const/env';
|
||||
import {
|
||||
BACKGROUND_DARK,
|
||||
BACKGROUND_LIGHT,
|
||||
SYMBOL_COLOR_DARK,
|
||||
SYMBOL_COLOR_LIGHT,
|
||||
THEME_CHANGE_DELAY,
|
||||
TITLE_BAR_HEIGHT,
|
||||
} from '@/const/theme';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { preloadDir, resourcesDir } from '../const/dir';
|
||||
import type { App } from './App';
|
||||
import type { App } from '../App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:Browser');
|
||||
@@ -20,9 +28,6 @@ const logger = createLogger('core:Browser');
|
||||
export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
devTools?: boolean;
|
||||
height?: number;
|
||||
/**
|
||||
* URL
|
||||
*/
|
||||
identifier: string;
|
||||
keepAlive?: boolean;
|
||||
parentIdentifier?: string;
|
||||
@@ -34,38 +39,18 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
|
||||
export default class Browser {
|
||||
private app: App;
|
||||
|
||||
/**
|
||||
* Internal electron window
|
||||
*/
|
||||
private _browserWindow?: BrowserWindow;
|
||||
|
||||
private themeListenerSetup = false;
|
||||
private stopInterceptHandler;
|
||||
/**
|
||||
* Identifier
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* Options at creation
|
||||
*/
|
||||
options: BrowserWindowOpts;
|
||||
|
||||
/**
|
||||
* Key for storing window state in storeManager
|
||||
*/
|
||||
private readonly windowStateKey: string;
|
||||
|
||||
/**
|
||||
* Method to expose window externally
|
||||
*/
|
||||
get browserWindow() {
|
||||
return this.retrieveOrInitialize();
|
||||
}
|
||||
|
||||
get webContents() {
|
||||
if (this._browserWindow.isDestroyed()) return null;
|
||||
|
||||
return this._browserWindow.webContents;
|
||||
}
|
||||
|
||||
@@ -86,6 +71,101 @@ export default class Browser {
|
||||
this.retrieveOrInitialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific theme configuration for window creation
|
||||
*/
|
||||
private getPlatformThemeConfig(isDarkMode?: boolean): Record<string, any> {
|
||||
const darkMode = isDarkMode ?? nativeTheme.shouldUseDarkColors;
|
||||
|
||||
if (isWindows) {
|
||||
return this.getWindowsThemeConfig(darkMode);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Windows-specific theme configuration
|
||||
*/
|
||||
private getWindowsThemeConfig(isDarkMode: boolean) {
|
||||
return {
|
||||
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
|
||||
titleBarOverlay: {
|
||||
color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
height: TITLE_BAR_HEIGHT,
|
||||
symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
|
||||
},
|
||||
titleBarStyle: 'hidden' as const,
|
||||
};
|
||||
}
|
||||
|
||||
private setupThemeListener(): void {
|
||||
if (this.themeListenerSetup) return;
|
||||
|
||||
nativeTheme.on('updated', this.handleThemeChange);
|
||||
this.themeListenerSetup = true;
|
||||
}
|
||||
|
||||
private handleThemeChange = (): void => {
|
||||
logger.debug(`[${this.identifier}] System theme changed, reapplying visual effects.`);
|
||||
setTimeout(() => {
|
||||
this.applyVisualEffects();
|
||||
}, THEME_CHANGE_DELAY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle application theme mode change (called from BrowserManager)
|
||||
*/
|
||||
handleAppThemeChange = (): void => {
|
||||
logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
|
||||
setTimeout(() => {
|
||||
this.applyVisualEffects();
|
||||
}, THEME_CHANGE_DELAY);
|
||||
};
|
||||
|
||||
private applyVisualEffects(): void {
|
||||
if (!this._browserWindow || this._browserWindow.isDestroyed()) return;
|
||||
|
||||
logger.debug(`[${this.identifier}] Applying visual effects for platform`);
|
||||
const isDarkMode = this.isDarkMode;
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
this.applyWindowsVisualEffects(isDarkMode);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private applyWindowsVisualEffects(isDarkMode: boolean): void {
|
||||
const config = this.getWindowsThemeConfig(isDarkMode);
|
||||
|
||||
this._browserWindow.setBackgroundColor(config.backgroundColor);
|
||||
this._browserWindow.setTitleBarOverlay(config.titleBarOverlay);
|
||||
}
|
||||
|
||||
private cleanupThemeListener(): void {
|
||||
if (this.themeListenerSetup) {
|
||||
// Note: nativeTheme listeners are global, consider using a centralized theme manager
|
||||
nativeTheme.off('updated', this.handleThemeChange);
|
||||
// for multiple windows to avoid duplicate listeners
|
||||
this.themeListenerSetup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private get isDarkMode() {
|
||||
const themeMode = this.app.storeManager.get('themeMode');
|
||||
if (themeMode === 'auto') return nativeTheme.shouldUseDarkColors;
|
||||
|
||||
return themeMode === 'dark';
|
||||
}
|
||||
|
||||
loadUrl = async (path: string) => {
|
||||
const initUrl = this.app.nextServerUrl + path;
|
||||
|
||||
@@ -203,6 +283,7 @@ export default class Browser {
|
||||
destroy() {
|
||||
logger.debug(`Destroying window instance: ${this.identifier}`);
|
||||
this.stopInterceptHandler?.();
|
||||
this.cleanupThemeListener();
|
||||
this._browserWindow = undefined;
|
||||
}
|
||||
|
||||
@@ -228,45 +309,37 @@ export default class Browser {
|
||||
`[${this.identifier}] Saved window state (only size used): ${JSON.stringify(savedState)}`,
|
||||
);
|
||||
|
||||
const { isWindows11, isWindows } = this.getWindowsVersion();
|
||||
const isDarkMode = nativeTheme.shouldUseDarkColors;
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
...res,
|
||||
...(isWindows
|
||||
? {
|
||||
titleBarStyle: 'hidden',
|
||||
}
|
||||
: {}),
|
||||
...(isWindows11
|
||||
? {
|
||||
backgroundMaterial: isDarkMode ? 'mica' : 'acrylic',
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'active',
|
||||
}
|
||||
: {}),
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#00000000',
|
||||
darkTheme: isDarkMode,
|
||||
frame: false,
|
||||
|
||||
height: savedState?.height || height,
|
||||
// Always create hidden first
|
||||
show: false,
|
||||
title,
|
||||
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
webPreferences: {
|
||||
// Context isolation environment
|
||||
// https://www.electronjs.org/docs/tutorial/context-isolation
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
},
|
||||
width: savedState?.width || width,
|
||||
...this.getPlatformThemeConfig(isDarkMode),
|
||||
});
|
||||
|
||||
this._browserWindow = browserWindow;
|
||||
logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
|
||||
|
||||
if (isWindows11) this.applyVisualEffects();
|
||||
// Initialize theme listener for this window to handle theme changes
|
||||
this.setupThemeListener();
|
||||
logger.debug(`[${this.identifier}] Theme listener setup and applying initial visual effects.`);
|
||||
|
||||
// Apply initial visual effects
|
||||
this.applyVisualEffects();
|
||||
|
||||
logger.debug(`[${this.identifier}] Setting up nextInterceptor.`);
|
||||
this.stopInterceptHandler = this.app.nextInterceptor({
|
||||
@@ -320,8 +393,9 @@ export default class Browser {
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
|
||||
}
|
||||
// Need to clean up intercept handler
|
||||
// Need to clean up intercept handler and theme manager
|
||||
this.stopInterceptHandler?.();
|
||||
this.cleanupThemeListener();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -355,8 +429,9 @@ export default class Browser {
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
|
||||
}
|
||||
// Need to clean up intercept handler
|
||||
// Need to clean up intercept handler and theme manager
|
||||
this.stopInterceptHandler?.();
|
||||
this.cleanupThemeListener();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -387,16 +462,6 @@ export default class Browser {
|
||||
this._browserWindow.webContents.send(channel, data);
|
||||
};
|
||||
|
||||
applyVisualEffects() {
|
||||
// Windows 11 can use this new API
|
||||
if (this._browserWindow) {
|
||||
logger.debug(`[${this.identifier}] Setting window background material for Windows 11`);
|
||||
const isDarkMode = nativeTheme.shouldUseDarkColors;
|
||||
this._browserWindow?.setBackgroundMaterial(isDarkMode ? 'mica' : 'acrylic');
|
||||
this._browserWindow?.setVibrancy('under-window');
|
||||
}
|
||||
}
|
||||
|
||||
toggleVisible() {
|
||||
logger.debug(`Toggling visibility for window: ${this.identifier}`);
|
||||
if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) {
|
||||
@@ -407,35 +472,11 @@ export default class Browser {
|
||||
}
|
||||
}
|
||||
|
||||
getWindowsVersion() {
|
||||
if (process.platform !== 'win32') {
|
||||
return {
|
||||
isWindows: false,
|
||||
isWindows10: false,
|
||||
isWindows11: false,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 获取操作系统版本(如 "10.0.22621")
|
||||
const release = os.release();
|
||||
const parts = release.split('.');
|
||||
|
||||
// 主版本和次版本
|
||||
const majorVersion = parseInt(parts[0], 10);
|
||||
const minorVersion = parseInt(parts[1], 10);
|
||||
|
||||
// 构建号是第三部分
|
||||
const buildNumber = parseInt(parts[2], 10);
|
||||
|
||||
// Windows 11 的构建号从 22000 开始
|
||||
const isWindows11 = majorVersion === 10 && minorVersion === 0 && buildNumber >= 22_000;
|
||||
|
||||
return {
|
||||
buildNumber,
|
||||
isWindows: true,
|
||||
isWindows11,
|
||||
version: release,
|
||||
};
|
||||
/**
|
||||
* Manually reapply visual effects (useful for fixing lost effects after window state changes)
|
||||
*/
|
||||
reapplyVisualEffects(): void {
|
||||
logger.debug(`[${this.identifier}] Manually reapplying visual effects via Browser.`);
|
||||
this.applyVisualEffects();
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { WebContents } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { AppBrowsersIdentifiers, appBrowsers } from '../appBrowsers';
|
||||
import type { App } from './App';
|
||||
import { AppBrowsersIdentifiers, appBrowsers } from '../../appBrowsers';
|
||||
import type { App } from '../App';
|
||||
import type { BrowserWindowOpts } from './Browser';
|
||||
import Browser from './Browser';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:BrowserManager');
|
||||
|
||||
export default class BrowserManager {
|
||||
export class BrowserManager {
|
||||
app: App;
|
||||
|
||||
browsers: Map<AppBrowsersIdentifiers, Browser> = new Map();
|
||||
@@ -194,4 +194,14 @@ export default class BrowserManager {
|
||||
getIdentifierByWebContents(webContents: WebContents): AppBrowsersIdentifiers | null {
|
||||
return this.webContentsMap.get(webContents) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle application theme mode changes and reapply visual effects to all windows
|
||||
*/
|
||||
handleAppThemeChange(): void {
|
||||
logger.debug('Handling app theme change for all browser windows');
|
||||
this.browsers.forEach((browser) => {
|
||||
browser.handleAppThemeChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
|
||||
import FileService from '@/services/fileSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from './App';
|
||||
import type { App } from '../App';
|
||||
|
||||
const logger = createLogger('core:StaticFileServerManager');
|
||||
|
||||
@@ -54,9 +54,12 @@ export class StaticFileServerManager {
|
||||
try {
|
||||
// 使用 get-port-please 获取可用端口
|
||||
this.serverPort = await getPort({
|
||||
port: 33250, // 首选端口
|
||||
ports: [33251, 33252, 33253, 33254, 33255], // 备用端口
|
||||
// 备用端口
|
||||
host: '127.0.0.1',
|
||||
|
||||
port: 33_250,
|
||||
// 首选端口
|
||||
ports: [33_251, 33_252, 33_253, 33_254, 33_255],
|
||||
});
|
||||
|
||||
logger.debug(`Found available port: ${this.serverPort}`);
|
||||
@@ -64,7 +67,7 @@ export class StaticFileServerManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
// 设置请求超时
|
||||
req.setTimeout(30000, () => {
|
||||
req.setTimeout(30_000, () => {
|
||||
logger.warn('Request timeout, closing connection');
|
||||
if (!res.destroyed && !res.headersSent) {
|
||||
res.writeHead(408, { 'Content-Type': 'text/plain' });
|
||||
@@ -155,10 +158,13 @@ export class StaticFileServerManager {
|
||||
|
||||
// 设置响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': fileResult.mimeType,
|
||||
'Cache-Control': 'public, max-age=31536000', // 缓存一年
|
||||
'Access-Control-Allow-Origin': 'http://localhost:*', // 允许 localhost 的任意端口
|
||||
// 缓存一年
|
||||
'Access-Control-Allow-Origin': 'http://localhost:*',
|
||||
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
// 允许 localhost 的任意端口
|
||||
'Content-Length': Buffer.byteLength(fileResult.content),
|
||||
'Content-Type': fileResult.mimeType,
|
||||
});
|
||||
|
||||
// 发送文件内容
|
||||
@@ -5,7 +5,7 @@ import { ElectronMainStore, StoreKey } from '@/types/store';
|
||||
import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { App } from './App';
|
||||
import { App } from '../App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:StoreManager');
|
||||
@@ -5,7 +5,7 @@ import { isDev } from '@/const/env';
|
||||
import { UPDATE_CHANNEL as channel, updaterConfig } from '@/modules/updater/configs';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App as AppCore } from './App';
|
||||
import type { App as AppCore } from '../App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:UpdaterManager');
|
||||
@@ -3,12 +3,12 @@ import { Menu } from 'electron';
|
||||
import { IMenuPlatform, MenuOptions, createMenuImpl } from '@/menus';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from './App';
|
||||
import type { App } from '../App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:MenuManager');
|
||||
|
||||
export default class MenuManager {
|
||||
export class MenuManager {
|
||||
app: App;
|
||||
private platformImpl: IMenuPlatform;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { globalShortcut } from 'electron';
|
||||
|
||||
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from '../App';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:ShortcutManager');
|
||||
|
||||
export interface ShortcutUpdateResult {
|
||||
errorType?:
|
||||
| 'INVALID_ID'
|
||||
| 'INVALID_FORMAT'
|
||||
| 'NO_MODIFIER'
|
||||
| 'CONFLICT'
|
||||
| 'SYSTEM_OCCUPIED'
|
||||
| 'UNKNOWN';
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export class ShortcutManager {
|
||||
private app: App;
|
||||
private shortcuts: Map<string, () => void> = new Map();
|
||||
private shortcutsConfig: Record<string, string> = {};
|
||||
|
||||
constructor(app: App) {
|
||||
logger.debug('Initializing ShortcutManager');
|
||||
this.app = app;
|
||||
|
||||
app.shortcutMethodMap.forEach((method, key) => {
|
||||
this.shortcuts.set(key, method);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert react-hotkey format to Electron accelerator format
|
||||
* @param accelerator The accelerator string from frontend
|
||||
* @returns Converted accelerator string for Electron
|
||||
*/
|
||||
private convertAcceleratorFormat(accelerator: string): string {
|
||||
return accelerator
|
||||
.split('+')
|
||||
.map((key) => {
|
||||
const trimmedKey = key.trim().toLowerCase();
|
||||
|
||||
// Convert react-hotkey 'mod' to Electron 'CommandOrControl'
|
||||
if (trimmedKey === 'mod') {
|
||||
return 'CommandOrControl';
|
||||
}
|
||||
|
||||
// Keep other keys as is, but preserve proper casing
|
||||
return key.trim().length === 1 ? key.trim().toUpperCase() : key.trim();
|
||||
})
|
||||
.join('+');
|
||||
}
|
||||
|
||||
initialize() {
|
||||
logger.info('Initializing global shortcuts');
|
||||
// Load shortcuts configuration from storage
|
||||
this.loadShortcutsConfig();
|
||||
// Register configured shortcuts
|
||||
this.registerConfiguredShortcuts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shortcuts configuration
|
||||
*/
|
||||
getShortcutsConfig(): Record<string, string> {
|
||||
return this.shortcutsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single shortcut configuration
|
||||
*/
|
||||
updateShortcutConfig(id: string, accelerator: string): ShortcutUpdateResult {
|
||||
try {
|
||||
logger.debug(`Updating shortcut ${id} to ${accelerator}`);
|
||||
|
||||
// 1. 检查 ID 是否有效
|
||||
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
|
||||
logger.error(`Invalid shortcut ID: ${id}`);
|
||||
return { errorType: 'INVALID_ID', success: false };
|
||||
}
|
||||
|
||||
// 2. 基本格式校验
|
||||
if (!accelerator || typeof accelerator !== 'string' || accelerator.trim() === '') {
|
||||
logger.error(`Invalid accelerator format: ${accelerator}`);
|
||||
return { errorType: 'INVALID_FORMAT', success: false };
|
||||
}
|
||||
|
||||
// 转换前端格式到 Electron 格式
|
||||
const convertedAccelerator = this.convertAcceleratorFormat(accelerator.trim());
|
||||
const cleanAccelerator = convertedAccelerator.toLowerCase();
|
||||
|
||||
logger.debug(`Converted accelerator from ${accelerator} to ${convertedAccelerator}`);
|
||||
|
||||
// 3. 检查是否包含 + 号(修饰键格式)
|
||||
if (!cleanAccelerator.includes('+')) {
|
||||
logger.error(
|
||||
`Invalid accelerator format: ${cleanAccelerator}. Must contain modifier keys like 'CommandOrControl+E'`,
|
||||
);
|
||||
return { errorType: 'INVALID_FORMAT', success: false };
|
||||
}
|
||||
|
||||
// 4. 检查是否有基本的修饰键
|
||||
const hasModifier = ['CommandOrControl', 'Command', 'Ctrl', 'Alt', 'Shift'].some((modifier) =>
|
||||
cleanAccelerator.includes(modifier.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!hasModifier) {
|
||||
logger.error(`Invalid accelerator format: ${cleanAccelerator}. Must contain modifier keys`);
|
||||
return { errorType: 'NO_MODIFIER', success: false };
|
||||
}
|
||||
|
||||
// 5. 检查冲突
|
||||
for (const [existingId, existingAccelerator] of Object.entries(this.shortcutsConfig)) {
|
||||
if (
|
||||
existingId !== id &&
|
||||
typeof existingAccelerator === 'string' &&
|
||||
existingAccelerator.toLowerCase() === cleanAccelerator
|
||||
) {
|
||||
logger.error(`Shortcut conflict: ${cleanAccelerator} already used by ${existingId}`);
|
||||
return { errorType: 'CONFLICT', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 尝试注册测试(检查是否被系统占用)
|
||||
const testSuccess = globalShortcut.register(convertedAccelerator, () => {});
|
||||
if (!testSuccess) {
|
||||
logger.error(
|
||||
`Shortcut ${convertedAccelerator} is already registered by system or other app`,
|
||||
);
|
||||
return { errorType: 'SYSTEM_OCCUPIED', success: false };
|
||||
} else {
|
||||
// 测试成功,立即取消注册
|
||||
globalShortcut.unregister(convertedAccelerator);
|
||||
}
|
||||
|
||||
// 7. 更新配置
|
||||
this.shortcutsConfig[id] = convertedAccelerator;
|
||||
|
||||
this.saveShortcutsConfig();
|
||||
this.registerConfiguredShortcuts();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Error updating shortcut ${id}:`, error);
|
||||
return { errorType: 'UNKNOWN', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register global shortcut
|
||||
* @param accelerator Shortcut key combination
|
||||
* @param callback Callback function
|
||||
* @returns Whether registration was successful
|
||||
*/
|
||||
registerShortcut(accelerator: string, callback: () => void): boolean {
|
||||
try {
|
||||
// If already registered, unregister first
|
||||
if (this.shortcuts.has(accelerator)) {
|
||||
this.unregisterShortcut(accelerator);
|
||||
}
|
||||
|
||||
// Register new shortcut
|
||||
const success = globalShortcut.register(accelerator, callback);
|
||||
|
||||
if (success) {
|
||||
this.shortcuts.set(accelerator, callback);
|
||||
logger.debug(`Registered shortcut: ${accelerator}`);
|
||||
} else {
|
||||
logger.error(`Failed to register shortcut: ${accelerator}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
logger.error(`Error registering shortcut: ${accelerator}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister global shortcut
|
||||
* @param accelerator Shortcut key combination
|
||||
*/
|
||||
unregisterShortcut(accelerator: string): void {
|
||||
try {
|
||||
globalShortcut.unregister(accelerator);
|
||||
this.shortcuts.delete(accelerator);
|
||||
logger.debug(`Unregistered shortcut: ${accelerator}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error unregistering shortcut: ${accelerator}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shortcut is already registered
|
||||
* @param accelerator Shortcut key combination
|
||||
* @returns Whether it is registered
|
||||
*/
|
||||
isRegistered(accelerator: string): boolean {
|
||||
return globalShortcut.isRegistered(accelerator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister all shortcuts
|
||||
*/
|
||||
unregisterAll(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
logger.info('Unregistered all shortcuts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load shortcuts configuration from storage
|
||||
*/
|
||||
private loadShortcutsConfig() {
|
||||
try {
|
||||
// Try to get configuration from storage
|
||||
const config = this.app.storeManager.get('shortcuts');
|
||||
|
||||
// If no configuration, use default configuration
|
||||
if (!config || Object.keys(config).length === 0) {
|
||||
logger.debug('No shortcuts config found, using defaults');
|
||||
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
|
||||
this.saveShortcutsConfig();
|
||||
} else {
|
||||
// Filter out invalid shortcuts that are not in DEFAULT_SHORTCUTS_CONFIG
|
||||
const filteredConfig: Record<string, string> = {};
|
||||
let hasInvalidKeys = false;
|
||||
|
||||
Object.entries(config).forEach(([id, accelerator]) => {
|
||||
if (DEFAULT_SHORTCUTS_CONFIG[id]) {
|
||||
filteredConfig[id] = accelerator;
|
||||
} else {
|
||||
hasInvalidKeys = true;
|
||||
logger.debug(`Filtering out invalid shortcut ID: ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure all default shortcuts are present
|
||||
Object.entries(DEFAULT_SHORTCUTS_CONFIG).forEach(([id, defaultAccelerator]) => {
|
||||
if (!(id in filteredConfig)) {
|
||||
filteredConfig[id] = defaultAccelerator;
|
||||
logger.debug(`Adding missing default shortcut: ${id} = ${defaultAccelerator}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.shortcutsConfig = filteredConfig;
|
||||
|
||||
// Save the filtered configuration back to storage if we removed invalid keys
|
||||
if (hasInvalidKeys) {
|
||||
logger.debug('Saving filtered shortcuts config to remove invalid keys');
|
||||
this.saveShortcutsConfig();
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Loaded shortcuts config:', this.shortcutsConfig);
|
||||
} catch (error) {
|
||||
logger.error('Error loading shortcuts config:', error);
|
||||
this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG;
|
||||
this.saveShortcutsConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save shortcuts configuration to storage
|
||||
*/
|
||||
private saveShortcutsConfig() {
|
||||
try {
|
||||
this.app.storeManager.set('shortcuts', this.shortcutsConfig);
|
||||
logger.debug('Saved shortcuts config');
|
||||
} catch (error) {
|
||||
logger.error('Error saving shortcuts config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register configured shortcuts
|
||||
*/
|
||||
private registerConfiguredShortcuts() {
|
||||
// Unregister all shortcuts first
|
||||
this.unregisterAll();
|
||||
|
||||
// Register each enabled shortcut
|
||||
Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => {
|
||||
logger.debug(`Registering shortcut '${id}' with ${accelerator}`);
|
||||
|
||||
// 只注册在 DEFAULT_SHORTCUTS_CONFIG 中存在的快捷键
|
||||
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
|
||||
logger.debug(`Skipping shortcut '${id}' - not found in DEFAULT_SHORTCUTS_CONFIG`);
|
||||
return;
|
||||
}
|
||||
|
||||
const method = this.shortcuts.get(id);
|
||||
if (accelerator && method) {
|
||||
this.registerShortcut(accelerator, method);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,157 +12,157 @@ import { join } from 'node:path';
|
||||
import { resourcesDir } from '@/const/dir';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from './App';
|
||||
import type { App } from '../App';
|
||||
|
||||
// 创建日志记录器
|
||||
// Create logger
|
||||
const logger = createLogger('core:Tray');
|
||||
|
||||
export interface TrayOptions {
|
||||
/**
|
||||
* 托盘图标路径(相对于资源目录)
|
||||
* Tray icon path (relative to resource directory)
|
||||
*/
|
||||
iconPath: string;
|
||||
|
||||
/**
|
||||
* 托盘标识符
|
||||
* Tray identifier
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* 托盘提示文本
|
||||
* Tray tooltip text
|
||||
*/
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export default class Tray {
|
||||
export class Tray {
|
||||
private app: App;
|
||||
|
||||
/**
|
||||
* 内部 Electron 托盘
|
||||
* Internal Electron tray
|
||||
*/
|
||||
private _tray?: ElectronTray;
|
||||
|
||||
/**
|
||||
* 标识符
|
||||
* Identifier
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* 创建时的选项
|
||||
* Options when created
|
||||
*/
|
||||
options: TrayOptions;
|
||||
|
||||
/**
|
||||
* 获取托盘实例
|
||||
* Get tray instance
|
||||
*/
|
||||
get tray() {
|
||||
return this.retrieveOrInitialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造托盘对象
|
||||
* @param options 托盘选项
|
||||
* @param application 应用实例
|
||||
* Construct tray object
|
||||
* @param options Tray options
|
||||
* @param application App instance
|
||||
*/
|
||||
constructor(options: TrayOptions, application: App) {
|
||||
logger.debug(`创建托盘实例: ${options.identifier}`);
|
||||
logger.debug(`托盘选项: ${JSON.stringify(options)}`);
|
||||
logger.debug(`Creating tray instance: ${options.identifier}`);
|
||||
logger.debug(`Tray options: ${JSON.stringify(options)}`);
|
||||
this.app = application;
|
||||
this.identifier = options.identifier;
|
||||
this.options = options;
|
||||
|
||||
// 初始化
|
||||
// Initialize
|
||||
this.retrieveOrInitialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化托盘
|
||||
* Initialize tray
|
||||
*/
|
||||
retrieveOrInitialize() {
|
||||
// 如果托盘已存在且未被销毁,则返回
|
||||
// If tray already exists and is not destroyed, return it
|
||||
if (this._tray) {
|
||||
logger.debug(`[${this.identifier}] 返回现有托盘实例`);
|
||||
logger.debug(`[${this.identifier}] Returning existing tray instance`);
|
||||
return this._tray;
|
||||
}
|
||||
|
||||
const { iconPath, tooltip } = this.options;
|
||||
|
||||
// 加载托盘图标
|
||||
logger.info(`创建新的托盘实例: ${this.identifier}`);
|
||||
// Load tray icon
|
||||
logger.info(`Creating new tray instance: ${this.identifier}`);
|
||||
const iconFile = join(resourcesDir, iconPath);
|
||||
logger.debug(`[${this.identifier}] 加载图标: ${iconFile}`);
|
||||
logger.debug(`[${this.identifier}] Loading icon: ${iconFile}`);
|
||||
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(iconFile);
|
||||
this._tray = new ElectronTray(icon);
|
||||
|
||||
// 设置工具提示
|
||||
// Set tooltip
|
||||
if (tooltip) {
|
||||
logger.debug(`[${this.identifier}] 设置提示文本: ${tooltip}`);
|
||||
logger.debug(`[${this.identifier}] Setting tooltip: ${tooltip}`);
|
||||
this._tray.setToolTip(tooltip);
|
||||
}
|
||||
|
||||
// 设置默认上下文菜单
|
||||
// Set default context menu
|
||||
this.setContextMenu();
|
||||
|
||||
// 设置点击事件
|
||||
// Set click event
|
||||
this._tray.on('click', () => {
|
||||
logger.debug(`[${this.identifier}] 托盘被点击`);
|
||||
logger.debug(`[${this.identifier}] Tray clicked`);
|
||||
this.onClick();
|
||||
});
|
||||
|
||||
logger.debug(`[${this.identifier}] 托盘实例创建完成`);
|
||||
logger.debug(`[${this.identifier}] Tray instance created successfully`);
|
||||
return this._tray;
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] 创建托盘失败:`, error);
|
||||
logger.error(`[${this.identifier}] Failed to create tray:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置托盘上下文菜单
|
||||
* @param template 菜单模板,如果未提供则使用默认模板
|
||||
* Set tray context menu
|
||||
* @param template Menu template, if not provided default template will be used
|
||||
*/
|
||||
setContextMenu(template?: MenuItemConstructorOptions[]) {
|
||||
logger.debug(`[${this.identifier}] 设置托盘上下文菜单`);
|
||||
logger.debug(`[${this.identifier}] Setting tray context menu`);
|
||||
|
||||
// 如果未提供模板,使用默认菜单
|
||||
// If no template provided, use default menu
|
||||
const defaultTemplate: MenuItemConstructorOptions[] = template || [
|
||||
{
|
||||
click: () => {
|
||||
logger.debug(`[${this.identifier}] 菜单项 "显示主窗口" 被点击`);
|
||||
logger.debug(`[${this.identifier}] Menu item "Show Main Window" clicked`);
|
||||
this.app.browserManager.showMainWindow();
|
||||
},
|
||||
label: '显示主窗口',
|
||||
label: 'Show Main Window',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
logger.debug(`[${this.identifier}] 菜单项 "退出" 被点击`);
|
||||
logger.debug(`[${this.identifier}] Menu item "Quit" clicked`);
|
||||
app.quit();
|
||||
},
|
||||
label: '退出',
|
||||
label: 'Quit',
|
||||
},
|
||||
];
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate(defaultTemplate);
|
||||
this._tray?.setContextMenu(contextMenu);
|
||||
logger.debug(`[${this.identifier}] 托盘上下文菜单已设置`);
|
||||
logger.debug(`[${this.identifier}] Tray context menu has been set`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理托盘点击事件
|
||||
* Handle tray click event
|
||||
*/
|
||||
onClick() {
|
||||
logger.debug(`[${this.identifier}] 处理托盘点击事件`);
|
||||
logger.debug(`[${this.identifier}] Handling tray click event`);
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
|
||||
if (mainWindow) {
|
||||
if (mainWindow.browserWindow.isVisible() && mainWindow.browserWindow.isFocused()) {
|
||||
logger.debug(`[${this.identifier}] 主窗口已可见且聚焦,现在隐藏它`);
|
||||
logger.debug(`[${this.identifier}] Main window is visible and focused, hiding it now`);
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
logger.debug(`[${this.identifier}] 显示并聚焦主窗口`);
|
||||
logger.debug(`[${this.identifier}] Showing and focusing main window`);
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
}
|
||||
@@ -170,59 +170,61 @@ export default class Tray {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘图标
|
||||
* @param iconPath 新图标路径(相对于资源目录)
|
||||
* Update tray icon
|
||||
* @param iconPath New icon path (relative to resource directory)
|
||||
*/
|
||||
updateIcon(iconPath: string) {
|
||||
logger.debug(`[${this.identifier}] 更新图标: ${iconPath}`);
|
||||
logger.debug(`[${this.identifier}] Updating icon: ${iconPath}`);
|
||||
try {
|
||||
const iconFile = join(resourcesDir, iconPath);
|
||||
const icon = nativeImage.createFromPath(iconFile);
|
||||
this._tray?.setImage(icon);
|
||||
this.options.iconPath = iconPath;
|
||||
logger.debug(`[${this.identifier}] 图标已更新`);
|
||||
logger.debug(`[${this.identifier}] Icon updated successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] 更新图标失败:`, error);
|
||||
logger.error(`[${this.identifier}] Failed to update icon:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提示文本
|
||||
* @param tooltip 新提示文本
|
||||
* Update tooltip text
|
||||
* @param tooltip New tooltip text
|
||||
*/
|
||||
updateTooltip(tooltip: string) {
|
||||
logger.debug(`[${this.identifier}] 更新提示文本: ${tooltip}`);
|
||||
logger.debug(`[${this.identifier}] Updating tooltip: ${tooltip}`);
|
||||
this._tray?.setToolTip(tooltip);
|
||||
this.options.tooltip = tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示气泡通知(仅在 Windows 上支持)
|
||||
* @param options 气泡选项
|
||||
* Display balloon notification (only supported on Windows)
|
||||
* @param options Balloon options
|
||||
*/
|
||||
displayBalloon(options: DisplayBalloonOptions) {
|
||||
if (process.platform === 'win32' && this._tray) {
|
||||
logger.debug(`[${this.identifier}] 显示气泡通知: ${JSON.stringify(options)}`);
|
||||
logger.debug(
|
||||
`[${this.identifier}] Displaying balloon notification: ${JSON.stringify(options)}`,
|
||||
);
|
||||
this._tray.displayBalloon(options);
|
||||
} else {
|
||||
logger.debug(`[${this.identifier}] 气泡通知仅在 Windows 上支持`);
|
||||
logger.debug(`[${this.identifier}] Balloon notification is only supported on Windows`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件
|
||||
* Broadcast event
|
||||
*/
|
||||
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
|
||||
logger.debug(`向托盘 ${this.identifier} 广播, 频道: ${channel}`);
|
||||
// 可以通过 App 实例的 browserManager 将消息转发到主窗口
|
||||
logger.debug(`Broadcasting to tray ${this.identifier}, channel: ${channel}`);
|
||||
// Can forward message to main window through App instance's browserManager
|
||||
this.app.browserManager.getMainWindow()?.broadcast(channel, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 销毁托盘实例
|
||||
* Destroy tray instance
|
||||
*/
|
||||
destroy() {
|
||||
logger.debug(`销毁托盘实例: ${this.identifier}`);
|
||||
logger.debug(`Destroying tray instance: ${this.identifier}`);
|
||||
if (this._tray) {
|
||||
this._tray.destroy();
|
||||
this._tray = undefined;
|
||||
@@ -1,10 +1,12 @@
|
||||
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import { nativeTheme } from 'electron';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { isMac } from '@/const/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from './App';
|
||||
import Tray, { TrayOptions } from './Tray';
|
||||
import type { App } from '../App';
|
||||
import { Tray, TrayOptions } from './Tray';
|
||||
|
||||
// 创建日志记录器
|
||||
const logger = createLogger('core:TrayManager');
|
||||
@@ -14,7 +16,7 @@ const logger = createLogger('core:TrayManager');
|
||||
*/
|
||||
export type TrayIdentifiers = 'main';
|
||||
|
||||
export default class TrayManager {
|
||||
export class TrayManager {
|
||||
app: App;
|
||||
|
||||
/**
|
||||
@@ -54,9 +56,13 @@ export default class TrayManager {
|
||||
initializeMainTray() {
|
||||
logger.debug('初始化主托盘');
|
||||
return this.retrieveOrInitialize({
|
||||
iconPath: 'tray-icon.png',
|
||||
identifier: 'main', // 使用应用图标,需要确保资源目录中有此文件
|
||||
tooltip: name, // 可以使用 app.getName() 或本地化字符串
|
||||
iconPath: isMac
|
||||
? nativeTheme.shouldUseDarkColors
|
||||
? 'tray-dark.png'
|
||||
: 'tray-light.png'
|
||||
: 'tray.png',
|
||||
identifier: 'main', // Use app icon, ensure this file exists in resources directory
|
||||
tooltip: name, // Can use app.getName() or localized string
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { globalShortcut } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
|
||||
|
||||
import type { App } from '../../App';
|
||||
import { ShortcutManager } from '../ShortcutManager';
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
globalShortcut: {
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
unregisterAll: vi.fn(),
|
||||
isRegistered: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock DEFAULT_SHORTCUTS_CONFIG
|
||||
vi.mock('@/shortcuts', () => ({
|
||||
DEFAULT_SHORTCUTS_CONFIG: {
|
||||
showApp: 'Control+E',
|
||||
openSettings: 'CommandOrControl+,',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ShortcutManager', () => {
|
||||
let shortcutManager: ShortcutManager;
|
||||
let mockApp: App;
|
||||
let mockStoreManager: any;
|
||||
let mockShortcutMethodMap: Map<string, () => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset all mocks to their default behavior
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(true);
|
||||
vi.mocked(globalShortcut.unregister).mockReturnValue(undefined);
|
||||
vi.mocked(globalShortcut.unregisterAll).mockReturnValue(undefined);
|
||||
vi.mocked(globalShortcut.isRegistered).mockReturnValue(false);
|
||||
|
||||
// Mock store manager
|
||||
mockStoreManager = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock shortcut method map
|
||||
mockShortcutMethodMap = new Map();
|
||||
const showAppMethod = vi.fn();
|
||||
const openSettingsMethod = vi.fn();
|
||||
mockShortcutMethodMap.set('showApp', showAppMethod);
|
||||
mockShortcutMethodMap.set('openSettings', openSettingsMethod);
|
||||
|
||||
// Mock App
|
||||
mockApp = {
|
||||
storeManager: mockStoreManager,
|
||||
shortcutMethodMap: mockShortcutMethodMap,
|
||||
} as unknown as App;
|
||||
|
||||
shortcutManager = new ShortcutManager(mockApp);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize shortcut manager with app', () => {
|
||||
expect(shortcutManager).toBeDefined();
|
||||
expect(shortcutManager['app']).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('should populate shortcuts map from app shortcut method map', () => {
|
||||
expect(shortcutManager['shortcuts'].size).toBe(2);
|
||||
expect(shortcutManager['shortcuts'].has('showApp')).toBe(true);
|
||||
expect(shortcutManager['shortcuts'].has('openSettings')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAcceleratorFormat', () => {
|
||||
it('should convert mod to CommandOrControl', () => {
|
||||
const result = shortcutManager['convertAcceleratorFormat']('mod+e');
|
||||
expect(result).toBe('CommandOrControl+E');
|
||||
});
|
||||
|
||||
it('should preserve other keys as is except single characters', () => {
|
||||
const result = shortcutManager['convertAcceleratorFormat']('ctrl+alt+f12');
|
||||
expect(result).toBe('ctrl+alt+f12');
|
||||
});
|
||||
|
||||
it('should handle single character keys with uppercase', () => {
|
||||
const result = shortcutManager['convertAcceleratorFormat']('ctrl + a');
|
||||
expect(result).toBe('ctrl+A');
|
||||
});
|
||||
|
||||
it('should handle complex combinations', () => {
|
||||
const result = shortcutManager['convertAcceleratorFormat']('mod+shift+delete');
|
||||
expect(result).toBe('CommandOrControl+shift+delete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should load shortcuts config and register shortcuts', () => {
|
||||
// Mock store to return empty config (will use defaults)
|
||||
mockStoreManager.get.mockReturnValue({});
|
||||
|
||||
shortcutManager.initialize();
|
||||
|
||||
expect(mockStoreManager.get).toHaveBeenCalledWith('shortcuts');
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Control+E', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith(
|
||||
'CommandOrControl+,',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle stored config with filtering', () => {
|
||||
const storedConfig = {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+Shift+P',
|
||||
invalidKey: 'Ctrl+I', // Should be filtered out
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(storedConfig);
|
||||
|
||||
shortcutManager.initialize();
|
||||
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('Ctrl+Shift+P');
|
||||
expect(config.invalidKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShortcutsConfig', () => {
|
||||
it('should return current shortcuts configuration', () => {
|
||||
mockStoreManager.get.mockReturnValue({});
|
||||
shortcutManager.initialize();
|
||||
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config).toEqual(DEFAULT_SHORTCUTS_CONFIG);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateShortcutConfig', () => {
|
||||
beforeEach(() => {
|
||||
mockStoreManager.get.mockReturnValue({});
|
||||
shortcutManager.initialize();
|
||||
});
|
||||
|
||||
it('should successfully update valid shortcut', () => {
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'Alt+E');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errorType).toBeUndefined();
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
||||
'shortcuts',
|
||||
expect.objectContaining({
|
||||
showApp: 'Alt+E',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid shortcut ID', () => {
|
||||
const result = shortcutManager.updateShortcutConfig('invalidId', 'Alt+E');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('INVALID_ID');
|
||||
});
|
||||
|
||||
it('should reject empty accelerator', () => {
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', '');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('INVALID_FORMAT');
|
||||
});
|
||||
|
||||
it('should reject accelerator without modifier keys', () => {
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'E');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('INVALID_FORMAT');
|
||||
});
|
||||
|
||||
it('should reject accelerator without proper modifiers', () => {
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'F1+E');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('NO_MODIFIER');
|
||||
});
|
||||
|
||||
it('should detect conflicts with existing shortcuts', () => {
|
||||
// First set a shortcut
|
||||
shortcutManager.updateShortcutConfig('showApp', 'Alt+E');
|
||||
|
||||
// Try to set the same accelerator for another shortcut
|
||||
const result = shortcutManager.updateShortcutConfig('openSettings', 'Alt+E');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('CONFLICT');
|
||||
});
|
||||
|
||||
it('should detect system occupied shortcuts', () => {
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(false);
|
||||
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'Ctrl+Alt+T');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('SYSTEM_OCCUPIED');
|
||||
});
|
||||
|
||||
it('should handle registration test cleanup', () => {
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(true);
|
||||
|
||||
shortcutManager.updateShortcutConfig('showApp', 'Ctrl+Alt+T');
|
||||
|
||||
// Should unregister the test registration
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Ctrl+Alt+T');
|
||||
});
|
||||
|
||||
it('should handle conversion from react-hotkey format', () => {
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'mod+shift+e');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config.showApp).toBe('CommandOrControl+shift+E');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
// Mock globalShortcut.register to throw an error during testing
|
||||
vi.mocked(globalShortcut.register).mockImplementation(() => {
|
||||
throw new Error('Register error');
|
||||
});
|
||||
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'Alt+E');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorType).toBe('UNKNOWN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerShortcut', () => {
|
||||
it('should register new shortcut successfully', () => {
|
||||
const callback = vi.fn();
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(true);
|
||||
|
||||
const result = shortcutManager.registerShortcut('Ctrl+T', callback);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+T', callback);
|
||||
expect(shortcutManager['shortcuts'].has('Ctrl+T')).toBe(true);
|
||||
});
|
||||
|
||||
it('should unregister existing shortcut before registering new one', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
// First registration
|
||||
shortcutManager['shortcuts'].set('Ctrl+T', callback1);
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(true);
|
||||
|
||||
shortcutManager.registerShortcut('Ctrl+T', callback2);
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Ctrl+T');
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+T', callback2);
|
||||
});
|
||||
|
||||
it('should handle registration failure', () => {
|
||||
const callback = vi.fn();
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(false);
|
||||
|
||||
const result = shortcutManager.registerShortcut('Ctrl+T', callback);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(shortcutManager['shortcuts'].has('Ctrl+T')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle registration errors', () => {
|
||||
const callback = vi.fn();
|
||||
vi.mocked(globalShortcut.register).mockImplementation(() => {
|
||||
throw new Error('Registration error');
|
||||
});
|
||||
|
||||
const result = shortcutManager.registerShortcut('Ctrl+T', callback);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterShortcut', () => {
|
||||
it('should unregister shortcut successfully', () => {
|
||||
const callback = vi.fn();
|
||||
shortcutManager['shortcuts'].set('Ctrl+T', callback);
|
||||
|
||||
shortcutManager.unregisterShortcut('Ctrl+T');
|
||||
|
||||
expect(globalShortcut.unregister).toHaveBeenCalledWith('Ctrl+T');
|
||||
expect(shortcutManager['shortcuts'].has('Ctrl+T')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle unregistration errors', () => {
|
||||
vi.mocked(globalShortcut.unregister).mockImplementation(() => {
|
||||
throw new Error('Unregister error');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => shortcutManager.unregisterShortcut('Ctrl+T')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRegistered', () => {
|
||||
it('should check if shortcut is registered', () => {
|
||||
vi.mocked(globalShortcut.isRegistered).mockReturnValue(true);
|
||||
|
||||
const result = shortcutManager.isRegistered('Ctrl+T');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(globalShortcut.isRegistered).toHaveBeenCalledWith('Ctrl+T');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterAll', () => {
|
||||
it('should unregister all shortcuts', () => {
|
||||
shortcutManager.unregisterAll();
|
||||
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadShortcutsConfig', () => {
|
||||
it('should use defaults when no config exists', () => {
|
||||
mockStoreManager.get.mockReturnValue(null);
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
|
||||
});
|
||||
|
||||
it('should use defaults when config is empty', () => {
|
||||
mockStoreManager.get.mockReturnValue({});
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
|
||||
});
|
||||
|
||||
it('should filter invalid keys from stored config', () => {
|
||||
const storedConfig = {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
invalidKey1: 'Ctrl+I',
|
||||
invalidKey2: 'Ctrl+J',
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(storedConfig);
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
const config = shortcutManager['shortcutsConfig'];
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('Ctrl+P');
|
||||
expect(config.invalidKey1).toBeUndefined();
|
||||
expect(config.invalidKey2).toBeUndefined();
|
||||
|
||||
// Should save filtered config
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', config);
|
||||
});
|
||||
|
||||
it('should add missing default shortcuts', () => {
|
||||
const incompleteConfig = {
|
||||
showApp: 'Alt+E',
|
||||
// Missing openSettings
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(incompleteConfig);
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
const config = shortcutManager['shortcutsConfig'];
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('CommandOrControl+,'); // Default value
|
||||
});
|
||||
|
||||
it('should not save config if no invalid keys were found', () => {
|
||||
const validConfig = {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(validConfig);
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
// Should not call set since no changes were made
|
||||
expect(mockStoreManager.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle store errors gracefully', () => {
|
||||
mockStoreManager.get.mockImplementation(() => {
|
||||
throw new Error('Store error');
|
||||
});
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveShortcutsConfig', () => {
|
||||
it('should save shortcuts config to store', () => {
|
||||
shortcutManager['shortcutsConfig'] = { showApp: 'Alt+E', openSettings: 'Ctrl+P' };
|
||||
|
||||
shortcutManager['saveShortcutsConfig']();
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle save errors gracefully', () => {
|
||||
mockStoreManager.set.mockImplementation(() => {
|
||||
throw new Error('Save error');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => shortcutManager['saveShortcutsConfig']()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerConfiguredShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
});
|
||||
|
||||
it('should register all configured shortcuts', () => {
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(true);
|
||||
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should skip shortcuts not in DEFAULT_SHORTCUTS_CONFIG', () => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
showApp: 'Alt+E',
|
||||
invalidKey: 'Ctrl+I',
|
||||
};
|
||||
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+I', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should skip shortcuts with empty accelerator', () => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
showApp: '',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should skip shortcuts without corresponding methods', () => {
|
||||
// Remove method from map
|
||||
mockShortcutMethodMap.delete('openSettings');
|
||||
shortcutManager = new ShortcutManager(mockApp);
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should complete full initialization flow', () => {
|
||||
const storedConfig = {
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+Shift+P',
|
||||
invalidKey: 'Ctrl+I',
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(storedConfig);
|
||||
vi.mocked(globalShortcut.register).mockReturnValue(true);
|
||||
|
||||
shortcutManager.initialize();
|
||||
|
||||
// Should filter config and register valid shortcuts
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('Ctrl+Shift+P');
|
||||
expect(config.invalidKey).toBeUndefined();
|
||||
|
||||
expect(globalShortcut.register).toHaveBeenCalledTimes(2);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', config);
|
||||
});
|
||||
|
||||
it('should handle complete update workflow', () => {
|
||||
mockStoreManager.get.mockReturnValue({});
|
||||
shortcutManager.initialize();
|
||||
|
||||
// Update a shortcut
|
||||
const result = shortcutManager.updateShortcutConfig('showApp', 'mod+alt+e');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should convert format and register
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config.showApp).toBe('CommandOrControl+alt+E');
|
||||
|
||||
// Should have saved and re-registered shortcuts
|
||||
expect(mockStoreManager.set).toHaveBeenCalled();
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith(
|
||||
'CommandOrControl+alt+E',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,11 @@
|
||||
* 快捷键操作类型枚举
|
||||
*/
|
||||
export const ShortcutActionEnum = {
|
||||
openSettings: 'openSettings',
|
||||
/**
|
||||
* 显示/隐藏主窗口
|
||||
*/
|
||||
toggleMainWindow: 'toggleMainWindow',
|
||||
showApp: 'showApp',
|
||||
} as const;
|
||||
|
||||
export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof ShortcutActionEnum];
|
||||
@@ -14,5 +15,6 @@ export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof Shortc
|
||||
* 默认快捷键配置
|
||||
*/
|
||||
export const DEFAULT_SHORTCUTS_CONFIG: Record<ShortcutActionType, string> = {
|
||||
[ShortcutActionEnum.toggleMainWindow]: 'CommandOrControl+E',
|
||||
[ShortcutActionEnum.showApp]: 'Control+E',
|
||||
[ShortcutActionEnum.openSettings]: 'CommandOrControl+,',
|
||||
};
|
||||
|
||||
@@ -4,12 +4,14 @@ export interface ElectronMainStore {
|
||||
dataSyncConfig: DataSyncConfig;
|
||||
encryptedTokens: {
|
||||
accessToken?: string;
|
||||
expiresAt?: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
locale: string;
|
||||
networkProxy: NetworkProxySettings;
|
||||
shortcuts: Record<string, string>;
|
||||
storagePath: string;
|
||||
themeMode: 'dark' | 'light' | 'auto';
|
||||
}
|
||||
|
||||
export type StoreKey = keyof ElectronMainStore;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload';
|
||||
import { contextBridge } from 'electron';
|
||||
|
||||
import { invoke } from './invoke';
|
||||
import { onStreamInvoke } from './streamer';
|
||||
|
||||
export const setupElectronApi = () => {
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
@@ -14,5 +15,5 @@ export const setupElectronApi = () => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', { invoke });
|
||||
contextBridge.exposeInMainWorld('electronAPI', { invoke, onStreamInvoke });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface StreamResponse {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface StreamerCallbacks {
|
||||
onData: (chunk: Uint8Array) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: Error) => void;
|
||||
onResponse: (response: StreamResponse) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the main process method and handles the stream response via callbacks.
|
||||
* @param params The request parameters.
|
||||
* @param callbacks The callbacks to handle stream events.
|
||||
*/
|
||||
export const onStreamInvoke = (
|
||||
params: ProxyTRPCRequestParams,
|
||||
callbacks: StreamerCallbacks,
|
||||
): (() => void) => {
|
||||
const requestId = uuid();
|
||||
|
||||
const cleanup = () => {
|
||||
ipcRenderer.removeAllListeners(`stream:data:${requestId}`);
|
||||
ipcRenderer.removeAllListeners(`stream:end:${requestId}`);
|
||||
ipcRenderer.removeAllListeners(`stream:error:${requestId}`);
|
||||
ipcRenderer.removeAllListeners(`stream:response:${requestId}`);
|
||||
};
|
||||
|
||||
ipcRenderer.on(`stream:data:${requestId}`, (_, chunk: Buffer) => {
|
||||
callbacks.onData(new Uint8Array(chunk));
|
||||
});
|
||||
|
||||
ipcRenderer.once(`stream:end:${requestId}`, () => {
|
||||
callbacks.onEnd();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ipcRenderer.once(`stream:error:${requestId}`, (_, error: Error) => {
|
||||
callbacks.onError(error);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ipcRenderer.once(`stream:response:${requestId}`, (_, response: StreamResponse) => {
|
||||
callbacks.onResponse(response);
|
||||
});
|
||||
|
||||
ipcRenderer.send('stream:start', { ...params, requestId });
|
||||
|
||||
// Return a cleanup function to be called on cancellation
|
||||
return cleanup;
|
||||
};
|
||||
@@ -1,4 +1,199 @@
|
||||
[
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Support more Text2Image from Qwen."]
|
||||
},
|
||||
"date": "2025-07-29",
|
||||
"version": "1.105.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Implement API Key management functionality."]
|
||||
},
|
||||
"date": "2025-07-28",
|
||||
"version": "1.105.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix setting window layout when in desktop was disappear."]
|
||||
},
|
||||
"date": "2025-07-28",
|
||||
"version": "1.104.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix setting window layout size, update i18n."]
|
||||
},
|
||||
"date": "2025-07-28",
|
||||
"version": "1.104.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add Gemini 2.5 Flash-Lite GA model."]
|
||||
},
|
||||
"date": "2025-07-26",
|
||||
"version": "1.104.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix update hotkey invalid when input mod in desktop."]
|
||||
},
|
||||
"date": "2025-07-26",
|
||||
"version": "1.104.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": [
|
||||
"Update convertUsage to handle XAI provider and adjust OpenAIStream to pass provider."
|
||||
]
|
||||
},
|
||||
"date": "2025-07-25",
|
||||
"version": "1.104.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Support custom hotkey on desktop."]
|
||||
},
|
||||
"date": "2025-07-24",
|
||||
"version": "1.104.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix chat stream in desktop and update shortcut."],
|
||||
"improvements": [
|
||||
"Add cached token count to usage of GoogleAI and VertexAI, fix desktop titlebar style in window, fix sub topic width in md responsive."
|
||||
]
|
||||
},
|
||||
"date": "2025-07-24",
|
||||
"version": "1.103.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
},
|
||||
"date": "2025-07-23",
|
||||
"version": "1.103.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add Qwen image generation capabilities."]
|
||||
},
|
||||
"date": "2025-07-22",
|
||||
"version": "1.103.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update tray icon."]
|
||||
},
|
||||
"date": "2025-07-22",
|
||||
"version": "1.102.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Remove debug logging from ModelRuntime and async caller."]
|
||||
},
|
||||
"date": "2025-07-22",
|
||||
"version": "1.102.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Add notification for desktop."]
|
||||
},
|
||||
"date": "2025-07-22",
|
||||
"version": "1.102.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Modal list header sticky style."]
|
||||
},
|
||||
"date": "2025-07-21",
|
||||
"version": "1.102.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add image generation capabilities using Google AI Imagen API."]
|
||||
},
|
||||
"date": "2025-07-21",
|
||||
"version": "1.102.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix lobehub provider /chat in desktop."]
|
||||
},
|
||||
"date": "2025-07-21",
|
||||
"version": "1.101.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Try fix authorization code exchange & pin next-auto to beta.29."]
|
||||
},
|
||||
"date": "2025-07-19",
|
||||
"version": "1.101.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Add zhipu cogview4."],
|
||||
"fixes": ["Some ai image bugs."]
|
||||
},
|
||||
"date": "2025-07-19",
|
||||
"version": "1.101.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix webapi proxy with clerk."]
|
||||
},
|
||||
"date": "2025-07-18",
|
||||
"version": "1.100.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Use server env config image models."]
|
||||
},
|
||||
"date": "2025-07-17",
|
||||
"version": "1.100.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Refactor desktop oauth and use JWTs token to support remote chat."]
|
||||
},
|
||||
"date": "2025-07-17",
|
||||
"version": "1.100.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Desktop local db can't upload image."]
|
||||
},
|
||||
"date": "2025-07-16",
|
||||
"version": "1.99.6"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix page error when url is not defined in web search plugin."]
|
||||
},
|
||||
"date": "2025-07-16",
|
||||
"version": "1.99.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix apikey issue on server log."]
|
||||
},
|
||||
"date": "2025-07-16",
|
||||
"version": "1.99.4"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Chat model list should not show image model."]
|
||||
},
|
||||
"date": "2025-07-16",
|
||||
"version": "1.99.3"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Some ai image generation feedback issues."]
|
||||
},
|
||||
"date": "2025-07-15",
|
||||
"version": "1.99.2"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2025-07-15",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Adding New Image Models
|
||||
|
||||
> Learn more about the AI image generation modal design in the [AI Image Generation Modal Design Discussion](https://github.com/lobehub/lobe-chat/discussions/7442)
|
||||
|
||||
## Parameter Standardization
|
||||
|
||||
All image generation models must use the standard parameters defined in `src/libs/standard-parameters/index.ts`. This ensures parameter consistency across different Providers, creating a more unified user experience.
|
||||
|
||||
**Supported Standard Parameters**:
|
||||
|
||||
- `prompt` (required): Text prompt for image generation
|
||||
- `aspectRatio`: Aspect ratio (e.g., "16:9", "1:1")
|
||||
- `width` / `height`: Image dimensions
|
||||
- `size`: Preset dimensions (e.g., "1024x1024")
|
||||
- `seed`: Random seed
|
||||
- `steps`: Generation steps
|
||||
- `cfg`: Guidance scale
|
||||
- For other parameters, please check the source file
|
||||
|
||||
## OpenAI Compatible Models
|
||||
|
||||
These models can be requested using the OpenAI SDK, with request parameters and return values consistent with DALL-E and GPT-Image-X series.
|
||||
|
||||
Taking Zhipu's CogView-4 as an example, which is an OpenAI-compatible model, you can add it by adding the model configuration in the corresponding AI models file `src/config/aiModels/zhipu.ts`:
|
||||
|
||||
```ts
|
||||
const zhipuImageModels: AIImageModelCard[] = [
|
||||
// Add model configuration
|
||||
// https://bigmodel.cn/dev/howuse/image-generation-model/cogview-4
|
||||
{
|
||||
description:
|
||||
'CogView-4 is the first open-source text-to-image model from Zhipu that supports Chinese character generation, with comprehensive improvements in semantic understanding, image generation quality, and Chinese-English text generation capabilities.',
|
||||
displayName: 'CogView-4',
|
||||
enabled: true,
|
||||
id: 'cogview-4',
|
||||
parameters: {
|
||||
prompt: {
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
default: '1024x1024',
|
||||
enum: ['1024x1024', '768x1344', '864x1152', '1344x768', '1152x864', '1440x720', '720x1440'],
|
||||
},
|
||||
},
|
||||
releasedAt: '2025-03-04',
|
||||
type: 'image',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Non-OpenAI Compatible Models
|
||||
|
||||
For image generation models that are not compatible with OpenAI format, you need to implement a custom `createImage` method. There are two main implementation approaches:
|
||||
|
||||
### Method 1: Using OpenAI Compatible Factory
|
||||
|
||||
Most Providers use `openaiCompatibleFactory` for OpenAI compatibility. You can pass in a custom `createImage` function (reference [PR #8534](https://github.com/lobehub/lobe-chat/pull/8534)).
|
||||
|
||||
**Implementation Steps**:
|
||||
|
||||
1. **Read Provider documentation and standard parameter definitions**
|
||||
- Review the Provider's image generation API documentation to understand request and response formats
|
||||
- Read `src/libs/standard-parameters/index.ts` to understand supported parameters
|
||||
- Add image model configuration in the corresponding AI models file
|
||||
|
||||
2. **Implement custom createImage method**
|
||||
- Create a standalone image generation function that accepts standard parameters
|
||||
- Convert standard parameters to Provider-specific format
|
||||
- Call the Provider's image generation API
|
||||
- Return a unified response format (imageUrl and optional width/height)
|
||||
|
||||
3. **Add tests**
|
||||
- Write unit tests covering success scenarios
|
||||
- Test various error cases and edge conditions
|
||||
|
||||
**Code Example**:
|
||||
|
||||
```ts
|
||||
// src/libs/model-runtime/provider-name/createImage.ts
|
||||
export const createProviderImage = async (
|
||||
payload: ImageGenerationPayload,
|
||||
options: any,
|
||||
): Promise<ImageGenerationResponse> => {
|
||||
const { model, prompt, ...params } = payload;
|
||||
|
||||
// Call Provider's native API
|
||||
const result = await callProviderAPI({
|
||||
model,
|
||||
prompt,
|
||||
// Convert parameter format
|
||||
custom_param: params.width,
|
||||
// ...
|
||||
});
|
||||
|
||||
// Return unified format
|
||||
return {
|
||||
created: Date.now(),
|
||||
data: [{ url: result.imageUrl }],
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/libs/model-runtime/provider-name/index.ts
|
||||
export const LobeProviderAI = openaiCompatibleFactory({
|
||||
constructorOptions: {
|
||||
// ... other configurations
|
||||
},
|
||||
createImage: createProviderImage, // Pass custom implementation
|
||||
provider: ModelProvider.ProviderName,
|
||||
});
|
||||
```
|
||||
|
||||
### Method 2: Direct Implementation in Provider Class
|
||||
|
||||
If your Provider has an independent class implementation, you can directly add the `createImage` method in the class (reference [PR #8503](https://github.com/lobehub/lobe-chat/pull/8503)).
|
||||
|
||||
**Implementation Steps**:
|
||||
|
||||
1. **Read Provider documentation and standard parameter definitions**
|
||||
- Review the Provider's image generation API documentation
|
||||
- Read `src/libs/standard-parameters/index.ts`
|
||||
- Add image model configuration in the corresponding AI models file
|
||||
|
||||
2. **Implement createImage method in Provider class**
|
||||
- Add the `createImage` method directly in the class
|
||||
- Handle parameter conversion and API calls
|
||||
- Return a unified response format
|
||||
|
||||
3. **Add tests**
|
||||
- Write comprehensive test cases for the new method
|
||||
|
||||
**Code Example**:
|
||||
|
||||
```ts
|
||||
// src/libs/model-runtime/provider-name/index.ts
|
||||
export class LobeProviderAI {
|
||||
async createImage(
|
||||
payload: ImageGenerationPayload,
|
||||
options?: ChatStreamCallbacks,
|
||||
): Promise<ImageGenerationResponse> {
|
||||
const { model, prompt, ...params } = payload;
|
||||
|
||||
// Call native API and handle response
|
||||
const result = await this.client.generateImage({
|
||||
model,
|
||||
prompt,
|
||||
// Parameter conversion
|
||||
});
|
||||
|
||||
return {
|
||||
created: Date.now(),
|
||||
data: [{ url: result.url }],
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Testing Requirements**: Add comprehensive unit tests for custom implementations, ensuring coverage of success scenarios and various error cases
|
||||
- **Error Handling**: Use `AgentRuntimeError` consistently for error wrapping to maintain error message consistency
|
||||
@@ -0,0 +1,162 @@
|
||||
# 添加新的图像模型
|
||||
|
||||
> 了解更多关于 AI 绘画模态的设计,请参考 [AI 绘画模态设计讨论](https://github.com/lobehub/lobe-chat/discussions/7442)
|
||||
|
||||
## 参数标准化
|
||||
|
||||
所有图像生成模型都必须使用 `src/libs/standard-parameters/index.ts` 中定义的标准参数。这确保了不同 Provider 之间的参数一致性,让用户体验更加统一。
|
||||
|
||||
**支持的标准参数**:
|
||||
|
||||
- `prompt` (必需):生成图像的提示词
|
||||
- `aspectRatio`:宽高比(如 "16:9", "1:1")
|
||||
- `width` / `height`:图像宽高
|
||||
- `size`:预设尺寸(如 "1024x1024")
|
||||
- `seed`:随机种子
|
||||
- `steps`:生成步数
|
||||
- `cfg`:引导缩放
|
||||
- 其他参数请查看源文件
|
||||
|
||||
## 兼容 OpenAI 请求格式的模型
|
||||
|
||||
指的是可以使用 openai SDK 进行请求,并且请求参数和和返回值和 dall-e 以及 gpt-image-x 系列一致。
|
||||
|
||||
以智谱的 CogView-4 为例,它是一个兼容 openai 请求格式的模型。你只需要在对应的 ai models 文件 `src/config/aiModels/zhipu.ts` 中,添加模型配置,例如:
|
||||
|
||||
```ts
|
||||
const zhipuImageModels: AIImageModelCard[] = [
|
||||
// 添加模型配置
|
||||
// https://bigmodel.cn/dev/howuse/image-generation-model/cogview-4
|
||||
{
|
||||
description:
|
||||
'CogView-4 是智谱首个支持生成汉字的开源文生图模型,在语义理解、图像生成质量、中英文字生成能力等方面全面提升,支持任意长度的中英双语输入,能够生成在给定范围内的任意分辨率图像。',
|
||||
displayName: 'CogView-4',
|
||||
enabled: true,
|
||||
id: 'cogview-4',
|
||||
parameters: {
|
||||
prompt: {
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
default: '1024x1024',
|
||||
enum: ['1024x1024', '768x1344', '864x1152', '1344x768', '1152x864', '1440x720', '720x1440'],
|
||||
},
|
||||
},
|
||||
releasedAt: '2025-03-04',
|
||||
type: 'image',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 不兼容 OpenAI 请求格式的模型
|
||||
|
||||
对于不兼容 OpenAI 格式的图像生成模型,需要实现自定义的 `createImage` 方法。有两种主要实现方式:
|
||||
|
||||
### 方式一:使用 OpenAI Compatible Factory
|
||||
|
||||
大部分 Provider 都使用 `openaiCompatibleFactory` 来兼容 OpenAI,可以通过传入自定义的 `createImage` 函数(参考 [PR #8534](https://github.com/lobehub/lobe-chat/pull/8534))。
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
1. **阅读 Provider 官方文档和标准参数定义**
|
||||
- 查看 Provider 的图像生成 API 文档,了解请求格式和响应格式
|
||||
- 阅读 `src/libs/standard-parameters/index.ts`,了解支持的参数
|
||||
- 在对应的 ai models 文件中增加 image model 配置
|
||||
|
||||
2. **实现自定义的 createImage 方法**
|
||||
- 创建独立的图像生成函数,接受标准生图参数
|
||||
- 将标准参数转换为 Provider 特定的格式
|
||||
- 调用 Provider 的生图接口
|
||||
- 返回统一格式的响应(imageUrl 和可选的宽高)
|
||||
|
||||
3. **补充测试**
|
||||
- 编写单元测试覆盖成功场景
|
||||
- 测试各种错误情况和边界条件
|
||||
|
||||
**代码示例**:
|
||||
|
||||
```ts
|
||||
// src/libs/model-runtime/provider-name/createImage.ts
|
||||
export const createProviderImage = async (
|
||||
payload: ImageGenerationPayload,
|
||||
options: any,
|
||||
): Promise<ImageGenerationResponse> => {
|
||||
const { model, prompt, ...params } = payload;
|
||||
|
||||
// 调用 Provider 的原生 API
|
||||
const result = await callProviderAPI({
|
||||
model,
|
||||
prompt,
|
||||
// 转换参数格式
|
||||
custom_param: params.width,
|
||||
// ...
|
||||
});
|
||||
|
||||
// 返回统一格式
|
||||
return {
|
||||
created: Date.now(),
|
||||
data: [{ url: result.imageUrl }],
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/libs/model-runtime/provider-name/index.ts
|
||||
export const LobeProviderAI = openaiCompatibleFactory({
|
||||
constructorOptions: {
|
||||
// ... 其他配置
|
||||
},
|
||||
createImage: createProviderImage, // 传入自定义实现
|
||||
provider: ModelProvider.ProviderName,
|
||||
});
|
||||
```
|
||||
|
||||
### 方式二:在 Provider 类中直接实现
|
||||
|
||||
如果你的 Provider 有独立的类实现,可以直接在类中添加 `createImage` 方法(参考 [PR #8503](https://github.com/lobehub/lobe-chat/pull/8503))。
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
1. **阅读 Provider 官方文档和标准参数定义**
|
||||
- 查看 Provider 的图像生成 API 文档
|
||||
- 阅读 `src/libs/standard-parameters/index.ts`
|
||||
- 在对应的 ai models 文件中增加 image model 配置
|
||||
|
||||
2. **在 Provider 类中实现 createImage 方法**
|
||||
- 直接在类中添加 `createImage` 方法
|
||||
- 处理参数转换和 API 调用
|
||||
- 返回统一格式的响应
|
||||
|
||||
3. **补充测试**
|
||||
- 为新方法编写完整的测试用例
|
||||
|
||||
**代码示例**:
|
||||
|
||||
```ts
|
||||
// src/libs/model-runtime/provider-name/index.ts
|
||||
export class LobeProviderAI {
|
||||
async createImage(
|
||||
payload: ImageGenerationPayload,
|
||||
options?: ChatStreamCallbacks,
|
||||
): Promise<ImageGenerationResponse> {
|
||||
const { model, prompt, ...params } = payload;
|
||||
|
||||
// 调用原生 API 并处理响应
|
||||
const result = await this.client.generateImage({
|
||||
model,
|
||||
prompt,
|
||||
// 参数转换
|
||||
});
|
||||
|
||||
return {
|
||||
created: Date.now(),
|
||||
data: [{ url: result.url }],
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
- **测试要求**:为自定义实现添加完整的单元测试,确保覆盖成功场景和各种错误情况
|
||||
- **错误处理**:统一使用 `AgentRuntimeError` 进行错误封装,保持错误信息的一致性
|
||||
@@ -431,6 +431,15 @@ table nextauth_verificationtokens {
|
||||
}
|
||||
}
|
||||
|
||||
table oauth_handoffs {
|
||||
id text [pk, not null]
|
||||
client varchar(50) [not null]
|
||||
payload jsonb [not null]
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
}
|
||||
|
||||
table oidc_access_tokens {
|
||||
id varchar(255) [pk, not null]
|
||||
data jsonb [not null]
|
||||
|
||||
@@ -625,4 +625,29 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
|
||||
- Default: `-`
|
||||
- Example: `-all,+qwq-32b,+deepseek-r1`
|
||||
|
||||
## FAL
|
||||
|
||||
### `ENABLED_FAL`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Enables FAL as a model provider by default. Set to `0` to disable the FAL service.
|
||||
- Default: `1`
|
||||
- Example: `0`
|
||||
|
||||
### `FAL_API_KEY`
|
||||
|
||||
- Type: Required
|
||||
- Description: This is the API key you applied for in the FAL service.
|
||||
- Default: -
|
||||
- Example: `fal-xxxxxx...xxxxxx`
|
||||
|
||||
### `FAL_MODEL_LIST`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Used to control the FAL 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,+fal-model-1,+fal-model-2=fal-special`
|
||||
|
||||
The above example disables all models first, then enables `fal-model-1` and `fal-model-2` (displayed as `fal-special`).
|
||||
|
||||
[model-list]: /docs/self-hosting/advanced/model-list
|
||||
|
||||
@@ -624,4 +624,29 @@ LobeChat 在部署时提供了丰富的模型服务商相关的环境变量,
|
||||
- 默认值:`-`
|
||||
- 示例:`-all,+qwq-32b,+deepseek-r1`
|
||||
|
||||
## FAL
|
||||
|
||||
### `ENABLED_FAL`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:默认启用 FAL 作为模型供应商,当设为 0 时关闭 FAL 服务
|
||||
- 默认值:`1`
|
||||
- 示例:`0`
|
||||
|
||||
### `FAL_API_KEY`
|
||||
|
||||
- 类型:必选
|
||||
- 描述:这是你在 FAL 服务中申请的 API 密钥
|
||||
- 默认值:-
|
||||
- 示例:`fal-xxxxxx...xxxxxx`
|
||||
|
||||
### `FAL_MODEL_LIST`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:用来控制 FAL 模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。模型定义语法规则与其他 provider 保持一致。
|
||||
- 默认值:`-`
|
||||
- 示例:`-all,+fal-model-1,+fal-model-2=fal-special`
|
||||
|
||||
上述示例表示先禁用所有模型,再启用 `fal-model-1` 和 `fal-model-2`(显示名为 `fal-special`)。
|
||||
|
||||
[model-list]: /zh/docs/self-hosting/advanced/model-list
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Resolving AI Image Generation Timeout on Vercel
|
||||
description: >-
|
||||
Learn how to resolve timeout issues when using AI image generation models like gpt-image-1 on Vercel by enabling Fluid Compute for extended execution time.
|
||||
|
||||
tags:
|
||||
- Vercel
|
||||
- AI Image Generation
|
||||
- Timeout
|
||||
- Fluid Compute
|
||||
- gpt-image-1
|
||||
---
|
||||
|
||||
# Resolving AI Image Generation Timeout on Vercel
|
||||
|
||||
## Problem Description
|
||||
|
||||
When using AI image generation models (such as `gpt-image-1`) on Vercel, you may encounter timeout errors. This occurs because AI image generation typically requires more than 1 minute to complete, which exceeds Vercel's default function execution time limit.
|
||||
|
||||
Common error symptoms include:
|
||||
|
||||
- Function timeout errors during image generation
|
||||
- Failed image generation requests after approximately 60 seconds
|
||||
- "Function execution timed out" messages
|
||||
|
||||
### Typical Log Symptoms
|
||||
|
||||
In your Vercel function logs, you may see entries like this:
|
||||
|
||||
```plaintext
|
||||
JUL 16 18:39:09.51 POST 504 /trpc/async/image.createImage
|
||||
Provider runtime map found for provider: openai
|
||||
```
|
||||
|
||||
The key indicators are:
|
||||
|
||||
- **Status Code**: `504` (Gateway Timeout)
|
||||
- **Endpoint**: `/trpc/async/image.createImage` or similar image generation endpoints
|
||||
- **Timing**: Usually occurs around 60 seconds after the request starts
|
||||
|
||||
## Solution: Enable Fluid Compute
|
||||
|
||||
For projects created before Vercel's dashboard update, you can resolve this issue by enabling Fluid Compute, which extends the maximum execution duration to 300 seconds.
|
||||
|
||||
### Steps to Enable Fluid Compute (Legacy Vercel Dashboard)
|
||||
|
||||
1. Go to your project dashboard on Vercel
|
||||
2. Navigate to the **Settings** tab
|
||||
3. Find the **Functions** section
|
||||
4. Enable **Fluid Compute** as shown in the screenshot below:
|
||||
|
||||

|
||||
|
||||
5. After enabling, the maximum execution duration will be extended to 300 seconds by default
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **For new projects**: Newer Vercel projects have Fluid Compute enabled by default, so this issue primarily affects legacy projects
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information about Vercel's function limitations and Fluid Compute:
|
||||
|
||||
- [Vercel Fluid Compute Documentation](https://vercel.com/docs/fluid-compute)
|
||||
- [Vercel Functions Limitations](https://vercel.com/docs/functions/limitations#max-duration)
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 解决 Vercel 上 AI 绘画生图超时问题
|
||||
description: 了解如何通过开启 Fluid Compute 来解决在 Vercel 上使用 gpt-image-1 等 AI 绘画模型时遇到的超时问题。
|
||||
tags:
|
||||
- Vercel
|
||||
- AI 绘画
|
||||
- 超时问题
|
||||
- Fluid Compute
|
||||
- gpt-image-1
|
||||
---
|
||||
|
||||
# 解决 Vercel 上 AI 绘画生图超时问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
在 Vercel 上使用 AI 绘画模型(如 `gpt-image-1`)时,您可能会遇到超时错误。这是因为 AI 绘画生成通常需要超过 1 分钟的时间,超出了 Vercel 默认的函数执行时间限制。
|
||||
|
||||
常见的错误症状包括:
|
||||
|
||||
- 图像生成过程中出现函数超时错误
|
||||
- 图像生成请求在大约 60 秒后失败
|
||||
- 出现 "函数执行超时" 的错误消息
|
||||
|
||||
### 典型的日志现象
|
||||
|
||||
在您的 Vercel 函数日志中,您可能会看到类似这样的条目:
|
||||
|
||||
```plaintext
|
||||
JUL 16 18:39:09.51 POST 504 /trpc/async/image.createImage
|
||||
Provider runtime map found for provider: openai
|
||||
```
|
||||
|
||||
关键指标包括:
|
||||
|
||||
- **状态码**: `504`(网关超时)
|
||||
- **端点**: `/trpc/async/image.createImage` 或类似的图像生成端点
|
||||
- **时间**: 通常在请求开始后约 60 秒出现
|
||||
|
||||
## 解决方案:开启 Fluid Compute
|
||||
|
||||
对于在 Vercel 控制台更新前创建的项目,您可以通过开启 Fluid Compute 来解决此问题,这将最大执行时长延长至 300 秒。
|
||||
|
||||
### 开启 Fluid Compute 的步骤(旧版 Vercel 控制台)
|
||||
|
||||
1. 前往您在 Vercel 上的项目控制台
|
||||
2. 进入 **Settings**(设置)选项卡
|
||||
3. 找到 **Functions**(函数)部分
|
||||
4. 按照下方截图所示开启 **Fluid Compute**:
|
||||
|
||||

|
||||
|
||||
5. 开启后,最大执行时长将默认延长至 300 秒
|
||||
|
||||
### 重要说明
|
||||
|
||||
- **新项目**:较新的 Vercel 项目默认已启用 Fluid Compute,因此此问题主要影响旧版项目
|
||||
|
||||
## 其他资源
|
||||
|
||||
有关 Vercel 函数限制和 Fluid Compute 的更多信息:
|
||||
|
||||
- [Vercel Fluid Compute 文档](https://vercel.com/docs/fluid-compute)
|
||||
- [Vercel 函数限制说明](https://vercel.com/docs/functions/limitations#max-duration)
|
||||
@@ -16,7 +16,7 @@ tags:
|
||||
|
||||
# Desktop Application
|
||||
|
||||
<Image alt={'Desktop Application'} borderless cover src={'https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96'}> />
|
||||
<Image alt={'Desktop Application'} borderless cover src={'https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96'} />
|
||||
|
||||
**Peak Performance, Zero Distractions**
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ tags:
|
||||
|
||||
# MCP Marketplace
|
||||
|
||||
<Image alt={'MCP Marketplace'} borderless cover src={'https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0'}> />
|
||||
<Image alt={'MCP Marketplace'} borderless cover src={'https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0'} />
|
||||
|
||||
**Discover, Connect, Expand**
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ This article will guide you on how to use AI21 Labs within LobeChat.
|
||||
### Step 2: Configure AI21 Labs in LobeChat
|
||||
|
||||
- Go to the `Settings` page in LobeChat
|
||||
- Under `Language Model`, find the setting for `AI21 Labs`
|
||||
- Under `AI Service Provider`, find the setting for `AI21 Labs`
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/9336d6c5-2a83-4aa9-854e-75e245b665cb'} />
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 AI21 Labs
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到 `AI21labs` 的设置项
|
||||
- 在`AI 服务商`下找到 `AI21labs` 的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/9336d6c5-2a83-4aa9-854e-75e245b665cb'} />
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ This article will guide you on how to use the 360AI in LobeChat.
|
||||
### Step 2: Configure 360AI in LobeChat
|
||||
|
||||
- Access the `Settings` interface in LobeChat
|
||||
- Under `Language Models`, find the option for `360`
|
||||
- Under `AI Service Provider`, find the option for `360`
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63'} />
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 360 智脑
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到 `360` 的设置项
|
||||
- 在`AI 服务商`下找到 `360` 的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63'} />
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ The Anthropic Claude API is now available for everyone to use. This document wil
|
||||
### Step 2: Configure Anthropic Claude in LobeChat
|
||||
|
||||
- Access the `Settings` interface in LobeChat.
|
||||
- Find the setting for `Anthropic Claude` under `Language Models`.
|
||||
- Find the setting for `Anthropic Claude` under `AI Service Provider`.
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff9c3eb8-412b-4275-80be-177ae7b7acbc'} />
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Anthropic Claude API 现在可供所有人使用,本文档将指导你如何
|
||||
### 步骤二:在 LobeChat 中配置 Anthropic Claude
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到`Anthropic Claude`的设置项
|
||||
- 在`AI 服务商`下找到`Anthropic Claude`的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff9c3eb8-412b-4275-80be-177ae7b7acbc'} />
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ This document will guide you on how to use [Azure OpenAI](https://oai.azure.com/
|
||||
### Step 2: Configure Azure OpenAI in LobeChat
|
||||
|
||||
- Access the `Settings` interface in LobeChat.
|
||||
- Find the setting for `Azure OpenAI` under `Language Model`.
|
||||
- Find the setting for `Azure OpenAI` under `AI Service Provider`.
|
||||
|
||||
<Image alt={'Enter the API key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/63d9f6d4-5b78-4c65-8cd1-ff8b7f143406'} />
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 Azure OpenAI
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到`Azure OpenAI`的设置项
|
||||
- 在`AI 服务商`下找到`Azure OpenAI`的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/63d9f6d4-5b78-4c65-8cd1-ff8b7f143406'} />
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ This article will guide you on how to use Baichuan in LobeChat:
|
||||
### Step 2: Configure Baichuan in LobeChat
|
||||
|
||||
- Visit the `Settings` interface in LobeChat
|
||||
- Find the setting for `Baichuan` under `Language Model`
|
||||
- Find the setting for `Baichuan` under `AI Service Provider`
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/dec6665a-b3ec-4c50-a57f-7c7eb3160e7b'} />
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置百川
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到`百川`的设置项
|
||||
- 在`AI 服务商`下找到`百川`的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/dec6665a-b3ec-4c50-a57f-7c7eb3160e7b'} />
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ This document will guide you on how to use Amazon Bedrock in LobeChat:
|
||||
### Step 3: Configure Amazon Bedrock in LobeChat
|
||||
|
||||
- Access the `Settings` interface in LobeChat
|
||||
- Find the setting for `Amazon Bedrock` under `Language Models` and open it
|
||||
- Find the setting for `Amazon Bedrock` under `AI Service Provider` and open it
|
||||
|
||||
<Image alt={'Enter Amazon Bedrock keys in LobeChat'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/7468594b-3355-4cb9-85bc-c9dace137653'} />
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Amazon Bedrock 是一个完全托管的基础模型 API 服务,允许用户通
|
||||
### 步骤三:在 LobeChat 中配置 Amazon Bedrock
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到`Amazon Bedrock`的设置项并打开
|
||||
- 在`AI 服务商`下找到`Amazon Bedrock`的设置项并打开
|
||||
|
||||
<Image alt={'LobeChat 中填写 Amazon Bedrock 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/7468594b-3355-4cb9-85bc-c9dace137653'} />
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ This document will guide you on how to use Cloudflare Workers AI in LobeChat:
|
||||
### Step 2: Configure Cloudflare Workers AI in LobeChat
|
||||
|
||||
- Go to the `Settings` interface in LobeChat.
|
||||
- Under `Language Model`, find the `Cloudflare` settings.
|
||||
- Under `AI Service Provider`, find the `Cloudflare` settings.
|
||||
|
||||
<Image alt={'Input API Token'} inStep src={'https://github.com/user-attachments/assets/82a7ebe0-69ad-43b6-8767-1316b443fa03'} />
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 Cloudflare Workers AI
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到 `Cloudflare` 的设置项
|
||||
- 在`AI 服务商`下找到 `Cloudflare` 的设置项
|
||||
|
||||
<Image alt={'填入访问令牌'} inStep src={'https://github.com/user-attachments/assets/82a7ebe0-69ad-43b6-8767-1316b443fa03'} />
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ This document will guide you on how to use DeepSeek in LobeChat:
|
||||
### Step 2: Configure DeepSeek in LobeChat
|
||||
|
||||
- Access the `App Settings` interface in LobeChat.
|
||||
- Find the setting for `DeepSeek` under `Language Models`.
|
||||
- Find the setting for `DeepSeek` under `AI Service Provider`.
|
||||
|
||||
<Image alt={'Enter Deepseek API Key'} inStep src={'https://github.com/user-attachments/assets/aaa3e2c5-7f16-4cfb-86b6-2814a1aafe3a'} />
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 DeepSeek
|
||||
|
||||
- 访问 LobeChat 的 `应用设置`界面
|
||||
- 在 `语言模型` 下找到 `DeepSeek` 的设置项
|
||||
- 在 `AI 服务商` 下找到 `DeepSeek` 的设置项
|
||||
|
||||
<Image alt={'填写 Deepseek API 密钥'} inStep src={'https://github.com/user-attachments/assets/aaa3e2c5-7f16-4cfb-86b6-2814a1aafe3a'} />
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Using Fal API Key in LobeChat
|
||||
description: >-
|
||||
Learn how to integrate Fal API Key in LobeChat for AI image and video generation using cutting-edge models like FLUX, Kling, and more.
|
||||
|
||||
tags:
|
||||
- Fal AI
|
||||
- Image Generation
|
||||
- Video Generation
|
||||
- API Key
|
||||
- Web UI
|
||||
---
|
||||
|
||||
# Using Fal in LobeChat
|
||||
|
||||
<Image alt={'Using Fal in LobeChat'} cover src={'https://hub-apac-1.lobeobjects.space/docs/f253e749baaa2ccac498014178f93091.png'} />
|
||||
|
||||
[Fal.ai](https://fal.ai/) is a lightning-fast inference platform specialized in AI media generation, hosting state-of-the-art models for image and video creation including FLUX, Kling, HiDream, and other cutting-edge generative models. This document will guide you on how to use Fal in LobeChat:
|
||||
|
||||
<Steps>
|
||||
### Step 1: Obtain Fal API Key
|
||||
|
||||
- Register for a [Fal.ai account](https://fal.ai/).
|
||||
- Navigate to [API Keys dashboard](https://fal.ai/dashboard/keys) and click **Add key** to create a new API key.
|
||||
- Copy the generated API key and keep it secure; it will only be shown once.
|
||||
|
||||
<Image
|
||||
alt={'Open the creation window'}
|
||||
inStep
|
||||
src={
|
||||
'https://hub-apac-1.lobeobjects.space/docs/3f3676e7f9c04a55603bc1174b636b45.png'
|
||||
}
|
||||
/>
|
||||
|
||||
<Image
|
||||
alt={'Create API Key'}
|
||||
inStep
|
||||
src={
|
||||
'https://hub-apac-1.lobeobjects.space/docs/214cc5019d9c0810951b33215349136e.png'
|
||||
}
|
||||
/>
|
||||
|
||||
<Image
|
||||
alt={'Retrieve API Key'}
|
||||
inStep
|
||||
src={
|
||||
'https://hub-apac-1.lobeobjects.space/docs/499a447e98dcc79407d56495d0305e2a.png'
|
||||
}
|
||||
/>
|
||||
|
||||
### Step 2: Configure Fal in LobeChat
|
||||
|
||||
- Visit the `Settings` page in LobeChat.
|
||||
- Under **AI Service Provider**, locate the **Fal** configuration section.
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/fa056feecba0133c76abe1ad12706c05.png'} />
|
||||
|
||||
- Paste the API key you obtained.
|
||||
- Choose a Fal model (e.g. `Flux.1 Schnell`, `Flux.1 Kontext Dev`) for image or video generation.
|
||||
|
||||
<Image alt={'Select Fal model for media generation'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/7560502f31b8500032922103fc22e69b.png'} />
|
||||
|
||||
<Callout type={'warning'}>
|
||||
During usage, you may incur charges according to Fal's pricing policy. Please review Fal's
|
||||
official pricing before heavy usage.
|
||||
</Callout>
|
||||
</Steps>
|
||||
|
||||
You can now use Fal's advanced image and video generation models directly within LobeChat to create stunning visual content.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 在 LobeChat 中使用 Fal API Key
|
||||
description: >-
|
||||
学习如何在 LobeChat 中配置和使用 Fal API Key,使用 FLUX、Kling 等尖端模型进行 AI 图像和视频生成。
|
||||
|
||||
tags:
|
||||
- Fal
|
||||
- 图像生成
|
||||
- 视频生成
|
||||
- API Key
|
||||
- Web UI
|
||||
---
|
||||
|
||||
# 在 LobeChat 中使用 Fal
|
||||
|
||||
<Image alt={'在 LobeChat 中使用 Fal'} cover src={'https://hub-apac-1.lobeobjects.space/docs/f253e749baaa2ccac498014178f93091.png'} />
|
||||
|
||||
[Fal.ai](https://fal.ai/) 是一个专门从事 AI 媒体生成的快速推理平台,提供包括 FLUX、Kling、HiDream 等在内的最先进图像和视频生成模型。本文将指导你如何在 LobeChat 中使用 Fal:
|
||||
|
||||
<Steps>
|
||||
### 步骤一:获取 Fal API Key
|
||||
|
||||
- 注册 [Fal.ai](https://fal.ai/) 账户;
|
||||
- 前往 [API Keys 控制台](https://fal.ai/dashboard/keys),点击 **Add key** 创建新的 API 密钥;
|
||||
- 复制生成的 API Key 并妥善保存,它只会显示一次。
|
||||
|
||||
<Image
|
||||
alt={'打开创建窗口'}
|
||||
inStep
|
||||
src={
|
||||
'https://hub-apac-1.lobeobjects.space/docs/3f3676e7f9c04a55603bc1174b636b45.png'
|
||||
}
|
||||
/>
|
||||
|
||||
<Image
|
||||
alt={'创建 API Key'}
|
||||
inStep
|
||||
src={
|
||||
'https://hub-apac-1.lobeobjects.space/docs/214cc5019d9c0810951b33215349136e.png'
|
||||
}
|
||||
/>
|
||||
|
||||
<Image
|
||||
alt={'获取 API Key'}
|
||||
inStep
|
||||
src={
|
||||
'https://hub-apac-1.lobeobjects.space/docs/499a447e98dcc79407d56495d0305e2a.png'
|
||||
}
|
||||
/>
|
||||
|
||||
### 步骤二:在 LobeChat 中配置 Fal
|
||||
|
||||
- 访问 LobeChat 的 `设置` 页面;
|
||||
- 在 `AI服务商` 下找到 `Fal` 的设置项;
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/fa056feecba0133c76abe1ad12706c05.png'} />
|
||||
|
||||
- 粘贴获取到的 API Key;
|
||||
- 选择一个 Fal 模型(如 `Flux.1 Schnell`、`Flux.1 Kontext Dev`)用于图像或视频生成。
|
||||
|
||||
<Image alt={'选择 Fal 模型进行媒体生成'} inStep src={'https://hub-apac-1.lobeobjects.space/docs/7560502f31b8500032922103fc22e69b.png'} />
|
||||
|
||||
<Callout type={'warning'}>
|
||||
在使用过程中,你可能需要向 Fal 支付相应费用,请在大量调用前查阅 Fal 的官方计费政策。
|
||||
</Callout>
|
||||
</Steps>
|
||||
|
||||
至此,你已经可以在 LobeChat 中使用 Fal 提供的先进图像和视频生成模型来创作精美的视觉内容了。
|
||||
@@ -39,7 +39,7 @@ This article will guide you on how to use Fireworks AI in LobeChat.
|
||||
### Step 2: Configure Fireworks AI in LobeChat
|
||||
|
||||
- Access the `Settings` interface in LobeChat
|
||||
- Under `Language Model`, locate the settings for `Fireworks AI`
|
||||
- Under `AI Service Provider`, locate the settings for `Fireworks AI`
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/12c1957d-f050-4235-95da-d55ddedfa6c9'} />
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 Fireworks AI
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到 `Fireworks AI` 的设置项
|
||||
- 在`AI 服务商`下找到 `Fireworks AI` 的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/12c1957d-f050-4235-95da-d55ddedfa6c9'} />
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ This article will guide you on how to use Gitee AI in LobeChat.
|
||||
### Step 2: Configure Gitee AI in LobeChat
|
||||
|
||||
- Access the `Settings` page in LobeChat
|
||||
- Under `Language Models`, find the settings for `Gitee AI`
|
||||
- Under `AI Service Provider`, find the settings for `Gitee AI`
|
||||
|
||||
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/eaa2a1fb-41ad-473d-ac10-a39c05886425'} />
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 Gitee AI
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到 `Gitee AI` 的设置项
|
||||
- 在`AI 服务商`下找到 `Gitee AI` 的设置项
|
||||
|
||||
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/eaa2a1fb-41ad-473d-ac10-a39c05886425'} />
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Currently, the usage of the Playground and free API is subject to limits on the
|
||||
### Step 2: Configure GitHub Models in LobeChat
|
||||
|
||||
- Navigate to the `Settings` interface in LobeChat.
|
||||
- Under `Language Models`, find the GitHub settings.
|
||||
- Under `AI Service Provider`, find the GitHub settings.
|
||||
|
||||
<Image alt={'Entering Access Token'} inStep src={'https://github.com/user-attachments/assets/a00f06cc-da7c-41e8-a4d5-d4b675a22673'} />
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ tags:
|
||||
### 步骤二:在 LobeChat 中配置 GitHub Models
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到 `GitHub` 的设置项
|
||||
- 在`AI 服务商`下找到 `GitHub` 的设置项
|
||||
|
||||
<Image alt={'填入访问令牌'} inStep src={'https://github.com/user-attachments/assets/a00f06cc-da7c-41e8-a4d5-d4b675a22673'} />
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ This document will guide you on how to use Google Gemini in LobeChat:
|
||||
### Step 2: Configure OpenAI in LobeChat
|
||||
|
||||
- Go to the `Settings` interface in LobeChat
|
||||
- Find the setting for `Google Gemini` under `Language Models`
|
||||
- Find the setting for `Google Gemini` under `AI Service Provider`
|
||||
|
||||
<Image alt={'Enter Google Gemini API Key in LobeChat'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/11442ce4-a615-49c4-937a-ca2ae93dd27c'} />
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Gemini AI 是由 Google AI 创建的一组大型语言模型(LLM),以其
|
||||
### 步骤二:在 LobeChat 中配置 OpenAI
|
||||
|
||||
- 访问 LobeChat 的`设置`界面
|
||||
- 在`语言模型`下找到`Google Gemini`的设置项
|
||||
- 在`AI 服务商`下找到`Google Gemini`的设置项
|
||||
|
||||
<Image alt={'LobeChat 中填写 Google Gemini API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/11442ce4-a615-49c4-937a-ca2ae93dd27c'} />
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ This document will guide you on how to use Groq in LobeChat:
|
||||
|
||||
### Configure Groq in LobeChat
|
||||
|
||||
You can find the Groq configuration option in `Settings` -> `Language Model`, where you can input the API Key you just obtained.
|
||||
You can find the Groq configuration option in `Settings` -> `AI Service Provider`, where you can input the API Key you just obtained.
|
||||
|
||||
<Image alt={'Groq service provider settings'} height={274} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/88948a3a-6681-4a8d-9734-a464e09e4957'} />
|
||||
</Steps>
|
||||
|
||||