Compare commits

..

3 Commits

Author SHA1 Message Date
arvinxx 3ed61d87da refactor claude code 2025-07-11 17:11:26 +08:00
arvinxx 45919e6db8 refactor claude code 2025-07-11 10:57:29 +08:00
arvinxx 01b411cbc3 init claude code 2025-07-10 22:35:19 +08:00
718 changed files with 5928 additions and 52238 deletions
+17 -93
View File
@@ -1,9 +1,8 @@
---
description:
description:
globs: src/services/**/*,src/database/**/*,src/server/**/*
alwaysApply: false
---
# LobeChat 后端技术架构指南
本指南旨在阐述 LobeChat 项目的后端分层架构,重点介绍各核心目录的职责以及它们之间的协作方式。
@@ -30,21 +29,24 @@ LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应
其主要分层如下:
1. 客户端服务层 (`src/services`):
- 位于 src/services/。
- 这是客户端业务逻辑的核心层,负责封装各种业务操作和数据处理逻辑。
- 环境适配: 根据不同的运行环境,服务层会选择合适的数据访问方式:
- 本地数据库模式: 直接调用 `Model` 层进行数据操作,适用于浏览器 PGLite 和本地 Electron 应用。
- 远程数据库模式: 通过 `tRPC` 客户端调用服务端 API,适用于需要云同步的场景。
- 本地数据库模式: 直接调用 `Model` 层进行数据操作,适用于浏览器 PGLite 和本地 Electron 应用。
- 远程数据库模式: 通过 `tRPC` 客户端调用服务端 API,适用于需要云同步的场景。
- 类型转换: 对于简单的数据类型转换,直接在此层进行类型断言,如 `this.pluginModel.query() as Promise<LobeTool[]>`
- 每个服务模块通常包含 `client.ts`(本地模式)、`server.ts`(远程模式)和 `type.ts`(接口定义)文件,在实现时应该确保本地模式和远程模式业务逻辑实现一致,只是数据库不同。
2. API 接口层 (`TRPC`):
- 位于 src/server/routers/
- 使用 `tRPC` 构建类型安全的 API。Router 根据运行时环境(如 Edge Functions, Node.js Lambda)进行组织。
- 负责接收客户端请求,并将其路由到相应的 `Service` 层进行处理。
- 新建 lambda 端点时可以参考 src/server/routers/lambda/\_template.ts
3. 仓库层 (`Repositories`):
- 位于 src/database/repositories/。
- 主要处理复杂的跨表查询和数据聚合逻辑,特别是当需要从多个 `Model` 获取数据并进行组合时。
- 与 `Model` 层不同,`Repository` 层专注于复杂的业务查询场景,而不涉及简单的领域模型转换。
@@ -52,6 +54,7 @@ LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应
- 如果数据操作简单(仅涉及单个 `Model`),则通常直接在 `src/services` 层调用 `Model` 并进行简单的类型断言。
4. 模型层 (`Models`):
- 位于 src/database/models/ (例如 src/database/models/plugin.ts 和 src/database/models/document.ts)。
- 提供对数据库中各个表(由 src/database/schemas/ 中的 Drizzle ORM schema 定义)的基本 CRUD (创建、读取、更新、删除) 操作和简单的查询能力。
- `Model` 类专注于单个数据表的直接操作,不涉及复杂的领域模型转换,这些转换通常在上层的 `src/services` 中通过类型断言完成。
@@ -62,11 +65,11 @@ LobeChat 的后端设计注重模块化、可测试性和灵活性,以适应
- 客户端模式 (浏览器/PWA): 使用 PGLite (基于 WASM 的 PostgreSQL),数据存储在用户浏览器本地。
- 服务端模式 (云部署): 使用远程 PostgreSQL 数据库。
- Electron 桌面应用:
- Electron 客户端会启动一个本地 Node.js 服务。
- 本地服务通过 `tRPC` 与 Electron 的渲染进程通信。
- 数据库选择依赖于是否开启云同步功能:
- 云同步开启: 连接到远程 PostgreSQL 数据库。
- 云同步关闭: 使用 PGLite (通过 Node.js 的 WASM 实现) 在本地存储数据。
- Electron 客户端会启动一个本地 Node.js 服务。
- 本地服务通过 `tRPC` 与 Electron 的渲染进程通信。
- 数据库选择依赖于是否开启云同步功能:
- 云同步开启: 连接到远程 PostgreSQL 数据库。
- 云同步关闭: 使用 PGLite (通过 Node.js 的 WASM 实现) 在本地存储数据。
## 数据流向说明
@@ -90,87 +93,8 @@ UI (Electron Renderer) → Zustand action → Client Service -> TRPC Client →
## 服务层 (Server Services)
- 位于 src/server/services/。
- 核心职责是封装独立的、可复用的业务逻辑单元。这些服务应易于测试。
- 平台差异抽象: 一个关键特性是通过其内部的 `impls` 子目录(例如 src/server/services/file/impls 包含 s3.ts 和 local.ts)来抹平不同运行环境带来的差异(例如云端使用 S3 存储,桌面版使用本地文件系统)。这使得上层(如 `tRPC` routers)无需关心底层具体实现。
- 目标是使 `tRPC` router 层的逻辑尽可能纯粹,专注于请求处理和业务流程编排。
- 服务可能会调用 `Repository` 层或直接调用 `Model` 层进行数据持久化和检索,也可能调用其他服务。
## 最佳实践 (Best Practices)
### 数据库操作封装原则
**连续的数据库操作应该封装到 Model 层**
当业务逻辑涉及多个相关的数据库操作时,建议将这些操作封装到 Model 层中,而不是在上层(Service 或 Router 层)中进行多次数据库调用。
**优势:**
- **代码复用**: Client DB 环境的 service 实现和 Server DB 的 lambda 层实现可以复用相同的 Model 方法
- **事务一致性**: 相关的数据库操作可以在同一个方法中管理,便于维护数据一致性
- **性能优化**: 减少数据库连接次数,提高查询效率
- **职责清晰**: Model 层专注数据访问,上层专注业务协调
**示例:**
```typescript
// ✅ 推荐:在 Model 层封装连续的数据库操作
class GenerationBatchModel {
async delete(id: string): Promise<{ deletedBatch: BatchItem; thumbnailUrls: string[] }> {
// 1. 查询相关数据
const batchWithGenerations = await this.db.query.generationBatches.findFirst({...});
// 2. 收集需要处理的数据
const thumbnailUrls = [...];
// 3. 执行删除操作
const [deletedBatch] = await this.db.delete(generationBatches)...;
return { deletedBatch, thumbnailUrls };
}
}
// ✅ 上层使用简洁
const { thumbnailUrls } = await model.delete(id);
await fileService.deleteFiles(thumbnailUrls);
```
### 文件操作与数据库操作的执行顺序
**删除操作原则:数据库删除在前,文件删除在后**
当业务逻辑同时涉及数据库记录和文件系统操作时,应该遵循"数据库优先"的原则。
**原因:**
- **用户体验优先**: 如果先删除文件再删除数据库记录,可能出现文件已删除但数据库记录仍存在的情况,用户访问时会遇到文件不存在的错误
- **影响程度较小**: 如果先删除数据库记录再删除文件,即使文件删除失败,用户也看不到这个记录,只是造成一些存储空间浪费,对用户体验影响更小
- **数据一致性**: 数据库记录是业务逻辑的核心,应该优先保证其一致性
**示例:**
```typescript
// ✅ 推荐:先删除数据库记录,再删除文件
async deleteGeneration(id: string) {
// 1. 先删除数据库记录
const deletedGeneration = await generationModel.delete(id);
// 2. 再删除相关文件
if (deletedGeneration.asset?.thumbnailUrl) {
await fileService.deleteFile(deletedGeneration.asset.thumbnailUrl);
}
}
// ❌ 不推荐:先删除文件
async deleteGeneration(id: string) {
const generation = await generationModel.findById(id);
// 如果这里删除成功,但后面数据库删除失败,用户会遇到访问错误
await fileService.deleteFile(generation.asset.thumbnailUrl);
await generationModel.delete(id); // 可能失败
}
```
**创建操作原则:数据库创建在前,文件操作在后**
创建操作同样应该优先处理数据库记录,确保数据的一致性和完整性。
- 位于 src/server/services/。
- 核心职责是封装独立的、可复用的业务逻辑单元。这些服务应易于测试。
- 平台差异抽象: 一个关键特性是通过其内部的 `impls` 子目录(例如 src/server/services/file/impls 包含 s3.ts 和 local.ts)来抹平不同运行环境带来的差异(例如云端使用 S3 存储,桌面版使用本地文件系统)。这使得上层(如 `tRPC` routers)无需关心底层具体实现。
- 目标是使 `tRPC` router 层的逻辑尽可能纯粹,专注于请求处理和业务流程编排。
- 服务可能会调用 `Repository` 层或直接调用 `Model` 层进行数据持久化和检索,也可能调用其他服务。
+34 -38
View File
@@ -1,14 +1,13 @@
---
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
@@ -17,57 +16,54 @@ 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.
- 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>
+35 -45
View File
@@ -1,25 +1,21 @@
---
description:
globs:
description:
globs:
alwaysApply: true
---
# Guide to Optimize Output(Response) Rendering
## File Path and Code Symbol Rendering
- When rendering file paths, use backtick wrapping instead of markdown links so they can be parsed as clickable links in Cursor IDE.
- Good: `src/components/Button.tsx`
- Bad: [src/components/Button.tsx](src/components/Button.tsx)
- Don't use line and column number in file path, this will make file path not clickable in Cursor IDE.
- Good: `src/components/Button.tsx` `10:20` (add a space between the file path and the line and column number)
- Bad: `src/components/Button.tsx:10:20`
- Good: `src/components/Button.tsx`
- Bad: [src/components/Button.tsx](mdc:src/components/Button.tsx)
- When rendering functions, variables, or other code symbols, use backtick wrapping so they can be parsed as navigable links in Cursor IDE
- Good: The `useState` hook in `MyComponent`
- Bad: The useState hook in MyComponent
- Good: The `useState` hook in `MyComponent`
- Bad: The useState hook in MyComponent
## Markdown Render
- don't use br tag to wrap in table cell
@@ -27,9 +23,9 @@ alwaysApply: true
## Terminal Command Output
- If terminal commands don't produce output, it's likely due to paging issues. Try piping the command to `cat` to ensure full output is displayed.
- Good: `git show commit_hash -- file.txt | cat`
- Good: `git log --oneline | cat`
- Reason: Some git commands use pagers by default, which may prevent output from being captured properly
- Good: `git show commit_hash -- file.txt | cat`
- Good: `git log --oneline | cat`
- Reason: Some git commands use pagers by default, which may prevent output from being captured properly
## Mermaid Diagram Generation: Strict Syntax Validation Checklist
@@ -48,56 +44,50 @@ Before producing any Mermaid diagram, you **must** compare your final code line-
### Checklist Details
#### Rule 1: Edge Labels Must Be Plain Text Only
#### Rule 1: Edge Labels Must Be Plain Text Only
> **Essence:** Anything inside `|...|` must contain pure, unformatted text. Absolutely NO Markdown, list markers, or parentheses/brackets allowed—these often cause rendering failures.
- **✅ Do:** `A -->|Process plain text data| B`
- **❌ Don't:** `A -->|1. Ordered list item| B` (No numbered lists)
- **❌ Don't:** `CC --"1. fetch('/api/...')"--> API` (No square brackets)
- **❌ Don't:** `A -->|- Unordered list item| B` (No hyphen lists)
- **❌ Don't:** `A -->|Transform (important)| B` (No parentheses)
- **❌ Don't:** `A -->|Transform [important]| B` (No square brackets)
#### Rule 2: Node Definition Handle Special Characters with Care
- **✅ Do:** `A -->|Process plain text data| B`
- **❌ Don't:** `A -->|1. Ordered list item| B` (No numbered lists)
- **❌ Don't:** `CC --"1. fetch('/api/...')"--> API` (No square brackets)
- **❌ Don't:** `A -->|- Unordered list item| B` (No hyphen lists)
- **❌ Don't:** `A -->|Transform (important)| B` (No parentheses)
- **❌ Don't:** `A -->|Transform [important]| B` (No square brackets)
#### Rule 2: Node Definition Handle Special Characters with Care
> **Essence:** When node text or subgraph titles contain special characters like `()` or `[]`, wrap the text in quotes to avoid conflicts with Mermaid shape syntax.
- **When your node text includes parentheses (e.g., 'React (JSX)'):**
- **✅ Do:** `I_REACT["<b>React component (JSX)</b>"]` (Quotes wrap all text)
- **❌ Don't:** `I_REACT(<b>React component (JSX)</b>)` (Wrong, Mermaid parses this as a shape)
- **❌ Don't:** `subgraph Plugin Features (Plugins)` (Wrong, subgraph titles with parentheses must also be wrapped in quotes)
#### Rule 3: Double Quotes in Text Must Be Escaped
- **When your node text includes parentheses (e.g., 'React (JSX)'):**
- **✅ Do:** `I_REACT["<b>React component (JSX)</b>"]` (Quotes wrap all text)
- **❌ Don't:** `I_REACT(<b>React component (JSX)</b>)` (Wrong, Mermaid parses this as a shape)
- **❌ Don't:** `subgraph Plugin Features (Plugins)` (Wrong, subgraph titles with parentheses must also be wrapped in quotes)
#### Rule 3: Double Quotes in Text Must Be Escaped
> **Essence:** Use `&quot;` for double quotes **inside node text**.
- **✅ Do:** `A[This node contains &quot;quotes&quot;]`
- **❌ Don't:** `A[This node contains "quotes"]`
#### Rule 4: All Formatting Must Use HTML Tags (NOT Markdown!)
- **✅ Do:** `A[This node contains &quot;quotes&quot;]`
- **❌ Don't:** `A[This node contains "quotes"]`
#### Rule 4: All Formatting Must Use HTML Tags (NOT Markdown!)
> **Essence:** For newlines, bold, and other text formatting in nodes, use HTML tags only. Markdown is not supported.
- **✅ Do (robust):** `A["<b>Bold</b> and <code>code</code><br>This is a new line"]`
- **❌ Don't (not rendered):** `C["# This is a heading"]`
- **❌ Don't (not rendered):** ``C["`const` means constant"]``
- **⚠️ Warning (unreliable):** `B["Markdown **bold** might sometimes work but DON'T rely on it"]`
#### Rule 5: No HTML Tags for Participants and Message Labels (Sequence Diagrams)
- **✅ Do (robust):** `A["<b>Bold</b> and <code>code</code><br>This is a new line"]`
- **❌ Don't (not rendered):** `C["# This is a heading"]`
- **❌ Don't (not rendered):** ``C["`const` means constant"]``
- **⚠️ Warning (unreliable):** `B["Markdown **bold** might sometimes work but DON'T rely on it"]`
#### Rule 5: No HTML Tags for Participants and Message Labels (Sequence Diagrams)
> **Important Addition:**
> In Mermaid sequence diagrams, you MUST NOT use any HTML tags (such as `<b>`, `<code>`, etc.) in:
>
> - `participant` display names (`as` part)
> - Message labels (the text after `:` in diagram flows)
>
> These tags are generally not rendered—they may appear as-is or cause compatibility issues.
- **✅ Do:** `participant A as Client`
- **❌ Don't:** `participant A as <b>Client</b>`
- **✅ Do:** `A->>B: 1. Establish connection`
- **❌ Don't:** `A->>B: 1. <code>Establish connection</code>`
- **✅ Do:** `participant A as Client`
- **❌ Don't:** `participant A as <b>Client</b>`
- **✅ Do:** `A->>B: 1. Establish connection`
- **❌ Don't:** `A->>B: 1. <code>Establish connection</code>`
---
@@ -0,0 +1,6 @@
---
description:
globs: src/locales/**/*
alwaysApply: false
---
read [i18n.mdc](mdc:.cursor/rules/i18n/i18n.mdc)
+7 -18
View File
@@ -1,16 +1,19 @@
---
description:
globs:
alwaysApply: true
---
## Project Description
You are developing an open-source, modern-design AI chat framework: lobe chat.
You are developing an open-source, modern-design AI chat framework: lobe chat.
Emoji logo: 🤯
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.
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:
@@ -42,17 +45,3 @@ The project uses the following technologies:
- 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/testing-guide.mdc) to learn test scripts.
+7 -16
View File
@@ -1,9 +1,8 @@
---
description:
globs:
description:
globs:
alwaysApply: true
---
# LobeChat Cursor Rules System Guide
This document explains how the LobeChat project's Cursor rules system works and serves as an index for manually accessible rules.
@@ -15,26 +14,22 @@ This document explains how the LobeChat project's Cursor rules system works and
## 📚 Four Ways to Access Rules
### 1. **Always Applied Rules** - `always_applied_workspace_rules`
- **What**: Core project guidelines that are always active
- **Content**: Project tech stack, basic coding standards, output formatting rules
- **Access**: No tools needed - automatically provided in every conversation
### 2. **Dynamic Context Rules** - `cursor_rules_context`
- **What**: Rules automatically matched based on files referenced in the conversation
- **Trigger**: Only when user **explicitly @ mentions files** or **opens files in Cursor**
- **Content**: May include brief descriptions or full rule content, depending on relevance
- **Access**: No tools needed - automatically updated when files are referenced
### 3. **Agent Requestable Rules** - `agent_requestable_workspace_rules`
- **What**: Detailed operational guides that can be requested on-demand
- **Access**: Use `fetch_rules` tool with rule names
- **Examples**: `debug`, `i18n/i18n`, `code-review`
### 4. **Manual Rules Index** - This file + `read_file`
- **What**: Additional rules not covered by the above mechanisms
- **Why needed**: Cursor's rule system only supports "agent request" or "auto attach" modes
- **Access**: Use `read_file` tool to read specific `.mdc` files
@@ -52,13 +47,10 @@ Use `read_file` to access rules from the index below when:
The following rules are available via `read_file` from the `.cursor/rules/` directory:
- `backend-architecture.mdc` Backend layer architecture and design guidelines
- `define-database-model.mdc` Database model definition guidelines
- `drizzle-schema-style-guide.mdc` Style guide for defining Drizzle ORM schemas
- `react-component.mdc` React component style guide and conventions
- `testing-guide.mdc` Comprehensive testing guide for Vitest environment
- `typescript.mdc` TypeScript code style guide
- `zustand-action-patterns.mdc` Recommended patterns for organizing Zustand actions
- `zustand-slice-organization.mdc` Best practices for structuring Zustand slices
- `drizzle-schema-style-guide.mdc` Style guide for defining Drizzle ORM schemas
- `react-component.mdc` React component style guide and conventions
## ❌ Common Misunderstandings to Avoid
@@ -70,7 +62,7 @@ The following rules are available via `read_file` from the `.cursor/rules/` dire
```
1. Start with always_applied_workspace_rules (automatic)
2. Check cursor_rules_context for auto-matched rules (automatic)
2. Check cursor_rules_context for auto-matched rules (automatic)
3. If you need specific guides: fetch_rules (manual)
4. If you identify gaps: consult this index → read_file (manual)
```
@@ -78,8 +70,7 @@ The following rules are available via `read_file` from the `.cursor/rules/` dire
## Example Decision Flow
**Scenario**: Working on a new Zustand store slice
1. Follow always_applied_workspace_rules ✅
2. If store files were @ mentioned → use cursor_rules_context rules ✅
2. If store files were @ mentioned → use cursor_rules_context rules ✅
3. Need detailed Zustand guidance → `read_file('.cursor/rules/zustand-slice-organization.mdc')` ✅
4. All rules apply simultaneously - no conflicts ✅
4. All rules apply simultaneously - no conflicts ✅
+3 -8
View File
@@ -1,9 +1,8 @@
---
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.
@@ -12,6 +11,7 @@ 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,8 +36,3 @@ 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.
+881
View File
@@ -0,0 +1,881 @@
---
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 两个环境下都验证通过
@@ -1,453 +0,0 @@
---
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 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
@@ -1,80 +0,0 @@
---
description: Electron IPC 接口测试策略
alwaysApply: false
---
### Electron IPC 接口测试策略 🖥️
对于涉及 Electron IPC 接口的测试,由于提供真实的 Electron 环境比较复杂,采用 **Mock 返回值** 的方式进行测试。
#### 基本 Mock 设置
```typescript
import { vi } from 'vitest';
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
// Mock Electron IPC 客户端
vi.mock('@/server/modules/ElectronIPCClient', () => ({
electronIpcClient: {
getFilePathById: vi.fn(),
deleteFiles: vi.fn(),
// 根据需要添加其他 IPC 方法
},
}));
```
#### 在测试中设置 Mock 行为
```typescript
beforeEach(() => {
// 重置所有 Mock
vi.resetAllMocks();
// 设置默认的 Mock 返回值
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue('/path/to/file.txt');
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
success: true,
});
});
```
#### 测试不同场景的示例
```typescript
it('应该处理文件删除成功的情况', async () => {
// 设置成功场景的 Mock
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
success: true,
});
const result = await service.deleteFiles(['desktop://file1.txt']);
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith(['desktop://file1.txt']);
expect(result.success).toBe(true);
});
it('应该处理文件删除失败的情况', async () => {
// 设置失败场景的 Mock
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValue(new Error('删除失败'));
const result = await service.deleteFiles(['desktop://file1.txt']);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
```
#### Mock 策略的优势
1. **环境简化**: 避免了复杂的 Electron 环境搭建
2. **测试可控**: 可以精确控制 IPC 调用的返回值和行为
3. **场景覆盖**: 容易测试各种成功/失败场景
4. **执行速度**: Mock 调用比真实 IPC 调用更快
#### 注意事项
- **Mock 准确性**: 确保 Mock 的行为与真实 IPC 接口行为一致
- **类型安全**: 使用 `vi.mocked()` 确保类型安全
- **Mock 重置**: 在 `beforeEach` 中重置 Mock 状态,避免测试间干扰
- **调用验证**: 不仅要验证返回值,还要验证 IPC 方法是否被正确调用
@@ -1,323 +0,0 @@
---
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` 对象结构。
```
## 📂 测试文件组织
### 文件命名约定
- **客户端测试**: `*.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` 块中添加测试,避免创建冗余顶级块
- **安全要求**: Model 测试必须包含权限检查,并在双环境下验证通过
-10
View File
@@ -36,16 +36,6 @@ config.overrides = [
'mdx/code-blocks': false,
},
},
{
files: ['src/store/image/**/*', 'src/types/generation/**/*'],
rules: {
'@typescript-eslint/no-empty-interface': 0,
'sort-keys-fix/sort-keys-fix': 0,
'typescript-sort-keys/interface': 0,
'typescript-sort-keys/string-enum': 0,
},
},
];
module.exports = config;
+3 -3
View File
@@ -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 目录设置到 C
TEMP: C:\temp
TMP: C:\temp
# 将 TEMP 和 TMP 目录设置到 D
TEMP: D:\temp
TMP: D:\temp
# Linux 平台构建处理
- name: Build artifact on Linux
+3 -3
View File
@@ -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 目录设置到 C
TEMP: C:\temp
TMP: C:\temp
# 将 TEMP 和 TMP 目录设置到 D
TEMP: D:\temp
TMP: D:\temp
# Linux 平台构建处理
- name: Build artifact on Linux
-5
View File
@@ -43,7 +43,6 @@ test-output
# misc
# add other ignore file below
CLAUDE.md
# local env files
.env*.local
@@ -72,7 +71,3 @@ public/swe-worker*
vertex-ai-key.json
.pnpm-store
./packages/lobe-ui
# for local prd docs
docs/prd
-453
View File
@@ -2,459 +2,6 @@
# Changelog
### [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">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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>
#### 🐛 Bug Fixes
- **misc**: Some ai image generation feedback issues.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Some ai image generation feedback issues, closes [#8440](https://github.com/lobehub/lobe-chat/issues/8440) ([bc41329](https://github.com/lobehub/lobe-chat/commit/bc41329))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.99.1](https://github.com/lobehub/lobe-chat/compare/v1.99.0...v1.99.1)
<sup>Released on **2025-07-15**</sup>
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 1.99.0](https://github.com/lobehub/lobe-chat/compare/v1.98.2...v1.99.0)
<sup>Released on **2025-07-14**</sup>
#### ✨ Features
- **plugin**: Support Streamable HTTP MCP Server Auth.
- **misc**: support AI Image.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **plugin**: Support Streamable HTTP MCP Server Auth, closes [#8425](https://github.com/lobehub/lobe-chat/issues/8425) ([853a09a](https://github.com/lobehub/lobe-chat/commit/853a09a))
- **misc**: support AI Image, closes [#8312](https://github.com/lobehub/lobe-chat/issues/8312) ([095de57](https://github.com/lobehub/lobe-chat/commit/095de57))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.98.2](https://github.com/lobehub/lobe-chat/compare/v1.98.1...v1.98.2)
<sup>Released on **2025-07-14**</sup>
#### 💄 Styles
- **misc**: Update i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Update i18n, closes [#8422](https://github.com/lobehub/lobe-chat/issues/8422) ([5b89ec8](https://github.com/lobehub/lobe-chat/commit/5b89ec8))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.98.1](https://github.com/lobehub/lobe-chat/compare/v1.98.0...v1.98.1)
<sup>Released on **2025-07-14**</sup>
#### 💄 Styles
- **misc**: Fix discover translation.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Fix discover translation, closes [#8423](https://github.com/lobehub/lobe-chat/issues/8423) ([15ae35c](https://github.com/lobehub/lobe-chat/commit/15ae35c))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 1.98.0](https://github.com/lobehub/lobe-chat/compare/v1.97.17...v1.98.0)
<sup>Released on **2025-07-13**</sup>
#### ✨ Features
- **misc**: Add network proxy for desktop.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **misc**: Add network proxy for desktop, closes [#7848](https://github.com/lobehub/lobe-chat/issues/7848) ([46d2509](https://github.com/lobehub/lobe-chat/commit/46d2509))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.17](https://github.com/lobehub/lobe-chat/compare/v1.97.16...v1.97.17)
<sup>Released on **2025-07-13**</sup>
#### 💄 Styles
- **misc**: Support Hunyuan A13B thinking model.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Support Hunyuan A13B thinking model, closes [#8278](https://github.com/lobehub/lobe-chat/issues/8278) ([09ca978](https://github.com/lobehub/lobe-chat/commit/09ca978))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.16](https://github.com/lobehub/lobe-chat/compare/v1.97.15...v1.97.16)
<sup>Released on **2025-07-13**</sup>
#### 💄 Styles
- **misc**: Update i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Update i18n, closes [#8410](https://github.com/lobehub/lobe-chat/issues/8410) ([2515875](https://github.com/lobehub/lobe-chat/commit/2515875))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.15](https://github.com/lobehub/lobe-chat/compare/v1.97.14...v1.97.15)
<sup>Released on **2025-07-12**</sup>
#### 🐛 Bug Fixes
- **misc**: Add vision support to Grok 4.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Add vision support to Grok 4, closes [#8386](https://github.com/lobehub/lobe-chat/issues/8386) ([8512f5a](https://github.com/lobehub/lobe-chat/commit/8512f5a))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.14](https://github.com/lobehub/lobe-chat/compare/v1.97.13...v1.97.14)
<sup>Released on **2025-07-12**</sup>
#### 🐛 Bug Fixes
- **misc**: Revert "💄 style: Open new topic by tap Just Chat again".
#### 💄 Styles
- **misc**: Add Kimi K2 model.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Revert "💄 style: Open new topic by tap Just Chat again", closes [#8402](https://github.com/lobehub/lobe-chat/issues/8402) ([55462b9](https://github.com/lobehub/lobe-chat/commit/55462b9))
#### Styles
- **misc**: Add Kimi K2 model, closes [#8401](https://github.com/lobehub/lobe-chat/issues/8401) ([4cb1a18](https://github.com/lobehub/lobe-chat/commit/4cb1a18))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.13](https://github.com/lobehub/lobe-chat/compare/v1.97.12...v1.97.13)
<sup>Released on **2025-07-12**</sup>
#### 💄 Styles
- **misc**: Support new Doubao thinking models, update i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Support new Doubao thinking models, closes [#8174](https://github.com/lobehub/lobe-chat/issues/8174) ([637d75c](https://github.com/lobehub/lobe-chat/commit/637d75c))
- **misc**: Update i18n, closes [#8400](https://github.com/lobehub/lobe-chat/issues/8400) ([790eeb8](https://github.com/lobehub/lobe-chat/commit/790eeb8))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.12](https://github.com/lobehub/lobe-chat/compare/v1.97.11...v1.97.12)
<sup>Released on **2025-07-11**</sup>
#### 🐛 Bug Fixes
- **misc**: Grok-4 reasoning model universal matching.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Grok-4 reasoning model universal matching, closes [#8390](https://github.com/lobehub/lobe-chat/issues/8390) ([d6f17f8](https://github.com/lobehub/lobe-chat/commit/d6f17f8))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.11](https://github.com/lobehub/lobe-chat/compare/v1.97.10...v1.97.11)
<sup>Released on **2025-07-11**</sup>
#### 💄 Styles
- **misc**: Open new topic by tap Just Chat again.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Open new topic by tap Just Chat again, closes [#8311](https://github.com/lobehub/lobe-chat/issues/8311) ([7e2f4ce](https://github.com/lobehub/lobe-chat/commit/7e2f4ce))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.10](https://github.com/lobehub/lobe-chat/compare/v1.97.9...v1.97.10)
<sup>Released on **2025-07-11**</sup>
#### 💄 Styles
- **misc**: Update i18n.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Update i18n, closes [#8387](https://github.com/lobehub/lobe-chat/issues/8387) ([00215c0](https://github.com/lobehub/lobe-chat/commit/00215c0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 1.97.9](https://github.com/lobehub/lobe-chat/compare/v1.97.8...v1.97.9)
<sup>Released on **2025-07-10**</sup>
+1 -2
View File
@@ -1,6 +1,5 @@
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/
ignore-workspace-root-check=true
-7
View File
@@ -24,13 +24,6 @@ LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平
pnpm install-isolated
```
### 配置环境变量
复制 `.env.desktop``.env`
> [!WARNING]
> 注意提前备份好 `.env` 文件,避免丢失配置。
### 开发模式运行
```bash
@@ -0,0 +1,229 @@
# Claude Code Integration
This document describes the Claude Code SDK integration in LobeChat Desktop application.
## Overview
Claude Code SDK enables running Claude Code as a subprocess, providing AI-powered coding assistance capabilities. The integration supports:
- **Multi-turn conversations** with context retention
- **File operations** (read/write)
- **Code execution** through bash commands
- **Session management** and continuation
- **Real-time streaming** responses
- **Cost tracking** per session
## Accessing Claude Code
In the LobeChat Desktop application, you can access Claude Code through:
1. **Sidebar Navigation**: Click the code icon (`</>`) in the sidebar (desktop only)
2. **Direct URL**: Navigate to `/claude-code` in the application
The Claude Code interface provides:
- A code editor for writing prompts
- Real-time streaming message display
- Session management with history
- Cost tracking and usage statistics
## Architecture
### Components
1. **IPC Layer** (`packages/electron-client-ipc`)
- Type definitions for Claude Code events
- IPC event interfaces for main/render communication
2. **Main Process Controller** (`apps/desktop/src/main/controllers/ClaudeCodeCtr.ts`)
- Handles Claude Code SDK integration
- Manages streaming sessions and abort controllers
- Tracks session history
3. **React Hook** (`src/hooks/useClaudeCode.ts`)
- Provides easy-to-use interface for React components
- Handles IPC communication with main process
- Manages streaming state and events
4. **UI Page** (`src/app/[variants]/(main)/claude-code/`)
- User interface for interacting with Claude Code
- Query editor with syntax highlighting
- Session management interface
- Real-time message streaming display
## Setup
### Prerequisites
1. Install Claude Code SDK dependency:
```bash
npm install @anthropic-ai/claude-code
```
2. Set up authentication:
```bash
# Option 1: Anthropic API Key
export ANTHROPIC_API_KEY="your-api-key"
# Option 2: Amazon Bedrock
export CLAUDE_CODE_USE_BEDROCK=1
# Configure AWS credentials
# Option 3: Google Vertex AI
export CLAUDE_CODE_USE_VERTEX=1
# Configure Google Cloud credentials
```
## Usage
### Basic Query
```typescript
import { useClaudeCode } from '@/hooks/useClaudeCode';
const MyComponent = () => {
const { query, isLoading } = useClaudeCode();
const handleQuery = async () => {
const result = await query('Write a function to calculate Fibonacci numbers', {
maxTurns: 3,
outputFormat: 'json',
});
console.log(result.messages);
console.log(result.sessionId);
};
};
```
### Streaming Query
```typescript
const { startStreamingQuery, isLoading } = useClaudeCode({
onStreamMessage: (message) => {
console.log('New message:', message);
},
onStreamComplete: (sessionId) => {
console.log('Stream completed:', sessionId);
},
onStreamError: (error) => {
console.error('Stream error:', error);
},
});
const handleStream = async () => {
await startStreamingQuery('Build a React component', {
maxTurns: 5,
outputFormat: 'stream-json',
allowedTools: ['Read', 'Write', 'Bash'],
});
};
```
### Session Management
```typescript
const { recentSessions, fetchRecentSessions, clearSession } = useClaudeCode();
// Get recent sessions
await fetchRecentSessions();
// Continue a previous session
await startStreamingQuery('Continue', {
resumeSessionId: session.sessionId,
});
// Clear a session
await clearSession(sessionId);
```
## IPC Events
### Client Dispatch Events (Renderer → Main)
- `claudeCodeQuery` - Execute a Claude Code query
- `claudeCodeStreamStart` - Start a streaming query
- `claudeCodeStreamStop` - Stop an active stream
- `claudeCodeCreateAbortController` - Create abort controller
- `claudeCodeAbort` - Trigger abort
- `claudeCodeGetRecentSessions` - Get session history
- `claudeCodeClearSession` - Clear a specific session
- `claudeCodeCheckAvailability` - Check if Claude Code is available
### Broadcast Events (Main → Renderer)
- `claudeCodeStreamMessage` - Stream message event
- `claudeCodeStreamComplete` - Stream completion event
- `claudeCodeStreamError` - Stream error event
## Configuration Options
```typescript
interface ClaudeCodeOptions {
maxTurns?: number; // Maximum conversation turns
systemPrompt?: string; // Override system prompt
appendSystemPrompt?: string; // Append to system prompt
cwd?: string; // Working directory
allowedTools?: string[] | string; // Allowed tools
disallowedTools?: string[] | string; // Disallowed tools
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
outputFormat?: 'text' | 'json' | 'stream-json';
inputFormat?: 'text' | 'stream-json';
mcpConfig?: string; // MCP configuration file path
permissionPromptTool?: string; // MCP tool for permissions
verbose?: boolean; // Enable verbose logging
continueLastSession?: boolean; // Continue last session
resumeSessionId?: string; // Resume specific session
}
```
## Message Types
```typescript
interface ClaudeCodeMessage {
type: 'assistant' | 'user' | 'system' | 'result';
message?: any;
session_id?: string;
subtype?: string;
duration_ms?: number;
duration_api_ms?: number;
is_error?: boolean;
num_turns?: number;
result?: string;
total_cost_usd?: number;
apiKeySource?: string;
cwd?: string;
tools?: string[];
mcp_servers?: Array<{ name: string; status: string }>;
model?: string;
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
}
```
## Best Practices
1. **Always check availability** before using Claude Code
2. **Handle errors gracefully** - both sync and async errors
3. **Use abort controllers** for long-running operations
4. **Monitor costs** through session tracking
5. **Clean up sessions** when no longer needed
6. **Set appropriate tool permissions** based on use case
## Troubleshooting
### Claude Code not available
1. Check if running in Electron desktop app
2. Verify API key is set correctly
3. Check environment variables
### Streaming not working
1. Ensure proper event listeners are set up
2. Check for abort controller conflicts
3. Verify stream ID is unique
### Session continuation fails
1. Check if session ID is valid
2. Ensure session hasn't been cleared
3. Verify prompt is appropriate for continuation
+1 -6
View File
@@ -24,12 +24,7 @@ const config = {
appImage: {
artifactName: '${productName}-${version}.${ext}',
},
asar: false,
asarUnpack: [
// https://github.com/electron-userland/electron-builder/issues/9001#issuecomment-2778802044
'**/node_modules/sharp/**/*',
'**/node_modules/@img/**/*',
],
asar: true,
detectUpdateChannel: true,
directories: {
buildResources: 'build',
+6 -13
View File
@@ -1,6 +1,6 @@
{
"name": "lobehub-desktop-dev",
"version": "0.0.0",
"version": "0.0.10",
"description": "LobeHub Desktop Application",
"homepage": "https://lobehub.com",
"repository": {
@@ -14,7 +14,6 @@
"build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.js --publish never",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.js --publish never",
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.js --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.js --publish never",
"electron:dev": "electron-vite dev",
"electron:run-unpack": "electron .",
@@ -25,7 +24,6 @@
"lint": "eslint --cache ",
"pg-server": "bun run scripts/pglite-server.ts",
"start": "electron-vite preview",
"test": "vitest --run",
"typecheck": "tsgo --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -47,10 +45,10 @@
"@types/resolve": "^1.20.6",
"@types/semver": "^7.7.0",
"@types/set-cookie-parser": "^2.4.10",
"@typescript/native-preview": "7.0.0-dev.20250711.1",
"@typescript/native-preview": "latest",
"consola": "^3.1.0",
"cookie": "^1.0.2",
"electron": "~37.1.0",
"electron": "^37.2.0",
"electron-builder": "^26.0.12",
"electron-is": "^3.0.0",
"electron-log": "^5.3.3",
@@ -58,24 +56,19 @@
"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",
"pglite-server": "^0.1.4",
"resolve": "^1.22.8",
"semver": "^7.5.4",
"set-cookie-parser": "^2.7.1",
"tsx": "^4.19.3",
"typescript": "^5.7.3",
"undici": "^7.9.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
"vite": "^6.2.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-builder"
"electron"
]
}
}
+14
View File
@@ -0,0 +1,14 @@
import { PGlite } from "@electric-sql/pglite";
import { createServer } from "pglite-server";
// 创建或连接到您现有的 PGlite 数据库
const db = new PGlite("/Users/arvinxx/Library/Application Support/lobehub-desktop/lobehub-local-db");
await db.waitReady;
// 创建服务器并监听端口
const PORT = 6543;
const pgServer = createServer(db);
pgServer.listen(PORT, () => {
console.log(`PGlite 服务器已启动,监听端口 ${PORT}`);
});
+2 -2
View File
@@ -13,7 +13,7 @@ export const appBrowsers = {
identifier: 'chat',
keepAlive: true,
minWidth: 400,
path: '/chat',
path: '/claude-code',
showOnInit: true,
titleBarStyle: 'hidden',
vibrancy: 'under-window',
@@ -36,7 +36,7 @@ export const appBrowsers = {
autoHideMenuBar: true,
height: 800,
identifier: 'settings',
// keepAlive: true,
keepAlive: true,
minWidth: 600,
parentIdentifier: 'chat',
path: '/settings',
-3
View File
@@ -27,6 +27,3 @@ export const LOCAL_DATABASE_DIR = 'lobehub-local-db';
export const FILE_STORAGE_DIR = 'file-storage';
// Plugin 安装目录
export const INSTALL_PLUGINS_DIR = 'plugins';
// Desktop file service
export const LOCAL_STORAGE_URL_PREFIX = '/lobe-desktop-file';
-12
View File
@@ -1,8 +1,6 @@
/**
* 应用设置存储相关常量
*/
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { appStorageDir } from '@/const/dir';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import { ElectronMainStore } from '@/types/store';
@@ -12,15 +10,6 @@ import { ElectronMainStore } from '@/types/store';
*/
export const STORE_NAME = 'lobehub-settings';
export const defaultProxySettings: NetworkProxySettings = {
enableProxy: false,
proxyBypass: 'localhost, 127.0.0.1, ::1',
proxyPort: '',
proxyRequireAuth: false,
proxyServer: '',
proxyType: 'http',
};
/**
* 存储默认值
*/
@@ -28,7 +17,6 @@ export const STORE_DEFAULTS: ElectronMainStore = {
dataSyncConfig: { storageMode: 'local' },
encryptedTokens: {},
locale: 'auto',
networkProxy: defaultProxySettings,
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
storagePath: appStorageDir,
};
+111 -310
View File
@@ -1,9 +1,10 @@
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
import { BrowserWindow, shell } from 'electron';
import { BrowserWindow, app, 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';
@@ -12,9 +13,10 @@ import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:AuthCtr');
const protocolPrefix = `com.lobehub.${name}`;
/**
* Authentication Controller
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
* Used to implement the OAuth authorization flow
*/
export default class AuthCtr extends ControllerModule {
/**
@@ -30,29 +32,9 @@ export default class AuthCtr extends ControllerModule {
private codeVerifier: string | null = null;
private authRequestState: string | null = null;
/**
* 轮询相关参数
*/
// 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();
}
beforeAppReady = () => {
this.registerProtocolHandler();
};
/**
* Request OAuth authorization
@@ -61,9 +43,6 @@ 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}`,
);
@@ -78,11 +57,8 @@ export default class AuthCtr extends ControllerModule {
this.authRequestState = crypto.randomBytes(16).toString('hex');
logger.debug(`Generated state parameter: ${this.authRequestState}`);
// Construct authorization URL with new redirect_uri
// Construct authorization URL
const authUrl = new URL('/oidc/auth', remoteUrl);
const redirectUri = this.constructRedirectUri(remoteUrl);
logger.info('redirectUri', redirectUri);
// Add query parameters
authUrl.search = querystring.stringify({
@@ -90,9 +66,7 @@ export default class AuthCtr extends ControllerModule {
code_challenge: codeChallenge,
code_challenge_method: 'S256',
prompt: 'consent',
redirect_uri: redirectUri,
// https://github.com/lobehub/lobe-chat/pull/8450
resource: 'urn:lobehub:chat',
redirect_uri: `${protocolPrefix}://auth/callback`,
response_type: 'code',
scope: 'profile email offline_access',
state: this.authRequestState,
@@ -104,9 +78,6 @@ 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);
@@ -115,188 +86,85 @@ export default class AuthCtr extends ControllerModule {
}
/**
* 启动轮询机制获取凭证
* Handle authorization callback
* This method is called when the browser redirects to our custom protocol
*/
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;
}
async handleAuthCallback(callbackUrl: string) {
logger.info(`Handling authorization callback: ${callbackUrl}`);
try {
// 使用缓存的远程服务器 URL
const remoteUrl = this.cachedRemoteUrl;
const url = new URL(callbackUrl);
const params = new URLSearchParams(url.search);
// 构造请求 URL
const url = new URL('/oidc/handoff', remoteUrl);
url.searchParams.set('id', this.authRequestState);
url.searchParams.set('client', 'desktop');
// Get authorization code
const code = params.get('code');
const state = params.get('state');
logger.debug(`Got parameters from callback URL: code=${code}, state=${state}`);
logger.debug(`Polling for credentials: ${url.toString()}`);
// 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');
// 直接发送 HTTP 请求
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
});
// 检查响应状态
if (response.status === 404) {
// 凭证还未准备好,这是正常情况
return null;
if (!code) {
logger.error('No authorization code received');
throw new Error('No authorization code received');
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
// 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');
}
// 解析响应数据
const data = (await response.json()) as {
data: {
id: string;
payload: { code: string; state: string };
};
success: boolean;
};
// 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');
if (data.success && data.data?.payload) {
logger.debug('Successfully retrieved credentials from handoff');
return {
code: data.data.payload.code,
state: data.data.payload.state,
};
// 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');
}
return null;
return result;
} catch (error) {
logger.debug('Polling attempt failed (this is normal):', error.message);
return null;
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;
}
}
/**
* Refresh access token
*/
@ipcClientEvent('refreshAccessToken')
async refreshAccessToken() {
logger.info('Starting to refresh access token');
try {
@@ -307,8 +175,6 @@ 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
@@ -322,7 +188,6 @@ 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 });
@@ -333,15 +198,48 @@ 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) {
if (!this.cachedRemoteUrl) {
throw new Error('No cached remote URL available for token exchange');
}
const remoteUrl = this.cachedRemoteUrl;
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
logger.info('Starting to exchange authorization code for token');
try {
const tokenUrl = new URL('/oidc/token', remoteUrl);
@@ -353,7 +251,7 @@ export default class AuthCtr extends ControllerModule {
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: this.constructRedirectUri(remoteUrl),
redirect_uri: `${protocolPrefix}://auth/callback`,
});
logger.debug('Sending token exchange request');
@@ -374,20 +272,10 @@ export default class AuthCtr extends ControllerModule {
throw new Error(errorMessage);
}
let data;
// Parse response
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()} `,
);
}
const data = await response.json();
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) {
@@ -397,20 +285,13 @@ 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,
data.expires_in,
);
await this.remoteServerConfigCtr.saveTokens(data.access_token, data.refresh_token);
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);
@@ -509,84 +390,4 @@ 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);
}
}
}
@@ -0,0 +1,361 @@
import {
ClaudeCodeMessage,
ClaudeCodeOptions,
ClaudeCodeQueryParams,
ClaudeCodeQueryResult,
ClaudeCodeSessionInfo,
ClaudeCodeStreamingParams,
} from '@lobechat/electron-client-ipc';
import { app } from 'electron';
import { join } from 'node:path';
import { createClaudeCodeModule } from '@/modules/claudeCode';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
const logger = createLogger('controllers:ClaudeCodeCtr');
interface StreamingSession {
abortController: AbortController;
sessionId?: string;
streamId: string;
}
export default class ClaudeCodeCtr extends ControllerModule {
private claudeCodeModule = createClaudeCodeModule({
debugMode: Boolean(process.env.DEBUG),
});
private streamingSessions = new Map<string, StreamingSession>();
private abortControllers = new Map<string, AbortController>();
private sessionHistory = new Map<string, ClaudeCodeSessionInfo>();
/**
* 检查 Claude Code 是否可用
*/
@ipcClientEvent('claudeCodeCheckAvailability')
async checkAvailability(): Promise<{
apiKeySource?: string;
available: boolean;
error?: string;
version?: string;
}> {
try {
return await this.claudeCodeModule.checkAvailability();
} catch (error) {
logger.error('Error checking Claude Code availability:', error);
return {
available: false,
error: error.message,
};
}
}
/**
* 执行 Claude Code 查询
*/
@ipcClientEvent('claudeCodeQuery')
async executeQuery(params: ClaudeCodeQueryParams): Promise<ClaudeCodeQueryResult> {
try {
logger.info('Executing Claude Code query:', params.prompt);
const abortController = params.abortSignal
? this.abortControllers.get(params.abortSignal)
: new AbortController();
const messages: ClaudeCodeMessage[] = [];
let sessionId: string | undefined;
const queryParams = {
abortController,
options: this.buildOptions(params.options),
prompt: params.prompt,
};
for await (const message of this.claudeCodeModule.query(queryParams)) {
messages.push(message);
if (message.session_id) {
sessionId = message.session_id;
}
}
// 更新会话历史
if (sessionId) {
this.updateSessionHistory(sessionId, messages);
}
return {
messages,
sessionId: sessionId || '',
success: true,
};
} catch (error) {
logger.error('Error executing Claude Code query:', error);
return {
error: error.message,
messages: [],
sessionId: '',
success: false,
};
}
}
/**
* 开始流式查询
*/
@ipcClientEvent('claudeCodeStreamStart')
async startStreamingQuery(
params: ClaudeCodeStreamingParams,
): Promise<{ error?: string; success: boolean }> {
try {
logger.info('Starting streaming Claude Code query:', params.streamId);
const abortController = params.abortSignal
? this.abortControllers.get(params.abortSignal)
: new AbortController();
const session: StreamingSession = {
abortController,
streamId: params.streamId,
};
this.streamingSessions.set(params.streamId, session);
// 在后台执行流式查询
this.executeStreamingQuery(params, abortController);
return { success: true };
} catch (error) {
logger.error('Error starting streaming query:', error);
return { error: error.message, success: false };
}
}
/**
* 停止流式查询
*/
@ipcClientEvent('claudeCodeStreamStop')
async stopStreamingQuery(streamId: string): Promise<{ success: boolean }> {
try {
logger.info('Stopping streaming query:', streamId);
const session = this.streamingSessions.get(streamId);
if (session) {
session.abortController.abort();
this.streamingSessions.delete(streamId);
}
return { success: true };
} catch (error) {
logger.error('Error stopping streaming query:', error);
return { success: false };
}
}
/**
* 创建 AbortController
*/
@ipcClientEvent('claudeCodeCreateAbortController')
createAbortController(): { signalId: string } {
const signalId = `abort-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const abortController = new AbortController();
this.abortControllers.set(signalId, abortController);
logger.debug('Created AbortController:', signalId);
// 清理过期的 AbortController30分钟后)
setTimeout(
() => {
this.abortControllers.delete(signalId);
},
30 * 60 * 1000,
);
return { signalId };
}
/**
* 触发 abort
*/
@ipcClientEvent('claudeCodeAbort')
abort(signalId: string): { success: boolean } {
try {
const abortController = this.abortControllers.get(signalId);
if (abortController) {
abortController.abort();
this.abortControllers.delete(signalId);
logger.debug('Aborted signal:', signalId);
return { success: true };
}
return { success: false };
} catch (error) {
logger.error('Error aborting:', error);
return { success: false };
}
}
/**
* 获取最近的会话列表
*/
@ipcClientEvent('claudeCodeGetRecentSessions')
getRecentSessions(): ClaudeCodeSessionInfo[] {
const sessions = Array.from(this.sessionHistory.values());
// 按最后活跃时间排序
sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
// 返回最近 20 个会话
return sessions.slice(0, 20);
}
/**
* 清除指定会话
*/
@ipcClientEvent('claudeCodeClearSession')
clearSession(sessionId: string): { success: boolean } {
try {
this.sessionHistory.delete(sessionId);
logger.debug('Cleared session:', sessionId);
return { success: true };
} catch (error) {
logger.error('Error clearing session:', error);
return { success: false };
}
}
/**
* 执行流式查询(后台)
*/
private async executeStreamingQuery(
params: ClaudeCodeStreamingParams,
abortController: AbortController,
) {
try {
const { streamId } = params;
let sessionId: string | undefined;
let messageCount = 0;
logger.debug('Starting streaming query execution for stream:', streamId);
const queryParams = {
abortController,
options: this.buildOptions(params.options),
prompt: params.prompt,
};
try {
for await (const message of this.claudeCodeModule.query(queryParams)) {
messageCount++;
logger.debug(`Stream ${streamId} - Message ${messageCount}:`, message.type);
logger.debug('output message:', message);
// 广播消息到渲染进程
this.app.browserManager.broadcastToAllWindows('claudeCodeStreamMessage', {
message,
streamId,
});
if (message.session_id) {
sessionId = message.session_id;
const session = this.streamingSessions.get(streamId);
if (session) {
session.sessionId = sessionId;
}
}
}
logger.debug(`Stream ${streamId} completed with ${messageCount} messages`);
} catch (queryError) {
logger.error('Error in Claude Code query:', queryError);
throw queryError;
}
// 更新会话历史
if (sessionId) {
// 这里我们不存储所有消息,只更新会话信息
const existingSession = this.sessionHistory.get(sessionId);
if (existingSession) {
existingSession.lastActiveAt = Date.now();
existingSession.turnCount++;
} else {
this.sessionHistory.set(sessionId, {
createdAt: Date.now(),
lastActiveAt: Date.now(),
sessionId,
turnCount: 1,
});
}
}
// 广播完成事件
this.app.browserManager.broadcastToAllWindows('claudeCodeStreamComplete', {
sessionId: sessionId || '',
streamId,
});
logger.debug('Stream completed successfully:', streamId);
// 清理
this.streamingSessions.delete(streamId);
} catch (error) {
logger.error('Error in streaming query:', error);
// 广播错误事件
this.app.browserManager.broadcastToAllWindows('claudeCodeStreamError', {
error: error.message,
streamId: params.streamId,
});
// 清理
this.streamingSessions.delete(params.streamId);
}
}
/**
* 构建选项对象
*/
private buildOptions(options?: ClaudeCodeOptions): ClaudeCodeOptions {
const defaultOptions: ClaudeCodeOptions = {
maxTurns: 5,
outputFormat: 'stream-json',
};
if (!options) {
return defaultOptions;
}
// 处理选项
const processedOptions: ClaudeCodeOptions = { ...defaultOptions, ...options };
// 如果提供了 mcpConfig 路径,确保它是绝对路径
if (options.mcpConfig && !join(options.mcpConfig).startsWith('/')) {
processedOptions.mcpConfig = join(app.getPath('userData'), options.mcpConfig);
}
return processedOptions;
}
/**
* 更新会话历史
*/
private updateSessionHistory(sessionId: string, messages: ClaudeCodeMessage[]) {
const resultMessage = messages.find((m) => m.type === 'result');
const existingSession = this.sessionHistory.get(sessionId);
if (existingSession) {
existingSession.lastActiveAt = Date.now();
existingSession.turnCount++;
if (resultMessage?.total_cost_usd) {
existingSession.totalCost = (existingSession.totalCost || 0) + resultMessage.total_cost_usd;
}
} else {
this.sessionHistory.set(sessionId, {
createdAt: Date.now(),
lastActiveAt: Date.now(),
sessionId,
totalCost: resultMessage?.total_cost_usd,
turnCount: 1,
});
}
}
}
@@ -1,172 +0,0 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { merge } from 'lodash';
import { isEqual } from 'lodash-es';
import { defaultProxySettings } from '@/const/store';
import { createLogger } from '@/utils/logger';
import {
ProxyConfigValidator,
ProxyConnectionTester,
ProxyDispatcherManager,
ProxyTestResult,
} from '../modules/networkProxy';
import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:NetworkProxyCtr');
/**
* 网络代理控制器
* 处理桌面应用的网络代理相关功能
*/
export default class NetworkProxyCtr extends ControllerModule {
/**
* 获取代理设置
*/
@ipcClientEvent('getProxySettings')
async getDesktopSettings(): Promise<NetworkProxySettings> {
try {
const settings = this.app.storeManager.get(
'networkProxy',
defaultProxySettings,
) as NetworkProxySettings;
logger.debug('Retrieved proxy settings:', {
enableProxy: settings.enableProxy,
proxyType: settings.proxyType,
});
return settings;
} catch (error) {
logger.error('Failed to get proxy settings:', error);
return defaultProxySettings;
}
}
/**
* 设置代理配置
*/
@ipcClientEvent('setProxySettings')
async setProxySettings(config: NetworkProxySettings): Promise<void> {
try {
// 验证配置
const validation = ProxyConfigValidator.validate(config);
if (!validation.isValid) {
const errorMessage = `Invalid proxy configuration: ${validation.errors.join(', ')}`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
// 获取当前配置
const currentConfig = this.app.storeManager.get(
'networkProxy',
defaultProxySettings,
) as NetworkProxySettings;
// 检查是否有变化
if (isEqual(currentConfig, config)) {
logger.debug('Proxy settings unchanged, skipping update');
return;
}
// 合并配置
const newConfig = merge({}, currentConfig, config);
// 应用代理设置
await ProxyDispatcherManager.applyProxySettings(newConfig);
// 保存到存储
this.app.storeManager.set('networkProxy', newConfig);
logger.info('Proxy settings updated successfully', {
enableProxy: newConfig.enableProxy,
proxyPort: newConfig.proxyPort,
proxyServer: newConfig.proxyServer,
proxyType: newConfig.proxyType,
});
} catch (error) {
logger.error('Failed to update proxy settings:', error);
throw error;
}
}
/**
* 测试代理连接
*/
@ipcClientEvent('testProxyConnection')
async testProxyConnection(url: string): Promise<{ message?: string; success: boolean }> {
try {
const result = await ProxyConnectionTester.testConnection(url);
if (result.success) {
return { success: true };
} else {
throw new Error(result.message || 'Connection test failed');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Proxy connection test failed:', errorMessage);
throw new Error(`Connection failed: ${errorMessage}`);
}
}
/**
* 测试指定代理配置
*/
@ipcClientEvent('testProxyConfig')
async testProxyConfig({
config,
testUrl,
}: {
config: NetworkProxySettings;
testUrl?: string;
}): Promise<ProxyTestResult> {
try {
return await ProxyConnectionTester.testProxyConfig(config, testUrl);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Proxy config test failed:', errorMessage);
return {
message: `Proxy config test failed: ${errorMessage}`,
success: false,
};
}
}
/**
* 应用初始代理设置
*/
async beforeAppReady(): Promise<void> {
try {
// 获取存储的代理设置
const networkProxy = this.app.storeManager.get(
'networkProxy',
defaultProxySettings,
) as NetworkProxySettings;
// 验证配置
const validation = ProxyConfigValidator.validate(networkProxy);
if (!validation.isValid) {
logger.warn('Invalid stored proxy configuration, using defaults:', validation.errors);
await ProxyDispatcherManager.applyProxySettings(defaultProxySettings);
return;
}
// 应用代理设置
await ProxyDispatcherManager.applyProxySettings(networkProxy);
logger.info('Initial proxy settings applied successfully', {
enableProxy: networkProxy.enableProxy,
proxyType: networkProxy.proxyType,
});
} catch (error) {
logger.error('Failed to apply initial proxy settings:', error);
// 出错时使用默认设置
try {
await ProxyDispatcherManager.applyProxySettings(defaultProxySettings);
logger.info('Fallback to default proxy settings');
} catch (fallbackError) {
logger.error('Failed to apply fallback proxy settings:', fallbackError);
}
}
}
}
@@ -79,12 +79,6 @@ 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.
@@ -95,19 +89,10 @@ 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, expiresIn?: number) {
async saveTokens(accessToken: string, refreshToken: string) {
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');
@@ -116,7 +101,6 @@ 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;
@@ -136,7 +120,6 @@ 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,
});
}
@@ -216,40 +199,17 @@ 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) {
@@ -330,7 +290,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
// 保存新令牌
logger.info('Token refresh successful, saving new tokens.');
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
await this.saveTokens(data.access_token, data.refresh_token);
return { success: true };
} catch (error) {
@@ -356,13 +316,6 @@ 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,17 +1,12 @@
import {
ProxyTRPCRequestParams,
ProxyTRPCRequestResult,
ProxyTRPCStreamRequestParams,
} from '@lobechat/electron-client-ipc';
import { IpcMainEvent, WebContents, ipcMain } from 'electron';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
} from '@lobechat/electron-client-ipc/src/types/proxyTRPCRequest';
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';
@@ -46,137 +41,6 @@ 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();
}
/**
@@ -221,12 +85,28 @@ 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
const { requestOptions, requester } = this.createRequester({
accessToken,
headers: originalHeaders,
method,
url: targetUrl,
});
// 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;
// 2. Make the request and capture response
return new Promise((resolve) => {
@@ -296,52 +176,6 @@ 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, // 注入代理
// agent: false, // Consider for keep-alive issues if they arise
};
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,10 +1,15 @@
import { UploadFileParams } from '@lobechat/electron-client-ipc';
import { CreateFileParams } from '@lobechat/electron-server-ipc';
import FileService from '@/services/fileSrv';
import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index';
interface UploadFileParams {
content: ArrayBuffer;
filename: string;
hash: string;
path: string;
type: string;
}
export default class UploadFileCtr extends ControllerModule {
private get fileService() {
return this.app.getService(FileService);
@@ -22,18 +27,8 @@ export default class UploadFileCtr extends ControllerModule {
return this.fileService.getFilePath(id);
}
@ipcServerEvent('getFileHTTPURL')
async getFileHTTPURL(path: string) {
return this.fileService.getFileHTTPURL(path);
}
@ipcServerEvent('deleteFiles')
async deleteFiles(paths: string[]) {
return this.fileService.deleteFiles(paths);
}
@ipcServerEvent('createFile')
async createFile(params: CreateFileParams) {
return this.fileService.uploadFile(params);
}
}
@@ -1,420 +0,0 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import NetworkProxyCtr from '../NetworkProxyCtr';
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// 模拟 undici - 使用 vi.fn() 直接在 Mock 中创建
vi.mock('undici', () => ({
fetch: vi.fn(),
getGlobalDispatcher: vi.fn(),
setGlobalDispatcher: vi.fn(),
Agent: vi.fn(),
ProxyAgent: vi.fn(),
}));
// 模拟 defaultProxySettings
vi.mock('@/const/store', () => ({
defaultProxySettings: {
enableProxy: false,
proxyBypass: 'localhost,127.0.0.1,::1',
proxyPort: '',
proxyRequireAuth: false,
proxyServer: '',
proxyType: 'http',
},
}));
// 模拟 App 及其依赖项
const mockStoreManager = {
get: vi.fn(),
set: vi.fn(),
};
const mockApp = {
storeManager: mockStoreManager,
} as unknown as App;
describe('NetworkProxyCtr', () => {
let networkProxyCtr: NetworkProxyCtr;
// 动态导入 undici 的 Mock
let mockUndici: any;
beforeEach(async () => {
vi.clearAllMocks();
// 动态导入 undici Mock
mockUndici = await import('undici');
networkProxyCtr = new NetworkProxyCtr(mockApp);
// 设置 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', () => {
const validConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should validate enabled proxy config with all required fields', () => {
// 通过测试公共方法来间接测试验证逻辑
expect(() => networkProxyCtr.setProxySettings(validConfig)).not.toThrow();
});
it('should validate disabled proxy config', () => {
const disabledConfig: NetworkProxySettings = {
...validConfig,
enableProxy: false,
};
expect(() => networkProxyCtr.setProxySettings(disabledConfig)).not.toThrow();
});
it('should reject invalid proxy type', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyType: 'invalid' as any,
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
it('should reject missing proxy server', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyServer: '',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
it('should reject invalid proxy port', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyPort: 'invalid',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
it('should reject missing auth credentials when auth is required', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyRequireAuth: true,
proxyUsername: '',
proxyPassword: '',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
});
describe('getDesktopSettings', () => {
it('should return stored proxy settings', async () => {
const expectedSettings: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
mockStoreManager.get.mockReturnValue(expectedSettings);
const result = await networkProxyCtr.getDesktopSettings();
expect(result).toEqual(expectedSettings);
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
});
it('should return default settings when store fails', async () => {
mockStoreManager.get.mockImplementation(() => {
throw new Error('Store error');
});
const result = await networkProxyCtr.getDesktopSettings();
expect(result).toEqual({
enableProxy: false,
proxyBypass: 'localhost,127.0.0.1,::1',
proxyPort: '',
proxyRequireAuth: false,
proxyServer: '',
proxyType: 'http',
});
});
});
describe('setProxySettings', () => {
const validConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should save valid proxy settings', async () => {
mockStoreManager.get.mockReturnValue({
enableProxy: false,
proxyType: 'http',
proxyServer: '',
proxyPort: '',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
});
await networkProxyCtr.setProxySettings(validConfig);
expect(mockStoreManager.set).toHaveBeenCalledWith(
'networkProxy',
expect.objectContaining(validConfig),
);
});
it('should skip update if settings are unchanged', async () => {
mockStoreManager.get.mockReturnValue(validConfig);
await networkProxyCtr.setProxySettings(validConfig);
expect(mockStoreManager.set).not.toHaveBeenCalled();
});
it('should throw error for invalid configuration', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyServer: '',
};
await expect(networkProxyCtr.setProxySettings(invalidConfig)).rejects.toThrow();
});
});
describe('testProxyConnection', () => {
it('should return success for successful connection', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
const result = await networkProxyCtr.testProxyConnection('https://www.google.com');
expect(result).toEqual({ success: true });
expect(mockUndici.fetch).toHaveBeenCalledWith('https://www.google.com', expect.any(Object));
});
it('should throw error for failed connection', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
});
it('should throw error for network error', async () => {
vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Network error'));
await expect(networkProxyCtr.testProxyConnection('https://www.google.com')).rejects.toThrow();
});
});
describe('testProxyConfig', () => {
const validConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
it('should return success for valid config and successful connection', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
expect(result.success).toBe(true);
expect(result.responseTime).toBeGreaterThanOrEqual(0);
});
it('should return failure for invalid config', async () => {
const invalidConfig: NetworkProxySettings = {
...validConfig,
proxyServer: '',
};
const result = await networkProxyCtr.testProxyConfig({ config: invalidConfig });
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid proxy configuration');
});
it('should test direct connection for disabled proxy', async () => {
const disabledConfig: NetworkProxySettings = {
...validConfig,
enableProxy: false,
};
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
};
vi.mocked(mockUndici.fetch).mockResolvedValueOnce(mockResponse);
const result = await networkProxyCtr.testProxyConfig({ config: disabledConfig });
expect(result.success).toBe(true);
});
it('should return failure for connection error', async () => {
vi.mocked(mockUndici.fetch).mockRejectedValueOnce(new Error('Connection failed'));
const result = await networkProxyCtr.testProxyConfig({ config: validConfig });
expect(result.success).toBe(false);
expect(result.message).toContain('Connection failed');
});
});
describe('beforeAppReady', () => {
it('should apply stored proxy settings on app ready', async () => {
const storedConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
mockStoreManager.get.mockReturnValue(storedConfig);
await networkProxyCtr.beforeAppReady();
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
});
it('should use default settings if stored config is invalid', async () => {
const invalidConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: '', // 无效的服务器
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
mockStoreManager.get.mockReturnValue(invalidConfig);
await networkProxyCtr.beforeAppReady();
expect(mockStoreManager.get).toHaveBeenCalledWith('networkProxy', expect.any(Object));
});
it('should handle errors gracefully', async () => {
mockStoreManager.get.mockImplementation(() => {
throw new Error('Store error');
});
// 不应该抛出错误
await expect(networkProxyCtr.beforeAppReady()).resolves.not.toThrow();
mockStoreManager.get.mockReset();
});
});
describe('ProxyUrlBuilder', () => {
it('should build URL without authentication', () => {
const config: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
};
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
it('should build URL with authentication', () => {
const config: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: true,
proxyUsername: 'user',
proxyPassword: 'pass',
proxyBypass: 'localhost,127.0.0.1,::1',
};
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
it('should handle special characters in credentials', () => {
const config: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: 'proxy.example.com',
proxyPort: '8080',
proxyRequireAuth: true,
proxyUsername: 'user@domain',
proxyPassword: 'pass:word',
proxyBypass: 'localhost,127.0.0.1,::1',
};
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
});
});
-8
View File
@@ -9,7 +9,6 @@ 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';
@@ -19,7 +18,6 @@ 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';
@@ -43,7 +41,6 @@ export class App {
updaterManager: UpdaterManager;
shortcutManager: ShortcutManager;
trayManager: TrayManager;
staticFileServerManager: StaticFileServerManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
/**
@@ -100,7 +97,6 @@ export class App {
this.updaterManager = new UpdaterManager(this);
this.shortcutManager = new ShortcutManager(this);
this.trayManager = new TrayManager(this);
this.staticFileServerManager = new StaticFileServerManager(this);
// register the schema to interceptor url
// it should register before app ready
@@ -134,9 +130,6 @@ export class App {
await this.i18n.init();
this.menuManager.initialize();
// Initialize static file manager
await this.staticFileServerManager.initialize();
// Initialize global shortcuts: globalShortcut must be called after app.whenReady()
this.shortcutManager.initialize();
@@ -406,7 +399,6 @@ export class App {
}
// 执行清理操作
this.staticFileServerManager.destroy();
this.unregisterAllRequestHandlers();
};
}
-2
View File
@@ -381,8 +381,6 @@ export default class Browser {
}
broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
if (this._browserWindow.isDestroyed()) return;
logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`);
this._browserWindow.webContents.send(channel, data);
};
+2 -5
View File
@@ -128,12 +128,9 @@ export default class BrowserManager {
*/
initializeBrowsers() {
logger.info('Initializing all browsers');
Object.values(appBrowsers).forEach((browser: BrowserWindowOpts) => {
Object.values(appBrowsers).forEach((browser) => {
logger.debug(`Initializing browser: ${browser.identifier}`);
if (browser.keepAlive) {
this.retrieveOrInitialize(browser);
}
this.retrieveOrInitialize(browser);
});
}
@@ -1,221 +0,0 @@
import { getPort } from 'get-port-please';
import { createServer } from 'node:http';
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import FileService from '@/services/fileSrv';
import { createLogger } from '@/utils/logger';
import type { App } from './App';
const logger = createLogger('core:StaticFileServerManager');
export class StaticFileServerManager {
private app: App;
private fileService: FileService;
private httpServer: any = null;
private serverPort: number = 0;
private isInitialized = false;
constructor(app: App) {
this.app = app;
this.fileService = app.getService(FileService);
logger.debug('StaticFileServerManager initialized');
}
/**
* 初始化静态文件管理器
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
logger.warn('StaticFileServerManager already initialized');
return;
}
logger.info('Initializing StaticFileServerManager');
try {
// 启动 HTTP 文件服务器
await this.startHttpServer();
this.isInitialized = true;
logger.info(
`StaticFileServerManager initialization completed, server running on port ${this.serverPort}`,
);
} catch (error) {
logger.error('Failed to initialize StaticFileServerManager:', error);
throw error;
}
}
/**
* 启动 HTTP 文件服务器
*/
private async startHttpServer(): Promise<void> {
try {
// 使用 get-port-please 获取可用端口
this.serverPort = await getPort({
port: 33250, // 首选端口
ports: [33251, 33252, 33253, 33254, 33255], // 备用端口
host: '127.0.0.1',
});
logger.debug(`Found available port: ${this.serverPort}`);
return new Promise((resolve, reject) => {
const server = createServer(async (req, res) => {
// 设置请求超时
req.setTimeout(30000, () => {
logger.warn('Request timeout, closing connection');
if (!res.destroyed && !res.headersSent) {
res.writeHead(408, { 'Content-Type': 'text/plain' });
res.end('Request Timeout');
}
});
// 监听客户端断开连接
req.on('close', () => {
logger.debug('Client disconnected during request processing');
});
try {
await this.handleHttpRequest(req, res);
} catch (error) {
logger.error('Unhandled error in HTTP request handler:', error);
// 尝试发送错误响应,但确保不会导致进一步错误
try {
if (!res.destroyed && !res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
} catch (responseError) {
logger.error('Failed to send error response:', responseError);
}
}
});
// 监听指定端口
server.listen(this.serverPort, '127.0.0.1', () => {
this.httpServer = server;
logger.info(`HTTP file server started on port ${this.serverPort}`);
resolve();
});
server.on('error', (error) => {
logger.error('HTTP server error:', error);
reject(error);
});
});
} catch (error) {
logger.error('Failed to get available port:', error);
throw error;
}
}
/**
* 处理 HTTP 请求
*/
private async handleHttpRequest(req: any, res: any): Promise<void> {
try {
// 检查响应是否已经结束
if (res.destroyed || res.headersSent) {
logger.warn('Response already ended, skipping request processing');
return;
}
const url = new URL(req.url, `http://127.0.0.1:${this.serverPort}`);
logger.debug(`Processing HTTP file request: ${req.url}`);
// 提取文件路径:从 /desktop-file/path/to/file.png 中提取相对路径
let filePath = decodeURIComponent(url.pathname.slice(1)); // 移除开头的 /
// 如果路径以 desktop-file/ 开头,则移除该前缀
const prefixWithoutSlash = LOCAL_STORAGE_URL_PREFIX.slice(1) + '/'; // 移除开头的 / 并添加结尾的 /
if (filePath.startsWith(prefixWithoutSlash)) {
filePath = filePath.slice(prefixWithoutSlash.length);
}
if (!filePath) {
logger.warn(`Empty file path in HTTP request: ${req.url}`);
if (!res.headersSent) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Bad Request: Empty file path');
}
return;
}
// 使用 FileService 获取文件
const fileResult = await this.fileService.getFile(`desktop://${filePath}`);
// 再次检查响应状态
if (res.destroyed || res.headersSent) {
logger.warn('Response ended during file processing');
return;
}
// 设置响应头
res.writeHead(200, {
'Content-Type': fileResult.mimeType,
'Cache-Control': 'public, max-age=31536000', // 缓存一年
'Access-Control-Allow-Origin': 'http://localhost:*', // 允许 localhost 的任意端口
'Content-Length': Buffer.byteLength(fileResult.content),
});
// 发送文件内容
res.end(Buffer.from(fileResult.content));
logger.debug(`HTTP file served successfully: desktop://${filePath}`);
} catch (error) {
logger.error(`Error serving HTTP file: ${error}`);
// 检查响应是否仍然可写
if (!res.destroyed && !res.headersSent) {
try {
// 判断是否是文件未找到错误
if (error.name === 'FileNotFoundError') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File Not Found');
} else {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
} catch (writeError) {
logger.error('Failed to write error response:', writeError);
}
} else {
logger.warn('Cannot write error response: connection already closed');
}
}
}
/**
* 获取文件服务器域名
*/
getFileServerDomain(): string {
if (!this.isInitialized || !this.serverPort) {
throw new Error('StaticFileServerManager not initialized or server not started');
}
const serverDomain = `http://127.0.0.1:${this.serverPort}`;
return serverDomain;
}
/**
* 销毁静态文件管理器
*/
destroy() {
logger.info('Destroying StaticFileServerManager');
if (this.httpServer) {
logger.debug('Closing HTTP file server');
this.httpServer.close(() => {
logger.debug('HTTP file server closed');
});
this.httpServer = null;
this.serverPort = 0;
}
this.isInitialized = false;
logger.info('StaticFileServerManager destroyed');
}
}
@@ -0,0 +1,353 @@
import { ClaudeCodeMessage } from '@lobechat/electron-client-ipc';
import { type ChildProcess, spawn } from 'node:child_process';
import { type Interface, createInterface } from 'node:readline';
import { createLogger } from '@/utils/logger';
import {
ClaudeCodeImpl,
ClaudeCodeProcessOptions,
ClaudeCodeQueryParams,
ClaudeCodeRuntimeConfig,
} from './type';
const logger = createLogger('modules:claude-code');
/**
* Claude Code Service Implementation
*/
export class ClaudeCodeServiceImpl extends ClaudeCodeImpl {
private activeProcesses = new Map<string, ChildProcess>();
private config: ClaudeCodeRuntimeConfig;
constructor(config: ClaudeCodeRuntimeConfig = {}) {
super();
this.config = {
debugMode: config.debugMode ?? Boolean(process.env.DEBUG),
maxMemoryUsage: config.maxMemoryUsage ?? 1024 * 1024 * 1024, // 1GB
timeoutMs: config.timeoutMs ?? 30 * 60 * 1000, // 30 minutes
};
}
/**
* Execute Claude Code query
*/
async *query(params: ClaudeCodeQueryParams): AsyncGenerator<ClaudeCodeMessage> {
const processId = this.generateProcessId();
let childProcess: ChildProcess | null = null;
let readline: Interface | null = null;
// Set entrypoint environment variable
if (!process.env.CLAUDE_CODE_ENTRYPOINT) {
process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts';
}
// Build process options
const processOptions = this.buildProcessOptions(params);
// Spawn child process using claude command directly
childProcess = spawn('claude', processOptions.args, {
cwd: processOptions.cwd,
env: { ...process.env, ...processOptions.env },
signal: params.abortController?.signal,
stdio: ['pipe', 'pipe', 'pipe'],
});
// Register process
this.activeProcesses.set(processId, childProcess);
// Handle process cleanup
const cleanup = () => {
if (childProcess && !childProcess.killed) {
childProcess.kill('SIGTERM');
}
this.activeProcesses.delete(processId);
};
// Setup abort handling
params.abortController?.signal.addEventListener('abort', cleanup);
process.on('exit', cleanup);
// Handle stdin
if (typeof params.prompt === 'string') {
childProcess.stdin?.end();
} else {
// Handle stream input if needed
this.streamToStdin(params.prompt, childProcess.stdin, params.abortController);
}
// Handle stderr in debug mode
if (this.config.debugMode && childProcess.stderr) {
childProcess.stderr.on('data', (data) => {
logger.debug('Claude Code stderr:', data.toString());
});
}
try {
// Handle process errors
let processError: Error | null = null;
childProcess.on('error', (error) => {
processError = new Error(`Failed to spawn Claude Code process: ${error.message}`);
});
// Create a promise to wait for process completion
const processExitPromise = new Promise<void>((resolve, reject) => {
childProcess!.on('close', (code) => {
if (params.abortController?.signal.aborted) {
reject(new Error('Claude Code process aborted by user'));
} else if (code !== 0) {
reject(new Error(`Claude Code process exited with code ${code}`));
} else {
resolve();
}
});
});
// Create readline interface for stdout and yield messages
if (childProcess.stdout) {
readline = createInterface({ input: childProcess.stdout });
try {
for await (const line of readline) {
if (processError) {
throw processError;
}
if (line.trim()) {
try {
const message = JSON.parse(line);
yield message;
} catch (parseError) {
logger.error('Failed to parse JSON line:', line, parseError);
continue;
}
}
}
} finally {
readline.close();
}
}
// Wait for process to complete
await processExitPromise;
} finally {
// Cleanup
if (readline) {
readline.close();
}
cleanup();
params.abortController?.signal.removeEventListener('abort', cleanup);
}
}
/**
* Check Claude Code availability
*/
async checkAvailability(): Promise<{
apiKeySource?: string;
available: boolean;
error?: string;
version?: string;
}> {
try {
// Check environment variables
const apiKey = process.env.ANTHROPIC_API_KEY;
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === '1';
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === '1';
if (!apiKey && !useBedrock && !useVertex) {
return {
available: false,
error:
'No API credentials found. Please set ANTHROPIC_API_KEY or configure third-party provider.',
};
}
let apiKeySource = 'unknown';
if (apiKey) apiKeySource = 'anthropic';
else if (useBedrock) apiKeySource = 'bedrock';
else if (useVertex) apiKeySource = 'vertex';
// Check if claude command exists
const claudeExists = await this.checkClaudeCommandExists();
if (!claudeExists) {
return {
available: false,
error: 'Claude CLI command not found. Please install Claude CLI first.',
};
}
// Get version
const version = await this.getVersion();
return {
apiKeySource,
available: true,
version,
};
} catch (error) {
logger.error('Error checking Claude Code availability:', error);
return {
available: false,
error: error.message,
};
}
}
/**
* Get Claude Code executable path
*/
async getExecutablePath(): Promise<string> {
// Return claude command directly
return 'claude';
}
/**
* Clean up resources
*/
cleanup(): void {
// Kill all active processes
for (const [processId, childProcess] of this.activeProcesses) {
if (!childProcess.killed) {
childProcess.kill('SIGTERM');
}
}
this.activeProcesses.clear();
}
/**
* Check if claude command exists
*/
private async checkClaudeCommandExists(): Promise<boolean> {
return new Promise((resolve) => {
const testProcess = spawn('which', ['claude'], { stdio: 'pipe' });
testProcess.on('close', (code) => {
resolve(code === 0);
});
testProcess.on('error', () => {
resolve(false);
});
});
}
/**
* Build process options from query parameters
*/
private buildProcessOptions(params: ClaudeCodeQueryParams): ClaudeCodeProcessOptions {
const args = ['--output-format', 'stream-json'];
if (this.config.debugMode) {
args.push('--verbose');
}
const options = params.options || {};
// Add options to args
if (options.systemPrompt) {
args.push('--system-prompt', options.systemPrompt);
}
if (options.appendSystemPrompt) {
args.push('--append-system-prompt', options.appendSystemPrompt);
}
if (options.maxTurns) {
args.push('--max-turns', options.maxTurns.toString());
}
if (options.permissionPromptTool) {
args.push('--permission-prompt-tool', options.permissionPromptTool);
}
if (options.continueLastSession) {
args.push('--continue');
}
if (options.resumeSessionId) {
args.push('--resume', options.resumeSessionId);
}
if (options.allowedTools) {
const tools = Array.isArray(options.allowedTools)
? options.allowedTools.join(',')
: options.allowedTools;
args.push('--allowedTools', tools);
}
if (options.disallowedTools) {
const tools = Array.isArray(options.disallowedTools)
? options.disallowedTools.join(',')
: options.disallowedTools;
args.push('--disallowedTools', tools);
}
if (options.mcpConfig) {
args.push('--mcp-config', options.mcpConfig);
}
if (options.permissionMode && options.permissionMode !== 'default') {
args.push('--permission-mode', options.permissionMode);
}
// Add prompt
if (typeof params.prompt === 'string') {
args.push('--print', params.prompt.trim());
} else {
args.push('--input-format', 'stream-json');
}
return {
args,
cwd: options.cwd,
env: {},
};
}
/**
* Generate unique process ID
*/
private generateProcessId(): string {
return `claude-code-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
/**
* Stream input to stdin
*/
private async streamToStdin(
stream: any,
stdin: any,
abortController?: AbortController,
): Promise<void> {
try {
for await (const message of stream) {
if (abortController?.signal.aborted) break;
stdin.write(JSON.stringify(message) + '\n');
}
stdin.end();
} catch (error) {
logger.error('Error streaming to stdin:', error);
stdin.end();
}
}
/**
* Get Claude Code version
*/
private async getVersion(): Promise<string> {
return new Promise((resolve) => {
const versionProcess = spawn('claude', ['--version'], { stdio: 'pipe' });
let output = '';
versionProcess.stdout?.on('data', (data) => {
output += data.toString();
});
versionProcess.on('close', (code) => {
if (code === 0) {
// Extract version from output
const versionMatch = output.match(/(\d+\.\d+\.\d+)/);
resolve(versionMatch ? versionMatch[1] : 'unknown');
} else {
resolve('unknown');
}
});
versionProcess.on('error', () => {
resolve('unknown');
});
});
}
}
@@ -0,0 +1,18 @@
import { ClaudeCodeServiceImpl } from './impl';
import { ClaudeCodeImpl, ClaudeCodeRuntimeConfig } from './type';
/**
* Create Claude Code module instance
*/
export const createClaudeCodeModule = (config?: ClaudeCodeRuntimeConfig): ClaudeCodeImpl => {
return new ClaudeCodeServiceImpl(config);
};
// Export types and implementation
export type {
ClaudeCodeProcessOptions,
ClaudeCodeProcessResult,
ClaudeCodeQueryParams,
ClaudeCodeRuntimeConfig,
ClaudeCodeStreamingParams,
} from './type';
@@ -0,0 +1,74 @@
import { ClaudeCodeMessage, ClaudeCodeOptions } from '@lobechat/electron-client-ipc';
/**
* Claude Code Query Parameters
*/
export interface ClaudeCodeQueryParams {
abortController?: AbortController;
options?: ClaudeCodeOptions;
prompt: string;
}
/**
* Claude Code Streaming Parameters
*/
export interface ClaudeCodeStreamingParams extends ClaudeCodeQueryParams {
streamId: string;
}
/**
* Claude Code Service Implementation Abstract Class
*/
export abstract class ClaudeCodeImpl {
/**
* Execute Claude Code query
* @param params Query parameters
* @returns AsyncGenerator of ClaudeCodeMessage
*/
abstract query(params: ClaudeCodeQueryParams): AsyncGenerator<ClaudeCodeMessage>;
/**
* Check Claude Code availability
* @returns Promise with availability status
*/
abstract checkAvailability(): Promise<{
apiKeySource?: string;
available: boolean;
error?: string;
version?: string;
}>;
/**
* Clean up resources
*/
abstract cleanup(): void;
}
/**
* Claude Code Process Options
*/
export interface ClaudeCodeProcessOptions {
args: string[];
cwd?: string;
env?: Record<string, string>;
executable?: string;
executableArgs?: string[];
pathToClaudeCodeExecutable?: string;
}
/**
* Claude Code Process Result
*/
export interface ClaudeCodeProcessResult {
exitCode: number;
killed: boolean;
signal?: string;
}
/**
* Claude Code Runtime Configuration
*/
export interface ClaudeCodeRuntimeConfig {
debugMode?: boolean;
maxMemoryUsage?: number;
timeoutMs?: number;
}
@@ -1,116 +0,0 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { Agent, ProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici';
import { createLogger } from '@/utils/logger';
import { ProxyUrlBuilder } from './urlBuilder';
// Create logger
const logger = createLogger('modules:networkProxy:dispatcher');
/**
* 代理管理器
*/
export class ProxyDispatcherManager {
private static isChanging = false;
private static changeQueue: Array<() => Promise<void>> = [];
/**
* 应用代理设置(带并发控制)
*/
static async applyProxySettings(config: NetworkProxySettings): Promise<void> {
return new Promise((resolve, reject) => {
const operation = async () => {
try {
await this.doApplyProxySettings(config);
resolve();
} catch (error) {
reject(error);
}
};
if (this.isChanging) {
// 如果正在切换,加入队列
this.changeQueue.push(operation);
} else {
// 立即执行
operation();
}
});
}
/**
* 执行代理设置应用
*/
private static async doApplyProxySettings(config: NetworkProxySettings): Promise<void> {
this.isChanging = true;
try {
const currentDispatcher = getGlobalDispatcher();
// 禁用代理,恢复默认连接
if (!config.enableProxy) {
await this.safeDestroyDispatcher(currentDispatcher);
// 创建一个新的默认 Agent 来替代代理
setGlobalDispatcher(new Agent());
logger.debug('Proxy disabled, reset to direct connection mode');
return;
}
// 构建代理 URL
const proxyUrl = ProxyUrlBuilder.build(config);
// 创建代理 agent
const agent = this.createProxyAgent(config.proxyType, proxyUrl);
// 切换代理前销毁旧 dispatcher
await this.safeDestroyDispatcher(currentDispatcher);
setGlobalDispatcher(agent);
logger.info(
`Proxy settings applied: ${config.proxyType}://${config.proxyServer}:${config.proxyPort}`,
);
logger.debug(
'Global request proxy set, all Node.js network requests will go through this proxy',
);
} finally {
this.isChanging = false;
// 处理队列中的下一个操作
if (this.changeQueue.length > 0) {
const nextOperation = this.changeQueue.shift();
if (nextOperation) {
setTimeout(() => nextOperation(), 0);
}
}
}
}
/**
* 创建代理 agent
*/
static createProxyAgent(proxyType: string, proxyUrl: string) {
try {
// undici 的 ProxyAgent 支持 http, https 和 socks5
return new ProxyAgent({ uri: proxyUrl });
} catch (error) {
logger.error(`Failed to create proxy agent for ${proxyType}:`, error);
throw new Error(
`Failed to create proxy agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
/**
* 安全销毁 dispatcher
*/
private static async safeDestroyDispatcher(dispatcher: any): Promise<void> {
try {
if (dispatcher && typeof dispatcher.destroy === 'function') {
await dispatcher.destroy();
}
} catch (error) {
logger.warn('Failed to destroy dispatcher:', error);
}
}
}
@@ -1,6 +0,0 @@
export { ProxyDispatcherManager } from './dispatcher';
export type { ProxyTestResult } from './tester';
export { ProxyConnectionTester } from './tester';
export { ProxyUrlBuilder } from './urlBuilder';
export type { ProxyValidationResult } from './validator';
export { ProxyConfigValidator } from './validator';
@@ -1,163 +0,0 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { fetch, getGlobalDispatcher, setGlobalDispatcher } from 'undici';
import { createLogger } from '@/utils/logger';
import { ProxyDispatcherManager } from './dispatcher';
import { ProxyUrlBuilder } from './urlBuilder';
import { ProxyConfigValidator } from './validator';
// Create logger
const logger = createLogger('modules:networkProxy:tester');
/**
* 代理连接测试结果
*/
export interface ProxyTestResult {
message?: string;
responseTime?: number;
success: boolean;
}
/**
* 代理连接测试器
*/
export class ProxyConnectionTester {
private static readonly DEFAULT_TIMEOUT = 10_000; // 10秒超时
private static readonly DEFAULT_TEST_URL = 'https://www.google.com';
/**
* 测试代理连接
*/
static async testConnection(
url: string = this.DEFAULT_TEST_URL,
timeout: number = this.DEFAULT_TIMEOUT,
): Promise<ProxyTestResult> {
const startTime = Date.now();
try {
logger.info(`Testing proxy connection with URL: ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
headers: {
'User-Agent': 'LobeChat-Desktop/1.0.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseTime = Date.now() - startTime;
logger.info(`Proxy connection test successful, response time: ${responseTime}ms`);
return {
responseTime,
success: true,
};
} catch (error) {
const responseTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error(`Proxy connection test failed after ${responseTime}ms:`, errorMessage);
return {
message: errorMessage,
responseTime,
success: false,
};
}
}
/**
* 测试指定代理配置的连接
*/
static async testProxyConfig(
config: NetworkProxySettings,
testUrl: string = this.DEFAULT_TEST_URL,
): Promise<ProxyTestResult> {
// 验证配置
const validation = ProxyConfigValidator.validate(config);
if (!validation.isValid) {
return {
message: `Invalid proxy configuration: ${validation.errors.join(', ')}`,
success: false,
};
}
// 如果未启用代理,直接测试
if (!config.enableProxy) {
return this.testConnection(testUrl);
}
// 创建临时代理 agent 进行测试
try {
const proxyUrl = ProxyUrlBuilder.build(config);
logger.debug(`Testing proxy with URL: ${proxyUrl}`);
const agent = ProxyDispatcherManager.createProxyAgent(config.proxyType, proxyUrl);
const startTime = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.DEFAULT_TIMEOUT);
// 临时设置代理进行测试
const originalDispatcher = getGlobalDispatcher();
setGlobalDispatcher(agent);
try {
const response = await fetch(testUrl, {
dispatcher: agent,
headers: {
'User-Agent': 'LobeChat-Desktop/1.0.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseTime = Date.now() - startTime;
logger.info(`Proxy test successful, response time: ${responseTime}ms`);
return {
responseTime,
success: true,
};
} catch (fetchError) {
clearTimeout(timeoutId);
throw fetchError;
} finally {
// 恢复原来的 dispatcher
setGlobalDispatcher(originalDispatcher);
// 清理临时创建的代理 agent
if (agent && typeof agent.destroy === 'function') {
try {
await agent.destroy();
} catch (error) {
logger.warn('Failed to destroy test agent:', error);
}
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error(`Proxy test failed: ${errorMessage}`, error);
return {
message: `Proxy test failed: ${errorMessage}`,
success: false,
};
}
}
}
@@ -1,25 +0,0 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
/**
* 代理 URL 构建器
*/
export const ProxyUrlBuilder = {
/**
* 构建代理 URL
*/
build(config: NetworkProxySettings): string {
const { proxyType, proxyServer, proxyPort, proxyRequireAuth, proxyUsername, proxyPassword } =
config;
let proxyUrl = `${proxyType}://${proxyServer}:${proxyPort}`;
// 添加认证信息
if (proxyRequireAuth && proxyUsername && proxyPassword) {
const encodedUsername = encodeURIComponent(proxyUsername);
const encodedPassword = encodeURIComponent(proxyPassword);
proxyUrl = `${proxyType}://${encodedUsername}:${encodedPassword}@${proxyServer}:${proxyPort}`;
}
return proxyUrl;
},
};
@@ -1,80 +0,0 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
/**
* 代理配置验证结果
*/
export interface ProxyValidationResult {
errors: string[];
isValid: boolean;
}
/**
* 代理配置验证器
*/
export class ProxyConfigValidator {
private static readonly SUPPORTED_TYPES = ['http', 'https', 'socks5'] as const;
private static readonly DEFAULT_BYPASS = 'localhost,127.0.0.1,::1';
/**
* 验证代理配置
*/
static validate(config: NetworkProxySettings): ProxyValidationResult {
const errors: string[] = [];
// 如果未启用代理,跳过验证
if (!config.enableProxy) {
return { errors: [], isValid: true };
}
// 验证代理类型
if (!this.SUPPORTED_TYPES.includes(config.proxyType as any)) {
errors.push(
`Unsupported proxy type: ${config.proxyType}. Supported types: ${this.SUPPORTED_TYPES.join(', ')}`,
);
}
// 验证代理服务器
if (!config.proxyServer?.trim()) {
errors.push('Proxy server is required when proxy is enabled');
} else if (!this.isValidHost(config.proxyServer)) {
errors.push('Invalid proxy server format');
}
// 验证代理端口
if (!config.proxyPort?.trim()) {
errors.push('Proxy port is required when proxy is enabled');
} else {
const port = parseInt(config.proxyPort, 10);
if (isNaN(port) || port < 1 || port > 65_535) {
errors.push('Proxy port must be a valid number between 1 and 65535');
}
}
// 验证认证信息
if (config.proxyRequireAuth) {
if (!config.proxyUsername?.trim()) {
errors.push('Proxy username is required when authentication is enabled');
}
if (!config.proxyPassword?.trim()) {
errors.push('Proxy password is required when authentication is enabled');
}
}
return {
errors,
isValid: errors.length === 0,
};
}
/**
* 验证主机名格式
*/
private static isValidHost(host: string): boolean {
// 简单的主机名验证(IP 地址或域名)
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
const domainRegex =
/^[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?(\.[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?)*$/;
return ipRegex.test(host) || domainRegex.test(host);
}
}
+42 -229
View File
@@ -1,28 +1,15 @@
import { DeleteFilesResponse } from '@lobechat/electron-server-ipc';
import * as fs from 'node:fs';
import { writeFile } from 'node:fs/promises';
import path, { join } from 'node:path';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { FILE_STORAGE_DIR, LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import { FILE_STORAGE_DIR } from '@/const/dir';
import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import { ServiceModule } from './index';
/**
* 文件未找到错误类
*/
export class FileNotFoundError extends Error {
constructor(
message: string,
public path: string,
) {
super(message);
this.name = 'FileNotFoundError';
}
}
const readFilePromise = promisify(fs.readFile);
const unlinkPromise = promisify(fs.unlink);
@@ -30,7 +17,7 @@ const unlinkPromise = promisify(fs.unlink);
const logger = createLogger('services:FileService');
interface UploadFileParams {
content: ArrayBuffer | string; // ArrayBuffer from browser or Base64 string from server
content: ArrayBuffer;
filename: string;
hash: string;
path: string;
@@ -45,16 +32,17 @@ interface FileMetadata {
}
export default class FileService extends ServiceModule {
/**
* 获取旧版上传目录路径
* @deprecated 仅用于向后兼容旧版文件访问,新文件应存储在 FILE_STORAGE_DIR 的自定义路径下
*/
get UPLOADS_DIR() {
return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads');
}
constructor(app) {
super(app);
// Initialize file storage directory
logger.info('Initializing file storage directory');
makeSureDirExist(this.UPLOADS_DIR);
logger.debug(`Upload directory created: ${this.UPLOADS_DIR}`);
}
/**
@@ -64,44 +52,31 @@ export default class FileService extends ServiceModule {
content,
filename,
hash,
path: filePath,
type,
}: UploadFileParams): Promise<{ metadata: FileMetadata; success: boolean }> {
logger.info(`Starting to upload file: ${filename}, hash: ${hash}, path: ${filePath}`);
logger.info(`Starting to upload file: ${filename}, hash: ${hash}`);
try {
// 获取当前时间戳,避免重复调用 Date.now()
const now = Date.now();
const date = (now / 1000 / 60 / 60).toFixed(0);
// 创建时间戳目录
const date = (Date.now() / 1000 / 60 / 60).toFixed(0);
const dirname = join(this.UPLOADS_DIR, date);
logger.debug(`Creating timestamp directory: ${dirname}`);
makeSureDirExist(dirname);
// 使用传入的 filePath 作为文件的存储路径
const fullStoragePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, filePath);
logger.debug(`Target file storage path: ${fullStoragePath}`);
// 生成文件保存路径
const fileExt = filename.split('.').pop() || '';
const savedFilename = `${hash}${fileExt ? `.${fileExt}` : ''}`;
const savedPath = join(dirname, savedFilename);
logger.debug(`Generated file save path: ${savedPath}`);
// 确保目标目录存在
const targetDir = path.dirname(fullStoragePath);
logger.debug(`Ensuring target directory exists: ${targetDir}`);
makeSureDirExist(targetDir);
const savedPath = fullStoragePath;
logger.debug(`Final file save path: ${savedPath}`);
// 根据 content 类型创建 Buffer
let buffer: Buffer;
if (typeof content === 'string') {
// 来自服务端的 Base64 字符串
buffer = Buffer.from(content, 'base64');
logger.debug(`Creating buffer from Base64 string, size: ${buffer.length} bytes`);
} else {
// 来自浏览器端的 ArrayBuffer
buffer = Buffer.from(content);
logger.debug(`Creating buffer from ArrayBuffer, size: ${buffer.length} bytes`);
}
// 写入文件内容
const buffer = Buffer.from(content);
logger.debug(`Writing file content, size: ${buffer.length} bytes`);
await writeFile(savedPath, buffer);
// 写入元数据文件
const metaFilePath = `${savedPath}.meta`;
const metadata = {
createdAt: now, // 使用统一的时间戳
createdAt: Date.now(),
filename,
hash,
size: buffer.length,
@@ -111,18 +86,13 @@ export default class FileService extends ServiceModule {
await writeFile(metaFilePath, JSON.stringify(metadata, null, 2));
// 返回与S3兼容的元数据格式
const desktopPath = `desktop://${filePath}`;
const desktopPath = `desktop://${date}/${savedFilename}`;
logger.info(`File upload successful: ${desktopPath}`);
// 从路径中提取文件名和目录信息
const parsedPath = path.parse(filePath);
const dirname = parsedPath.dir || '';
const savedFilename = parsedPath.base;
return {
metadata: {
date, // 保持时间戳格式,用于兼容性和时间追踪
dirname,
date,
dirname: date,
filename: savedFilename,
path: desktopPath,
},
@@ -134,24 +104,6 @@ export default class FileService extends ServiceModule {
}
}
/**
* 判断路径是否为旧版格式(时间戳目录)
*
* 旧版路径格式: {timestamp}/{hash}.{ext} (例如: 1234567890/abc123.png)
* 新版路径格式: 任意自定义路径 (例如: user_uploads/images/photo.png, ai_generations/image.jpg)
*
* @param path - 相对路径,不包含 desktop:// 前缀
* @returns true 如果是旧版格式,false 如果是新版格式
*/
private isLegacyPath(path: string): boolean {
const parts = path.split('/');
if (parts.length < 2) return false;
// 如果第一部分是纯数字(时间戳),则认为是旧版格式
// 时间戳格式:精确到小时的 Unix 时间戳,通常是 10 位数字
return /^\d+$/.test(parts[0]);
}
/**
* 获取文件内容
*/
@@ -171,49 +123,13 @@ export default class FileService extends ServiceModule {
// 解析路径
const relativePath = normalizedPath.replace('desktop://', '');
const filePath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Reading file from path: ${filePath}`);
// 智能路由:根据路径格式决定从哪个目录读取文件
let filePath: string;
let isLegacyAttempt = false;
if (this.isLegacyPath(relativePath)) {
// 旧版路径:从 uploads 目录读取(向后兼容)
filePath = join(this.UPLOADS_DIR, relativePath);
isLegacyAttempt = true;
logger.debug(`Legacy path detected, reading from uploads directory: ${filePath}`);
} else {
// 新版路径:从 FILE_STORAGE_DIR 根目录读取
filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, reading from storage root: ${filePath}`);
}
// 读取文件内容,如果第一次尝试失败且是 legacy 路径,则尝试新路径
// 读取文件内容
logger.debug(`Starting to read file content`);
let content: Buffer;
try {
content = await readFilePromise(filePath);
logger.debug(`File content read complete, size: ${content.length} bytes`);
} catch (firstError) {
if (isLegacyAttempt) {
// 如果是 legacy 路径读取失败,尝试从新路径读取
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(
`Legacy path read failed, attempting fallback to storage root: ${fallbackPath}`,
);
try {
content = await readFilePromise(fallbackPath);
filePath = fallbackPath; // 更新 filePath 用于后续的元数据读取
logger.debug(`Fallback read successful, size: ${content.length} bytes`);
} catch (fallbackError) {
logger.error(
`Both legacy and fallback paths failed. Legacy error: ${(firstError as Error).message}, Fallback error: ${(fallbackError as Error).message}`,
);
throw firstError; // 抛出原始错误
}
} else {
throw firstError;
}
}
const content = await readFilePromise(filePath);
logger.debug(`File content read complete, size: ${content.length} bytes`);
// 读取元数据获取MIME类型
const metaFilePath = `${filePath}.meta`;
@@ -226,9 +142,7 @@ export default class FileService extends ServiceModule {
mimeType = metadata.type || mimeType;
logger.debug(`Got MIME type from metadata: ${mimeType}`);
} catch (metaError) {
logger.warn(
`Failed to read metadata file: ${(metaError as Error).message}, using default MIME type`,
);
logger.warn(`Failed to read metadata file: ${(metaError as Error).message}, using default MIME type`);
// 如果元数据文件不存在,尝试从文件扩展名猜测MIME类型
const ext = path.split('.').pop()?.toLowerCase();
if (ext) {
@@ -270,12 +184,6 @@ export default class FileService extends ServiceModule {
};
} catch (error) {
logger.error(`File retrieval failed:`, error);
// 如果是文件不存在错误,抛出自定义的 FileNotFoundError
if (error instanceof Error && error.message.includes('ENOENT')) {
throw new FileNotFoundError(`File not found: ${path}`, path);
}
throw new Error(`File retrieval failed: ${(error as Error).message}`);
}
}
@@ -292,53 +200,15 @@ export default class FileService extends ServiceModule {
throw new Error(`Invalid desktop file path: ${path}`);
}
// 标准化路径格式
const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
// 解析路径
const relativePath = normalizedPath.replace('desktop://', '');
const relativePath = path.replace('desktop://', '');
const filePath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`File deletion path: ${filePath}`);
// 智能路由:根据路径格式决定从哪个目录删除文件
let filePath: string;
let isLegacyAttempt = false;
if (this.isLegacyPath(relativePath)) {
// 旧版路径:从 uploads 目录删除(向后兼容)
filePath = join(this.UPLOADS_DIR, relativePath);
isLegacyAttempt = true;
logger.debug(`Legacy path detected, deleting from uploads directory: ${filePath}`);
} else {
// 新版路径:从 FILE_STORAGE_DIR 根目录删除
filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, deleting from storage root: ${filePath}`);
}
// 删除文件及其元数据,如果第一次尝试失败且是 legacy 路径,则尝试新路径
// 删除文件及其元数据
logger.debug(`Starting file deletion`);
try {
await unlinkPromise(filePath);
logger.debug(`File deletion successful`);
} catch (firstError) {
if (isLegacyAttempt) {
// 如果是 legacy 路径删除失败,尝试从新路径删除
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(
`Legacy path deletion failed, attempting fallback to storage root: ${fallbackPath}`,
);
try {
await unlinkPromise(fallbackPath);
filePath = fallbackPath; // 更新 filePath 用于后续的元数据删除
logger.debug(`Fallback deletion successful`);
} catch (fallbackError) {
logger.error(
`Both legacy and fallback deletion failed. Legacy error: ${(firstError as Error).message}, Fallback error: ${(fallbackError as Error).message}`,
);
throw firstError; // 抛出原始错误
}
} else {
throw firstError;
}
}
await unlinkPromise(filePath);
logger.debug(`File deletion successful`);
// 尝试删除元数据文件,但不强制要求存在
try {
@@ -400,9 +270,7 @@ export default class FileService extends ServiceModule {
});
const success = errors.length === 0;
logger.info(
`Batch deletion operation complete, success: ${success}, error count: ${errors.length}`,
);
logger.info(`Batch deletion operation complete, success: ${success}, error count: ${errors.length}`);
return {
success,
...(errors.length > 0 && { errors }),
@@ -417,65 +285,10 @@ export default class FileService extends ServiceModule {
throw new Error(`Invalid desktop file path: ${path}`);
}
// 标准化路径格式
const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
// 解析路径
const relativePath = normalizedPath.replace('desktop://', '');
// 智能路由:根据路径格式决定从哪个目录获取文件路径
let fullPath: string;
if (this.isLegacyPath(relativePath)) {
// 旧版路径:从 uploads 目录获取(向后兼容)
fullPath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Legacy path detected, resolved to uploads directory: ${fullPath}`);
// 检查文件是否存在,如果不存在则尝试新路径
try {
await fs.promises.access(fullPath, fs.constants.F_OK);
logger.debug(`Legacy path file exists: ${fullPath}`);
} catch {
// 如果 legacy 路径文件不存在,尝试新路径
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`Legacy path file not found, trying fallback path: ${fallbackPath}`);
try {
await fs.promises.access(fallbackPath, fs.constants.F_OK);
fullPath = fallbackPath;
logger.debug(`Fallback path file exists: ${fullPath}`);
} catch {
// 两个路径都不存在,返回原始的 legacy 路径(保持原有行为)
logger.debug(
`Neither legacy nor fallback path exists, returning legacy path: ${fullPath}`,
);
}
}
} else {
// 新版路径:从 FILE_STORAGE_DIR 根目录获取
fullPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, resolved to storage root: ${fullPath}`);
}
const relativePath = path.replace('desktop://', '');
const fullPath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Resolved filesystem path: ${fullPath}`);
return fullPath;
}
async getFileHTTPURL(path: string): Promise<string> {
logger.debug(`Getting file HTTP URL: ${path}`);
// 处理desktop://路径
if (!path.startsWith('desktop://')) {
logger.error(`Invalid desktop file path: ${path}`);
throw new Error(`Invalid desktop file path: ${path}`);
}
// 标准化路径格式
const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://');
// 解析路径:从 desktop://path/to/file.png 中提取 path/to/file.png
const relativePath = normalizedPath.replace('desktop://', '');
// 使用 StaticFileServerManager 获取文件服务器域名,然后构建完整 URL
const serverDomain = this.app.staticFileServerManager.getFileServerDomain();
const httpURL = `${serverDomain}${LOCAL_STORAGE_URL_PREFIX}/${relativePath}`;
logger.debug(`Generated HTTP URL: ${httpURL}`);
return httpURL;
}
}
+1 -3
View File
@@ -1,14 +1,12 @@
import { DataSyncConfig, NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
export interface ElectronMainStore {
dataSyncConfig: DataSyncConfig;
encryptedTokens: {
accessToken?: string;
expiresAt?: number;
refreshToken?: string;
};
locale: string;
networkProxy: NetworkProxySettings;
shortcuts: Record<string, string>;
storagePath: string;
}
+1 -2
View File
@@ -19,9 +19,8 @@ export const createLogger = (namespace: string) => {
error: (message, ...args) => {
if (process.env.NODE_ENV === 'production') {
electronLog.error(message, ...args);
} else {
console.error(message, ...args);
}
debugLogger(`ERROR: ${message}`, ...args);
},
info: (message, ...args) => {
if (process.env.NODE_ENV === 'production') {
@@ -1,6 +1,6 @@
// copy from https://github.com/kirill-konshin/next-electron-rsc
import { serialize as serializeCookie } from 'cookie';
import { type Protocol, type Session, protocol } from 'electron';
import type { Protocol, Session } from 'electron';
import type { NextConfig } from 'next';
import type NextNodeServer from 'next/dist/server/next-server';
import assert from 'node:assert';
@@ -11,7 +11,6 @@ import { parse } from 'node:url';
import resolve from 'resolve';
import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
import { LOCAL_STORAGE_URL_PREFIX } from '@/const/dir';
import { isDev } from '@/const/env';
import { createLogger } from '@/utils/logger';
@@ -179,9 +178,7 @@ export function createHandler({
}
};
}
let registerProtocolHandle = false;
let interceptorCount = 0; // 追踪活跃的拦截器数量
protocol.registerSchemesAsPrivileged([
{
@@ -227,14 +224,6 @@ export function createHandler({
socket: Socket,
): Promise<Response> => {
try {
// 检查是否是本地文件服务请求,如果是则跳过处理
const url = new URL(request.url);
if (url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/')) {
if (debug) logger.debug(`Skipping local file service request: ${request.url}`);
// 直接使用 fetch 转发请求到本地文件服务
return fetch(request);
}
// 先尝试使用自定义处理器处理请求
for (const customHandler of customHandlers) {
try {
@@ -365,32 +354,19 @@ export function createHandler({
);
const socket = new Socket();
interceptorCount++; // 增加拦截器计数
const closeSocket = () => socket.end();
process.on('SIGTERM', () => closeSocket);
process.on('SIGINT', () => closeSocket);
if (!registerProtocolHandle) {
if (!isDev && !registerProtocolHandle) {
logger.debug(
`Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`,
);
protocol.handle('http', async (request) => {
if (!isDev) {
// 检查是否是本地文件服务请求,如果是则允许通过
const isLocalhost = request.url.startsWith(localhostUrl);
const url = new URL(request.url);
const isLocalIP =
request.url.startsWith('http://127.0.0.1:') ||
request.url.startsWith('http://localhost:');
const isLocalFileService = url.pathname.startsWith(LOCAL_STORAGE_URL_PREFIX + '/');
const valid = isLocalhost || (isLocalIP && isLocalFileService);
if (!valid) {
throw new Error('External HTTP not supported, use HTTPS');
}
assert(request.url.startsWith(localhostUrl), 'External HTTP not supported, use HTTPS');
}
return handleRequest(request, session, socket);
@@ -398,19 +374,12 @@ export function createHandler({
registerProtocolHandle = true;
}
logger.debug(`Active interceptors count: ${interceptorCount}`);
return function stopIntercept() {
interceptorCount--; // 减少拦截器计数
logger.debug(`Stopping interceptor, remaining count: ${interceptorCount}`);
// 只有当没有活跃的拦截器时才取消注册协议处理器
if (registerProtocolHandle && interceptorCount === 0) {
logger.debug('Unregistering HTTP protocol handler (no active interceptors)');
if (registerProtocolHandle) {
logger.debug('Unregistering HTTP protocol handler');
protocol.unhandle('http');
registerProtocolHandle = false;
}
process.off('SIGTERM', () => closeSocket);
process.off('SIGINT', () => closeSocket);
closeSocket();
+1 -2
View File
@@ -2,7 +2,6 @@ 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
@@ -15,5 +14,5 @@ export const setupElectronApi = () => {
console.error(error);
}
contextBridge.exposeInMainWorld('electronAPI', { invoke, onStreamInvoke });
contextBridge.exposeInMainWorld('electronAPI', { invoke });
};
-58
View File
@@ -1,58 +0,0 @@
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;
};
-125
View File
@@ -1,129 +1,4 @@
[
{
"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",
"version": "1.99.1"
},
{
"children": {
"features": ["support AI Image."]
},
"date": "2025-07-14",
"version": "1.99.0"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-14",
"version": "1.98.2"
},
{
"children": {
"improvements": ["Fix discover translation."]
},
"date": "2025-07-14",
"version": "1.98.1"
},
{
"children": {
"features": ["Add network proxy for desktop."]
},
"date": "2025-07-13",
"version": "1.98.0"
},
{
"children": {
"improvements": ["Support Hunyuan A13B thinking model."]
},
"date": "2025-07-13",
"version": "1.97.17"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-13",
"version": "1.97.16"
},
{
"children": {
"fixes": ["Add vision support to Grok 4."]
},
"date": "2025-07-12",
"version": "1.97.15"
},
{
"children": {
"fixes": ["Revert \"💄 style: Open new topic by tap Just Chat again\"."],
"improvements": ["Add Kimi K2 model."]
},
"date": "2025-07-12",
"version": "1.97.14"
},
{
"children": {
"improvements": ["Support new Doubao thinking models, update i18n."]
},
"date": "2025-07-12",
"version": "1.97.13"
},
{
"children": {
"fixes": ["Grok-4 reasoning model universal matching."]
},
"date": "2025-07-11",
"version": "1.97.12"
},
{
"children": {
"improvements": ["Open new topic by tap Just Chat again."]
},
"date": "2025-07-11",
"version": "1.97.11"
},
{
"children": {
"improvements": ["Update i18n."]
},
"date": "2025-07-11",
"version": "1.97.10"
},
{
"children": {
"improvements": ["Integrate Amazon Cognito for user authentication."]
-79
View File
@@ -163,7 +163,6 @@ table files {
name text [not null]
size integer [not null]
url text [not null]
source text
client_id text
metadata jsonb
chunk_task_id uuid
@@ -219,45 +218,6 @@ table knowledge_bases {
}
}
table generation_batches {
id text [pk, not null]
user_id text [not null]
generation_topic_id text [not null]
provider text [not null]
model text [not null]
prompt text [not null]
width integer
height integer
ratio varchar(64)
config jsonb
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table generation_topics {
id text [pk, not null]
user_id text [not null]
title text
cover_url text
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table generations {
id text [pk, not null]
user_id text [not null]
generation_batch_id varchar(64) [not null]
async_task_id uuid
file_id text
seed integer
asset jsonb
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
}
table message_chunks {
message_id text
chunk_id uuid
@@ -431,15 +391,6 @@ 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]
@@ -883,10 +834,6 @@ table users {
updated_at "timestamp with time zone" [not null, default: `now()`]
}
ref: agents_files.file_id > files.id
ref: agents_files.agent_id > agents.id
ref: agents_knowledge_bases.knowledge_base_id - knowledge_bases.id
ref: agents_knowledge_bases.agent_id > agents.id
@@ -901,34 +848,8 @@ ref: document_chunks.document_id > documents.id
ref: documents.file_id > files.id
ref: file_chunks.file_id - files.id
ref: file_chunks.chunk_id - chunks.id
ref: generations.file_id - files.id
ref: files.embedding_task_id - async_tasks.id
ref: files_to_sessions.file_id > files.id
ref: files_to_sessions.session_id > sessions.id
ref: generation_batches.user_id - users.id
ref: generation_batches.generation_topic_id > generation_topics.id
ref: generation_topics.user_id - users.id
ref: generations.user_id - users.id
ref: generations.generation_batch_id > generation_batches.id
ref: generations.async_task_id - async_tasks.id
ref: messages_files.file_id > files.id
ref: messages_files.message_id > messages.id
ref: messages.session_id - sessions.id
ref: messages.parent_id - messages.id
+1 -1
View File
@@ -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 `AI Service Provider`, find the setting for `AI21 Labs`
- Under `Language Model`, find the setting for `AI21 Labs`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/9336d6c5-2a83-4aa9-854e-75e245b665cb'} />
+1 -1
View File
@@ -28,7 +28,7 @@ tags:
### 步骤二:在 LobeChat 中配置 AI21 Labs
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `AI21labs` 的设置项
- 在`语言模型`下找到 `AI21labs` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/9336d6c5-2a83-4aa9-854e-75e245b665cb'} />
+1 -1
View File
@@ -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 `AI Service Provider`, find the option for `360`
- Under `Language Models`, find the option for `360`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63'} />
+1 -1
View File
@@ -28,7 +28,7 @@ tags:
### 步骤二:在 LobeChat 中配置 360 智脑
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `360` 的设置项
- 在`语言模型`下找到 `360` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63'} />
+1 -1
View File
@@ -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 `AI Service Provider`.
- Find the setting for `Anthropic Claude` under `Language Models`.
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff9c3eb8-412b-4275-80be-177ae7b7acbc'} />
+1 -1
View File
@@ -36,7 +36,7 @@ Anthropic Claude API 现在可供所有人使用,本文档将指导你如何
### 步骤二:在 LobeChat 中配置 Anthropic Claude
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`Anthropic Claude`的设置项
- 在`语言模型`下找到`Anthropic Claude`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff9c3eb8-412b-4275-80be-177ae7b7acbc'} />
+1 -1
View File
@@ -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 `AI Service Provider`.
- Find the setting for `Azure OpenAI` under `Language Model`.
<Image alt={'Enter the API key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/63d9f6d4-5b78-4c65-8cd1-ff8b7f143406'} />
+1 -1
View File
@@ -33,7 +33,7 @@ tags:
### 步骤二:在 LobeChat 中配置 Azure OpenAI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`Azure OpenAI`的设置项
- 在`语言模型`下找到`Azure OpenAI`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/63d9f6d4-5b78-4c65-8cd1-ff8b7f143406'} />
+1 -1
View File
@@ -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 `AI Service Provider`
- Find the setting for `Baichuan` under `Language Model`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/dec6665a-b3ec-4c50-a57f-7c7eb3160e7b'} />
+1 -1
View File
@@ -26,7 +26,7 @@ tags:
### 步骤二:在 LobeChat 中配置百川
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`百川`的设置项
- 在`语言模型`下找到`百川`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/dec6665a-b3ec-4c50-a57f-7c7eb3160e7b'} />
+1 -1
View File
@@ -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 `AI Service Provider` and open it
- Find the setting for `Amazon Bedrock` under `Language Models` 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'} />
+1 -1
View File
@@ -68,7 +68,7 @@ Amazon Bedrock 是一个完全托管的基础模型 API 服务,允许用户通
### 步骤三:在 LobeChat 中配置 Amazon Bedrock
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`Amazon Bedrock`的设置项并打开
- 在`语言模型`下找到`Amazon Bedrock`的设置项并打开
<Image alt={'LobeChat 中填写 Amazon Bedrock 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/7468594b-3355-4cb9-85bc-c9dace137653'} />
+1 -1
View File
@@ -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 `AI Service Provider`, find the `Cloudflare` settings.
- Under `Language Model`, find the `Cloudflare` settings.
<Image alt={'Input API Token'} inStep src={'https://github.com/user-attachments/assets/82a7ebe0-69ad-43b6-8767-1316b443fa03'} />
+1 -1
View File
@@ -40,7 +40,7 @@ tags:
### 步骤二:在 LobeChat 中配置 Cloudflare Workers AI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `Cloudflare` 的设置项
- 在`语言模型`下找到 `Cloudflare` 的设置项
<Image alt={'填入访问令牌'} inStep src={'https://github.com/user-attachments/assets/82a7ebe0-69ad-43b6-8767-1316b443fa03'} />
+1 -1
View File
@@ -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 `AI Service Provider`.
- Find the setting for `DeepSeek` under `Language Models`.
<Image alt={'Enter Deepseek API Key'} inStep src={'https://github.com/user-attachments/assets/aaa3e2c5-7f16-4cfb-86b6-2814a1aafe3a'} />
+1 -1
View File
@@ -43,7 +43,7 @@ tags:
### 步骤二:在 LobeChat 中配置 DeepSeek
- 访问 LobeChat 的 `应用设置`界面
- 在 `AI 服务商` 下找到 `DeepSeek` 的设置项
- 在 `语言模型` 下找到 `DeepSeek` 的设置项
<Image alt={'填写 Deepseek API 密钥'} inStep src={'https://github.com/user-attachments/assets/aaa3e2c5-7f16-4cfb-86b6-2814a1aafe3a'} />
-69
View File
@@ -1,69 +0,0 @@
---
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://github.com/user-attachments/assets/febb2ffb-8fe8-4f88-b8c9-8fa0985a2352'} />
[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://github.com/user-attachments/assets/a2203b3a-1657-485a-a060-b018e7b2faaa'
}
/>
<Image
alt={'Create API Key'}
inStep
src={
'https://github.com/user-attachments/assets/a216e326-6a51-4f3a-b8c1-23bb995ddac2'
}
/>
<Image
alt={'Retrieve API Key'}
inStep
src={
'https://github.com/user-attachments/assets/faee998d-4349-4c17-a5c4-07a7ff65f18e'
}
/>
### 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://github.com/user-attachments/assets/7566f679-f935-4a5b-9c98-a8d99d9c7994'} />
- Paste the API key you obtained.
- Choose a Fal model (e.g. `fal-ai/flux-pro`, `fal-ai/kling-video`, `fal-ai/hidream-i1-fast`) for image or video generation.
<Image alt={'Select Fal model for media generation'} inStep src={'https://github.com/user-attachments/assets/817167ff-4723-4f84-8528-c779c5c3a118'} />
<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.
-68
View File
@@ -1,68 +0,0 @@
---
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://github.com/user-attachments/assets/febb2ffb-8fe8-4f88-b8c9-8fa0985a2352'} />
[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://github.com/user-attachments/assets/a2203b3a-1657-485a-a060-b018e7b2faaa'
}
/>
<Image
alt={'创建 API Key'}
inStep
src={
'https://github.com/user-attachments/assets/a216e326-6a51-4f3a-b8c1-23bb995ddac2'
}
/>
<Image
alt={'获取 API Key'}
inStep
src={
'https://github.com/user-attachments/assets/faee998d-4349-4c17-a5c4-07a7ff65f18e'
}
/>
### 步骤二:在 LobeChat 中配置 Fal
- 访问 LobeChat 的 `设置` 页面;
- 在 `AI服务商` 下找到 `Fal` 的设置项;
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/7566f679-f935-4a5b-9c98-a8d99d9c7994'} />
- 粘贴获取到的 API Key
- 选择一个 Fal 模型(如 `fal-ai/flux-pro`、`fal-ai/kling-video`、`fal-ai/hidream-i1-fast`)用于图像或视频生成。
<Image alt={'选择 Fal 模型进行媒体生成'} inStep src={'https://github.com/user-attachments/assets/817167ff-4723-4f84-8528-c779c5c3a118'} />
<Callout type={'warning'}>
在使用过程中,你可能需要向 Fal 支付相应费用,请在大量调用前查阅 Fal 的官方计费政策。
</Callout>
</Steps>
至此,你已经可以在 LobeChat 中使用 Fal 提供的先进图像和视频生成模型来创作精美的视觉内容了。
+1 -1
View File
@@ -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 `AI Service Provider`, locate the settings for `Fireworks AI`
- Under `Language Model`, locate the settings for `Fireworks AI`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/12c1957d-f050-4235-95da-d55ddedfa6c9'} />
+1 -1
View File
@@ -36,7 +36,7 @@ tags:
### 步骤二:在 LobeChat 中配置 Fireworks AI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `Fireworks AI` 的设置项
- 在`语言模型`下找到 `Fireworks AI` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/12c1957d-f050-4235-95da-d55ddedfa6c9'} />
+1 -1
View File
@@ -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 `AI Service Provider`, find the settings for `Gitee AI`
- Under `Language Models`, find the settings for `Gitee AI`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/eaa2a1fb-41ad-473d-ac10-a39c05886425'} />
+1 -1
View File
@@ -37,7 +37,7 @@ tags:
### 步骤二:在 LobeChat 中配置 Gitee AI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `Gitee AI` 的设置项
- 在`语言模型`下找到 `Gitee AI` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/eaa2a1fb-41ad-473d-ac10-a39c05886425'} />
+1 -1
View File
@@ -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 `AI Service Provider`, find the GitHub settings.
- Under `Language Models`, find the GitHub settings.
<Image alt={'Entering Access Token'} inStep src={'https://github.com/user-attachments/assets/a00f06cc-da7c-41e8-a4d5-d4b675a22673'} />
+1 -1
View File
@@ -53,7 +53,7 @@ tags:
### 步骤二:在 LobeChat 中配置 GitHub Models
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `GitHub` 的设置项
- 在`语言模型`下找到 `GitHub` 的设置项
<Image alt={'填入访问令牌'} inStep src={'https://github.com/user-attachments/assets/a00f06cc-da7c-41e8-a4d5-d4b675a22673'} />
+1 -1
View File
@@ -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 `AI Service Provider`
- Find the setting for `Google Gemini` under `Language Models`
<Image alt={'Enter Google Gemini API Key in LobeChat'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/11442ce4-a615-49c4-937a-ca2ae93dd27c'} />
+1 -1
View File
@@ -35,7 +35,7 @@ Gemini AI 是由 Google AI 创建的一组大型语言模型(LLM),以其
### 步骤二:在 LobeChat 中配置 OpenAI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`Google Gemini`的设置项
- 在`语言模型`下找到`Google Gemini`的设置项
<Image alt={'LobeChat 中填写 Google Gemini API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/11442ce4-a615-49c4-937a-ca2ae93dd27c'} />
+1 -1
View File
@@ -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` -> `AI Service Provider`, where you can input the API Key you just obtained.
You can find the Groq configuration option in `Settings` -> `Language Model`, 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>
+1 -1
View File
@@ -40,7 +40,7 @@ Groq 的 [LPU 推理引擎](https://wow.groq.com/news_press/groq-lpu-inference-e
### 在 LobeChat 中配置 Groq
你可以在 `设置` -> `AI 服务商` 中找到 Groq 的配置选项,将刚才获取的 API Key 填入。
你可以在 `设置` -> `语言模型` 中找到 Groq 的配置选项,将刚才获取的 API Key 填入。
<Image alt={'Groq 服务商设置'} height={274} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/88948a3a-6681-4a8d-9734-a464e09e4957'} />
</Steps>
+1 -1
View File
@@ -34,7 +34,7 @@ This article will guide you on how to use Tencent Hunyuan in LobeChat.
### Step 2: Configure Tencent Hunyuan in LobeChat
- Go to the `Settings` page in LobeChat
- Find the `Tencent Hunyuan` settings under `AI Service Provider`
- Find the `Tencent Hunyuan` settings under `Language Models`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/796c94af-9bad-4e3c-b1c7-dbb17c215c56'} />
+1 -1
View File
@@ -32,7 +32,7 @@ tags:
### 步骤二:在 LobeChat 中配置腾讯混元
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `腾讯混元` 的设置项
- 在`语言模型`下找到 `腾讯混元` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/796c94af-9bad-4e3c-b1c7-dbb17c215c56'} />
+1 -1
View File
@@ -35,7 +35,7 @@ This article will guide you on how to use InternLM in LobeChat.
### Step 2: Configure InternLM in LobeChat
- Go to the `Settings` interface in LobeChat
- Find the settings option for `InternLM` under `AI Service Provider`
- Find the settings option for `InternLM` under `Language Models`
<Image alt={'Enter API Key'} inStep src={'https://github.com/user-attachments/assets/8ec7656e-1e3d-41e0-95a0-f6883135c2fc'} />
+1 -1
View File
@@ -32,7 +32,7 @@ tags:
### 步骤二:在 LobeChat 中配置书生浦语
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到 `书生浦语` 的设置项
- 在`语言模型`下找到 `书生浦语` 的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/user-attachments/assets/8ec7656e-1e3d-41e0-95a0-f6883135c2fc'} />
+1 -1
View File
@@ -35,7 +35,7 @@ This document will guide you on how to use Jina AI in LobeChat:
### Step 2: Configure Jina AI in LobeChat
- Visit LobeChat's `Application Settings` interface.
- Find the `Jina AI` setting under `AI Service Provider`.
- Find the `Jina AI` setting under `Language Model`.
<Image alt={'Fill in Jina AI API Key'} inStep src={'https://github.com/user-attachments/assets/1077bee5-b379-4063-b7bd-23b98ec146e2'} />
+1 -1
View File
@@ -34,7 +34,7 @@ tags:
### 步骤二:在 LobeChat 中配置 Jina AI
- 访问 LobeChat 的 `应用设置`界面
- 在 `AI 服务商` 下找到 `Jina AI` 的设置项
- 在 `语言模型` 下找到 `Jina AI` 的设置项
<Image alt={'填写 Jina AI API 密钥'} inStep src={'https://github.com/user-attachments/assets/1077bee5-b379-4063-b7bd-23b98ec146e2'} />
+1 -1
View File
@@ -42,7 +42,7 @@ This document will guide you on how to use Minimax in LobeChat:
### Step 2: Configure MiniMax in LobeChat
- Go to the `Settings` interface of LobeChat
- Find the setting for `MiniMax` under `AI Service Provider`
- Find the setting for `MiniMax` under `Language Model`
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/b839e04e-0cef-46a3-bb84-0484a3f51c69'} />
+1 -1
View File
@@ -41,7 +41,7 @@ tags:
### 步骤二:在 LobeChat 中配置 MiniMax
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`MiniMax`的设置项
- 在`语言模型`下找到`MiniMax`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/b839e04e-0cef-46a3-bb84-0484a3f51c69'} />
+1 -1
View File
@@ -26,7 +26,7 @@ The Mistral AI API is now available for everyone to use. This document will guid
### Step 2: Configure Mistral AI in LobeChat
- Go to the `Settings` interface in LobeChat
- Find the setting for `Mistral AI` under `AI Service Provider`
- Find the setting for `Mistral AI` under `Language Model`
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ba8e688a-e0c1-4567-9013-94205f83fc60'} />
+1 -1
View File
@@ -24,7 +24,7 @@ Mistral AI API 现在可供所有人使用,本文档将指导你如何在 Lobe
### 步骤二:在 LobeChat 中配置 Mistral AI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`Mistral AI`的设置项
- 在`语言模型`下找到`Mistral AI`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ba8e688a-e0c1-4567-9013-94205f83fc60'} />
+1 -1
View File
@@ -25,7 +25,7 @@ The Moonshot AI API is now available for everyone to use. This document will gui
### Step 2: Configure Moonshot AI in LobeChat
- Visit the `Settings` interface in LobeChat
- Find the setting for `Moonshot AI` under `AI Service Provider`
- Find the setting for `Moonshot AI` under `Language Models`
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/e1b5f84f-015e-437c-98cc-a3431fa3b077'} />
+1 -1
View File
@@ -23,7 +23,7 @@ Moonshot AI API 现在可供所有人使用,本文档将指导你如何在 Lob
### 步骤二:在 LobeChat 中配置 Moonshot AI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`Moonshot AI`的设置项
- 在`语言模型`下找到`Moonshot AI`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/e1b5f84f-015e-437c-98cc-a3431fa3b077'} />
+1 -1
View File
@@ -38,7 +38,7 @@ This document will guide you on how to integrate Novita AI in LobeChat:
### Step 3: Configure Novita AI in LobeChat
- Visit the `Settings` interface in LobeChat
- Find the setting for `novita.ai` under `AI Service Provider`
- Find the setting for `novita.ai` under `Language Model`
<Image alt={'Enter Novita AI API key in LobeChat'} inStep src={'https://github.com/user-attachments/assets/00c02637-873e-4e7e-9dc3-a95085b16dd7'} />
+1 -1
View File
@@ -38,7 +38,7 @@ tags:
### 步骤三:在 LobeChat 中配置 Novita AI
- 访问 LobeChat 的 `设置` 界面
- 在 `AI 服务商` 下找到 `novita.ai` 的设置项
- 在 `语言模型` 下找到 `novita.ai` 的设置项
- 打开 novita.ai 并填入获得的 API 密钥
<Image alt={'在 LobeChat 中输入 Novita AI API 密钥'} inStep src={'https://github.com/user-attachments/assets/00c02637-873e-4e7e-9dc3-a95085b16dd7'} />
+1 -1
View File
@@ -163,7 +163,7 @@ ollama pull llama3
## Custom Configuration
You can find Ollama's configuration options in `Settings` -> `AI Service Provider`, where you can configure Ollama's proxy, model names, etc.
You can find Ollama's configuration options in `Settings` -> `Language Models`, where you can configure Ollama's proxy, model names, etc.
<Image alt={'Ollama Provider Settings'} height={274} src={'https://github.com/lobehub/lobe-chat/assets/28616219/54b3696b-5b13-4761-8c1b-1e664867b2dd'} />
+1 -1
View File
@@ -161,7 +161,7 @@ ollama pull llama3
## 自定义配置
你可以在 `设置` -> `AI 服务商` 中找到 Ollama 的配置选项,你可以在这里配置 Ollama 的代理、模型名称等。
你可以在 `设置` -> `语言模型` 中找到 Ollama 的配置选项,你可以在这里配置 Ollama 的代理、模型名称等。
<Image alt={'Ollama 服务商设置'} height={274} src={'https://github.com/lobehub/lobe-chat/assets/28616219/54b3696b-5b13-4761-8c1b-1e664867b2dd'} />
+4 -4
View File
@@ -31,7 +31,7 @@ This document will guide you on how to use [OpenAI](https://openai.com/) in Lobe
alt={'Open the creation window'}
inStep
src={
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296253192-ff2193dd-f125-4e58-82e8-91bc376c0d68.png'
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296253192-ff2193dd-f125-4e58-82e8-91bc376c0d68.png'
}
/>
@@ -41,7 +41,7 @@ This document will guide you on how to use [OpenAI](https://openai.com/) in Lobe
alt={'Create API Key'}
inStep
src={
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296254170-803bacf0-4471-4171-ae79-0eab08d621d1.png'
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296254170-803bacf0-4471-4171-ae79-0eab08d621d1.png'
}
/>
@@ -51,7 +51,7 @@ This document will guide you on how to use [OpenAI](https://openai.com/) in Lobe
alt={'Retrieve API Key'}
inStep
src={
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296255167-f2745f2b-f083-4ba8-bc78-9b558e0002de.png'
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296255167-f2745f2b-f083-4ba8-bc78-9b558e0002de.png'
}
/>
@@ -62,7 +62,7 @@ This document will guide you on how to use [OpenAI](https://openai.com/) in Lobe
### Step 2: Configure OpenAI in LobeChat
- Visit the `Settings` page in LobeChat
- Find the setting for `OpenAI` under `AI Service Provider`
- Find the setting for `OpenAI` under `Language Model`
<Image alt={'Enter API Key'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/3f31bc33-509f-4ad2-ba81-280c2a6ec5fa'} />
+4 -4
View File
@@ -28,7 +28,7 @@ tags:
alt={'打开创建窗口'}
inStep
src={
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296253192-ff2193dd-f125-4e58-82e8-91bc376c0d68.png'
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296253192-ff2193dd-f125-4e58-82e8-91bc376c0d68.png'
}
/>
@@ -38,7 +38,7 @@ tags:
alt={'创建 API Key'}
inStep
src={
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296254170-803bacf0-4471-4171-ae79-0eab08d621d1.png'
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296254170-803bacf0-4471-4171-ae79-0eab08d621d1.png'
}
/>
@@ -48,7 +48,7 @@ tags:
alt={'获取 API Key'}
inStep
src={
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296255167-f2745f2b-f083-4ba8-bc78-9b558e0002de.png'
'https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/296255167-f2745f2b-f083-4ba8-bc78-9b558e0002de.png'
}
/>
@@ -57,7 +57,7 @@ tags:
### 步骤二:在 LobeChat 中配置 OpenAI
- 访问 LobeChat 的`设置`界面
- 在`AI 服务商`下找到`OpenAI`的设置项
- 在`语言模型`下找到`OpenAI`的设置项
<Image alt={'填入 API 密钥'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/3f31bc33-509f-4ad2-ba81-280c2a6ec5fa'} />
+1 -1
View File
@@ -55,7 +55,7 @@ This document will guide you on how to use OpenRouter in LobeChat:
### Step 4: Configure OpenRouter in LobeChat
- Visit the `Settings` interface in LobeChat
- Find the setting for `OpenRouter` under `AI Service Provider`
- Find the setting for `OpenRouter` under `Language Models`
- Enable OpenRouter and enter the API key you obtained
<Image alt={'Configure OpenRouter in LobeChat'} height={518} inStep src={'https://github.com/lobehub/lobe-chat/assets/34400653/5c3898ab-23d7-44c2-bbd9-b255e25e400c'} />

Some files were not shown because too many files have changed in this diff Show More