mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 fix: chat model list should not show image model (#8448)
This commit is contained in:
@@ -55,59 +55,4 @@ pnpm install
|
||||
# !: don't any build script to check weather code can work after modify
|
||||
```
|
||||
|
||||
check [testing guide](./testing-guide.mdc) to learn test scripts.
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI chat framework: lobe chat.
|
||||
|
||||
Emoji logo: 🤯
|
||||
|
||||
## Project Technologies Stack
|
||||
|
||||
read [package.json](mdc:package.json) to know all npm packages you can use. read [folder-structure.mdx](mdc:docs/development/basic/folder-structure.mdx) to learn project structure.
|
||||
|
||||
The project uses the following technologies:
|
||||
|
||||
- pnpm as package manager
|
||||
- Next.js 15 for frontend and backend, using app router instead of pages router
|
||||
- react 19, using hooks, functional components, react server components
|
||||
- TypeScript programming language
|
||||
- antd, @lobehub/ui for component framework
|
||||
- antd-style for css-in-js framework
|
||||
- react-layout-kit for flex layout
|
||||
- react-i18next for i18n
|
||||
- lucide-react, @ant-design/icons for icons
|
||||
- @lobehub/icons for AI provider/model logo icon
|
||||
- @formkit/auto-animate for react list animation
|
||||
- zustand for global state management
|
||||
- nuqs for type-safe search params state manager
|
||||
- SWR for react data fetch
|
||||
- aHooks for react hooks library
|
||||
- dayjs for date and time library
|
||||
- lodash-es for utility library
|
||||
- fast-deep-equal for deep comparison of JavaScript objects
|
||||
- zod for data validation
|
||||
- TRPC for type safe backend
|
||||
- PGLite for client DB and PostgreSQL for backend DB
|
||||
- Drizzle ORM
|
||||
- Vitest for testing, testing-library for react component test
|
||||
- Prettier for code formatting
|
||||
- ESLint for code linting
|
||||
- Cursor AI for code editing and AI coding assistance
|
||||
|
||||
Note: All tools and libraries used are the latest versions. The application only needs to be compatible with the latest browsers;
|
||||
|
||||
## Often used npm scripts
|
||||
|
||||
```bash
|
||||
# type check
|
||||
bun type-check
|
||||
|
||||
# install dependencies
|
||||
pnpm install
|
||||
|
||||
# !: don't any build script to check weather code can work after modify
|
||||
```
|
||||
|
||||
check [testing guide](./testing-guide.mdc) to learn test scripts.
|
||||
check [testing guide](./testing-guide/testing-guide.mdc) to learn test scripts.
|
||||
|
||||
@@ -1,881 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: *.test.ts,*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
type: agent-requested
|
||||
title: 测试指南 - LobeChat Testing Guide
|
||||
description: LobeChat 项目的 Vitest 测试环境配置、运行方式、修复原则指南
|
||||
---
|
||||
|
||||
# 测试指南 - LobeChat Testing Guide
|
||||
|
||||
## 🧪 测试环境概览
|
||||
|
||||
LobeChat 项目使用 Vitest 测试库,配置了两种不同的测试环境:
|
||||
|
||||
### 客户端测试环境 (DOM Environment)
|
||||
|
||||
- **配置文件**: [vitest.config.ts](mdc:vitest.config.ts)
|
||||
- **环境**: Happy DOM (浏览器环境模拟)
|
||||
- **数据库**: PGLite (浏览器环境的 PostgreSQL)
|
||||
- **用途**: 测试前端组件、客户端逻辑、React 组件等
|
||||
- **设置文件**: [tests/setup.ts](mdc:tests/setup.ts)
|
||||
|
||||
### 服务端测试环境 (Node Environment)
|
||||
|
||||
- **配置文件**: [vitest.config.server.ts](mdc:vitest.config.server.ts)
|
||||
- **环境**: Node.js
|
||||
- **数据库**: 真实的 PostgreSQL 数据库
|
||||
- **并发限制**: 单线程运行 (`singleFork: true`)
|
||||
- **用途**: 测试数据库模型、服务端逻辑、API 端点等
|
||||
- **设置文件**: [tests/setup-db.ts](mdc:tests/setup-db.ts)
|
||||
|
||||
## 🚀 测试运行命令
|
||||
|
||||
### package.json 脚本说明
|
||||
|
||||
查看 [package.json](mdc:package.json) 中的测试相关脚本:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "npm run test-app && npm run test-server",
|
||||
"test-app": "vitest run --config vitest.config.ts",
|
||||
"test-app:coverage": "vitest run --config vitest.config.ts --coverage",
|
||||
"test-server": "vitest run --config vitest.config.server.ts",
|
||||
"test-server:coverage": "vitest run --config vitest.config.server.ts --coverage"
|
||||
}
|
||||
```
|
||||
|
||||
### 推荐的测试运行方式
|
||||
|
||||
#### ✅ 正确的命令格式
|
||||
|
||||
```bash
|
||||
# 运行所有客户端测试
|
||||
npx vitest run --config vitest.config.ts
|
||||
|
||||
# 运行所有服务端测试
|
||||
npx vitest run --config vitest.config.server.ts
|
||||
|
||||
# 运行特定测试文件 (支持模糊匹配)
|
||||
npx vitest run --config vitest.config.ts basic
|
||||
npx vitest run --config vitest.config.ts user.test.ts
|
||||
|
||||
# 运行特定文件的特定行号
|
||||
npx vitest run --config vitest.config.ts src/utils/helper.test.ts:25
|
||||
npx vitest run --config vitest.config.ts basic/foo.test.ts:10,basic/foo.test.ts:25
|
||||
|
||||
# 过滤特定测试用例名称
|
||||
npx vitest -t "test case name" --config vitest.config.ts
|
||||
|
||||
# 组合使用文件和测试名称过滤
|
||||
npx vitest run --config vitest.config.ts filename.test.ts -t "specific test"
|
||||
```
|
||||
|
||||
#### ❌ 避免的命令格式
|
||||
|
||||
```bash
|
||||
# ❌ 不要使用 pnpm test xxx (这不是有效的 vitest 命令)
|
||||
pnpm test some-file
|
||||
|
||||
# ❌ 不要使用裸 vitest (会进入 watch 模式)
|
||||
vitest test-file.test.ts
|
||||
|
||||
# ❌ 不要混淆测试环境
|
||||
npx vitest run --config vitest.config.server.ts client-component.test.ts
|
||||
```
|
||||
|
||||
### 关键运行参数说明
|
||||
|
||||
- **`vitest run`**: 运行一次测试然后退出 (避免 watch 模式)
|
||||
- **`vitest`**: 默认进入 watch 模式,持续监听文件变化
|
||||
- **`--config`**: 指定配置文件,选择正确的测试环境
|
||||
- **`-t`**: 过滤测试用例名称,支持正则表达式
|
||||
- **`--coverage`**: 生成测试覆盖率报告
|
||||
|
||||
## 🔧 测试修复原则
|
||||
|
||||
### 核心原则 ⚠️
|
||||
|
||||
1. **充分阅读测试代码**: 在修复测试之前,必须完整理解测试的意图和实现
|
||||
2. **测试优先修复**: 如果是测试本身写错了,修改测试而不是实现代码
|
||||
3. **专注单一问题**: 只修复指定的测试,不要添加额外测试或功能
|
||||
4. **不自作主张**: 不要因为发现其他问题就直接修改,先提出再讨论
|
||||
|
||||
### 测试修复流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "阶段一:分析与复现"
|
||||
A[开始:收到测试失败报告] --> B[定位并运行失败的测试];
|
||||
B --> C{是否能在本地复现?};
|
||||
C -->|否| D[检查测试环境/配置/依赖];
|
||||
C -->|是| E[分析:阅读测试代码、错误日志、Git 历史];
|
||||
end
|
||||
|
||||
subgraph "阶段二:诊断与调试"
|
||||
E --> F[建立假设:问题出在测试、代码还是环境?];
|
||||
F --> G["调试:使用 console.log 或 debugger 深入检查"];
|
||||
G --> H{假设是否被证实?};
|
||||
H -->|否, 重新假设| F;
|
||||
end
|
||||
|
||||
subgraph "阶段三:修复与验证"
|
||||
H -->|是| I{确定根本原因};
|
||||
I -->|测试逻辑错误| J[修复测试代码];
|
||||
I -->|实现代码 Bug| K[修复实现代码];
|
||||
I -->|环境/配置问题| L[修复配置或依赖];
|
||||
J --> M[验证修复:重新运行失败的测试];
|
||||
K --> M;
|
||||
L --> M;
|
||||
M --> N{测试是否通过?};
|
||||
N -->|否, 修复无效| F;
|
||||
N -->|是| O[扩大验证:运行当前文件内所有测试];
|
||||
O --> P{是否全部通过?};
|
||||
P -->|否, 引入新问题| F;
|
||||
end
|
||||
|
||||
subgraph "阶段四:总结"
|
||||
P -->|是| Q[完成:撰写修复总结];
|
||||
end
|
||||
|
||||
D --> F;
|
||||
```
|
||||
|
||||
### 修复完成后的总结
|
||||
|
||||
测试修复完成后,应该提供简要说明,包括:
|
||||
|
||||
1. **错误原因分析**: 说明测试失败的根本原因
|
||||
- 测试逻辑错误
|
||||
- 实现代码bug
|
||||
- 环境配置问题
|
||||
- 依赖变更导致的问题
|
||||
|
||||
2. **修复方法说明**: 简述采用的修复方式
|
||||
- 修改了哪些文件
|
||||
- 采用了什么解决方案
|
||||
- 为什么选择这种修复方式
|
||||
|
||||
**示例格式**:
|
||||
|
||||
```markdown
|
||||
## 测试修复总结
|
||||
|
||||
**错误原因**: 测试中的 mock 数据格式与实际 API 返回格式不匹配,导致断言失败。
|
||||
|
||||
**修复方法**: 更新了测试文件中的 mock 数据结构,使其与最新的 API 响应格式保持一致。具体修改了 `user.test.ts` 中的 `mockUserData` 对象结构。
|
||||
```
|
||||
|
||||
## 📂 测试文件组织
|
||||
|
||||
### 文件命名约定
|
||||
|
||||
- **客户端测试**: `*.test.ts`, `*.test.tsx` (任意位置)
|
||||
- **服务端测试**: `src/database/models/**/*.test.ts`, `src/database/server/**/*.test.ts` (限定路径)
|
||||
|
||||
### 测试文件组织风格
|
||||
|
||||
项目采用 **测试文件与源文件同目录** 的组织风格:
|
||||
|
||||
- 测试文件放在对应源文件的同一目录下
|
||||
- 命名格式:`原文件名.test.ts` 或 `原文件名.test.tsx`
|
||||
|
||||
例如:
|
||||
|
||||
```
|
||||
src/components/Button/
|
||||
├── index.tsx # 源文件
|
||||
└── index.test.tsx # 测试文件
|
||||
```
|
||||
|
||||
## 🛠️ 测试调试技巧
|
||||
|
||||
### 运行失败测试的步骤
|
||||
|
||||
1. **确定测试类型**: 查看文件路径确定使用哪个配置
|
||||
2. **运行单个测试**: 使用 `-t` 参数隔离问题
|
||||
3. **检查错误日志**: 仔细阅读错误信息和堆栈跟踪
|
||||
4. **查看最近修改记录**: 检查相关文件的最近变更情况
|
||||
5. **添加调试日志**: 在测试中添加 `console.log` 了解执行流程
|
||||
|
||||
### Electron IPC 接口测试策略 🖥️
|
||||
|
||||
对于涉及 Electron IPC 接口的测试,由于提供真实的 Electron 环境比较复杂,采用 **Mock 返回值** 的方式进行测试。
|
||||
|
||||
#### 基本 Mock 设置
|
||||
|
||||
```typescript
|
||||
import { vi } from "vitest";
|
||||
import { electronIpcClient } from "@/server/modules/ElectronIPCClient";
|
||||
|
||||
// Mock Electron IPC 客户端
|
||||
vi.mock("@/server/modules/ElectronIPCClient", () => ({
|
||||
electronIpcClient: {
|
||||
getFilePathById: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
// 根据需要添加其他 IPC 方法
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
#### 在测试中设置 Mock 行为
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
// 重置所有 Mock
|
||||
vi.resetAllMocks();
|
||||
|
||||
// 设置默认的 Mock 返回值
|
||||
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue(
|
||||
"/path/to/file.txt"
|
||||
);
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 测试不同场景的示例
|
||||
|
||||
```typescript
|
||||
it("应该处理文件删除成功的情况", async () => {
|
||||
// 设置成功场景的 Mock
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const result = await service.deleteFiles(["desktop://file1.txt"]);
|
||||
|
||||
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith([
|
||||
"desktop://file1.txt",
|
||||
]);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("应该处理文件删除失败的情况", async () => {
|
||||
// 设置失败场景的 Mock
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValue(
|
||||
new Error("删除失败")
|
||||
);
|
||||
|
||||
const result = await service.deleteFiles(["desktop://file1.txt"]);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
#### Mock 策略的优势
|
||||
|
||||
1. **环境简化**: 避免了复杂的 Electron 环境搭建
|
||||
2. **测试可控**: 可以精确控制 IPC 调用的返回值和行为
|
||||
3. **场景覆盖**: 容易测试各种成功/失败场景
|
||||
4. **执行速度**: Mock 调用比真实 IPC 调用更快
|
||||
|
||||
#### 注意事项
|
||||
|
||||
- **Mock 准确性**: 确保 Mock 的行为与真实 IPC 接口行为一致
|
||||
- **类型安全**: 使用 `vi.mocked()` 确保类型安全
|
||||
- **Mock 重置**: 在 `beforeEach` 中重置 Mock 状态,避免测试间干扰
|
||||
- **调用验证**: 不仅要验证返回值,还要验证 IPC 方法是否被正确调用
|
||||
|
||||
### 检查最近修改记录 🔍
|
||||
|
||||
为了更好地判断测试失败的根本原因,需要**系统性地检查相关文件的修改历史**。这是问题定位的关键步骤。
|
||||
|
||||
#### 第一步:确定需要检查的文件范围
|
||||
|
||||
1. **测试文件本身**: `path/to/component.test.ts`
|
||||
2. **对应的实现文件**: `path/to/component.ts` 或 `path/to/component/index.ts`
|
||||
3. **相关依赖文件**: 测试或实现中导入的其他模块
|
||||
|
||||
#### 第二步:检查当前工作目录状态
|
||||
|
||||
```bash
|
||||
# 查看所有未提交的修改状态
|
||||
git status
|
||||
|
||||
# 重点关注测试文件和实现文件是否有未提交的修改
|
||||
git status | grep -E "(test|spec)"
|
||||
```
|
||||
|
||||
#### 第三步:检查未提交的修改内容
|
||||
|
||||
```bash
|
||||
# 查看测试文件的未提交修改 (工作区 vs 暂存区)
|
||||
git diff path/to/component.test.ts | cat
|
||||
|
||||
# 查看对应实现文件的未提交修改
|
||||
git diff path/to/component.ts | cat
|
||||
|
||||
# 查看已暂存但未提交的修改
|
||||
git diff --cached path/to/component.test.ts | cat
|
||||
git diff --cached path/to/component.ts | cat
|
||||
```
|
||||
|
||||
#### 第四步:检查提交历史和时间相关性
|
||||
|
||||
**首先查看提交时间,判断修改的时效性**:
|
||||
|
||||
```bash
|
||||
# 查看测试文件的最近提交历史,包含提交时间
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -5 path/to/component.test.ts | cat
|
||||
|
||||
# 查看实现文件的最近提交历史,包含提交时间
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -5 path/to/component.ts | cat
|
||||
|
||||
# 查看详细的提交时间(ISO格式,便于精确判断)
|
||||
git log --pretty=format:"%h %ad %an %s" --date=iso -3 path/to/component.ts | cat
|
||||
git log --pretty=format:"%h %ad %an %s" --date=iso -3 path/to/component.test.ts | cat
|
||||
```
|
||||
|
||||
**判断提交的参考价值**:
|
||||
|
||||
1. **最近提交(24小时内)**: 🔴 **高度相关** - 很可能是导致测试失败的直接原因
|
||||
2. **近期提交(1-7天内)**: 🟡 **中等相关** - 可能相关,需要仔细分析修改内容
|
||||
3. **较早提交(超过1周)**: ⚪ **低相关性** - 除非是重大重构,否则不太可能是直接原因
|
||||
|
||||
#### 第五步:基于时间相关性查看具体修改内容
|
||||
|
||||
**根据提交时间的远近,优先查看最近的修改**:
|
||||
|
||||
```bash
|
||||
# 如果有24小时内的提交,重点查看这些修改
|
||||
git show HEAD -- path/to/component.test.ts | cat
|
||||
git show HEAD -- path/to/component.ts | cat
|
||||
|
||||
# 查看次新的提交(如果最新提交时间较远)
|
||||
git show HEAD~1 -- path/to/component.ts | cat
|
||||
git show <recent-commit-hash> -- path/to/component.ts | cat
|
||||
|
||||
# 对比最近两次提交的差异
|
||||
git diff HEAD~1 HEAD -- path/to/component.ts | cat
|
||||
```
|
||||
|
||||
#### 第六步:分析修改与测试失败的关系
|
||||
|
||||
基于修改记录和时间相关性判断:
|
||||
|
||||
1. **最近修改了实现代码**:
|
||||
|
||||
```bash
|
||||
# 重点检查实现逻辑的变化
|
||||
git diff HEAD~1 path/to/component.ts | cat
|
||||
```
|
||||
|
||||
- 很可能是实现代码的变更导致测试失败
|
||||
- 检查实现逻辑是否正确
|
||||
- 确认测试是否需要相应更新
|
||||
|
||||
2. **最近修改了测试代码**:
|
||||
|
||||
```bash
|
||||
# 重点检查测试逻辑的变化
|
||||
git diff HEAD~1 path/to/component.test.ts | cat
|
||||
```
|
||||
|
||||
- 可能是测试本身写错了
|
||||
- 检查测试逻辑和断言是否正确
|
||||
- 确认测试是否符合实现的预期行为
|
||||
|
||||
3. **两者都有最近修改**:
|
||||
|
||||
```bash
|
||||
# 对比两个文件的修改时间
|
||||
git log --pretty=format:"%ad %f" --date=iso -1 path/to/component.ts | cat
|
||||
git log --pretty=format:"%ad %f" --date=iso -1 path/to/component.test.ts | cat
|
||||
```
|
||||
|
||||
- 需要综合分析两者的修改
|
||||
- 确定哪个修改更可能导致问题
|
||||
- 优先检查时间更近的修改
|
||||
|
||||
4. **都没有最近修改**:
|
||||
- 可能是依赖变更或环境问题
|
||||
- 检查 `package.json`、配置文件等的修改
|
||||
- 查看是否有全局性的代码重构
|
||||
|
||||
#### 修改记录检查示例
|
||||
|
||||
```bash
|
||||
# 完整的检查流程示例
|
||||
echo "=== 检查文件修改状态 ==="
|
||||
git status | grep component
|
||||
|
||||
echo "=== 检查未提交修改 ==="
|
||||
git diff src/components/Button/index.test.tsx | cat
|
||||
git diff src/components/Button/index.tsx | cat
|
||||
|
||||
echo "=== 检查提交历史和时间 ==="
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -3 src/components/Button/index.test.tsx | cat
|
||||
git log --pretty=format:"%h %ad %s" --date=relative -3 src/components/Button/index.tsx | cat
|
||||
|
||||
echo "=== 根据时间优先级查看修改内容 ==="
|
||||
# 如果有24小时内的提交,重点查看
|
||||
git show HEAD -- src/components/Button/index.tsx | cat
|
||||
```
|
||||
|
||||
## 🗃️ 数据库 Model 测试指南
|
||||
|
||||
### 测试环境选择 💡
|
||||
|
||||
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
|
||||
|
||||
### ⚠️ 双环境验证要求
|
||||
|
||||
**对于所有 Model 测试,必须在两个环境下都验证通过**:
|
||||
|
||||
#### 完整验证流程
|
||||
|
||||
```bash
|
||||
# 1. 先在客户端环境测试(快速验证)
|
||||
npx vitest run --config vitest.config.ts src/database/models/__tests__/myModel.test.ts
|
||||
|
||||
# 2. 再在服务端环境测试(兼容性验证)
|
||||
npx vitest run --config vitest.config.server.ts src/database/models/__tests__/myModel.test.ts
|
||||
```
|
||||
|
||||
### 创建新 Model 测试的最佳实践 📋
|
||||
|
||||
#### 1. 参考现有实现和测试模板
|
||||
|
||||
创建新 Model 测试前,**必须先参考现有的实现模式**:
|
||||
|
||||
- **Model 实现参考**:
|
||||
- **测试模板参考**:
|
||||
- **复杂示例参考**:
|
||||
|
||||
#### 2. 用户权限检查 - 安全第一 🔒
|
||||
|
||||
这是**最关键的安全要求**。所有涉及用户数据的操作都必须包含用户权限检查:
|
||||
|
||||
**❌ 错误示例 - 存在安全漏洞**:
|
||||
|
||||
```typescript
|
||||
// 危险:缺少用户权限检查,任何用户都能操作任何数据
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(eq(myTable.id, id)) // ❌ 只检查 ID,没有检查 userId
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
**✅ 正确示例 - 安全的实现**:
|
||||
|
||||
```typescript
|
||||
// 安全:必须同时匹配 ID 和 userId
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(
|
||||
and(
|
||||
eq(myTable.id, id),
|
||||
eq(myTable.userId, this.userId) // ✅ 用户权限检查
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
**必须进行用户权限检查的方法**:
|
||||
|
||||
- `update()` - 更新操作
|
||||
- `delete()` - 删除操作
|
||||
- `findById()` - 查找特定记录
|
||||
- 任何涉及特定记录的查询或修改操作
|
||||
|
||||
#### 3. 测试文件结构和必测场景
|
||||
|
||||
**基本测试结构**:
|
||||
|
||||
```typescript
|
||||
// @vitest-environment node
|
||||
describe("MyModel", () => {
|
||||
describe("create", () => {
|
||||
it("should create a new record");
|
||||
it("should handle edge cases");
|
||||
});
|
||||
|
||||
describe("queryAll", () => {
|
||||
it("should return records for current user only");
|
||||
it("should handle empty results");
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update own records");
|
||||
it("should NOT update other users records"); // 🔒 安全测试
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete own records");
|
||||
it("should NOT delete other users records"); // 🔒 安全测试
|
||||
});
|
||||
|
||||
describe("user isolation", () => {
|
||||
it("should enforce user data isolation"); // 🔒 核心安全测试
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**必须测试的安全场景** 🔒:
|
||||
|
||||
```typescript
|
||||
it("should not update records of other users", async () => {
|
||||
// 创建其他用户的记录
|
||||
const [otherUserRecord] = await serverDB
|
||||
.insert(myTable)
|
||||
.values({ userId: "other-user", data: "original" })
|
||||
.returning();
|
||||
|
||||
// 尝试更新其他用户的记录
|
||||
const result = await myModel.update(otherUserRecord.id, { data: "hacked" });
|
||||
|
||||
// 应该返回 undefined 或空数组(因为权限检查失败)
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// 验证原始数据未被修改
|
||||
const unchanged = await serverDB.query.myTable.findFirst({
|
||||
where: eq(myTable.id, otherUserRecord.id),
|
||||
});
|
||||
expect(unchanged?.data).toBe("original"); // 数据应该保持不变
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. Mock 外部依赖服务
|
||||
|
||||
如果 Model 依赖外部服务(如 FileService),需要正确 Mock:
|
||||
|
||||
**设置 Mock**:
|
||||
|
||||
```typescript
|
||||
// 在文件顶部设置 Mock
|
||||
const mockGetFullFileUrl = vi.fn();
|
||||
vi.mock("@/server/services/file", () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({
|
||||
getFullFileUrl: mockGetFullFileUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
// 在 beforeEach 中重置和配置 Mock
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockGetFullFileUrl.mockImplementation(
|
||||
(url: string) => `https://example.com/${url}`
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**验证 Mock 调用**:
|
||||
|
||||
```typescript
|
||||
it("should process URLs through FileService", async () => {
|
||||
// ... 测试逻辑
|
||||
|
||||
// 验证 Mock 被正确调用
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledWith("expected-url");
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. 数据库状态管理
|
||||
|
||||
**正确的数据清理模式**:
|
||||
|
||||
```typescript
|
||||
const userId = "test-user";
|
||||
const otherUserId = "other-user";
|
||||
|
||||
beforeEach(async () => {
|
||||
// 清理用户表(级联删除相关数据)
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试用户
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 清理测试数据
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. 测试数据类型和外键约束处理 ⚠️
|
||||
|
||||
**必须使用 Schema 导出的类型**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用 schema 导出的类型
|
||||
import { NewGenerationBatch, NewGeneration } from '../../schemas';
|
||||
|
||||
const testBatch: NewGenerationBatch = {
|
||||
userId,
|
||||
generationTopicId: 'test-topic-id',
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
prompt: 'Test prompt for image generation',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
config: { /* ... */ },
|
||||
};
|
||||
|
||||
const testGeneration: NewGeneration = {
|
||||
id: 'test-gen-id',
|
||||
generationBatchId: 'test-batch-id',
|
||||
asyncTaskId: null, // 处理外键约束
|
||||
fileId: null, // 处理外键约束
|
||||
seed: 12345,
|
||||
userId,
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:没有类型声明或使用错误类型
|
||||
const testBatch = { // 缺少类型声明
|
||||
generationTopicId: 'test-topic-id',
|
||||
// ...
|
||||
};
|
||||
|
||||
const testGeneration = { // 缺少类型声明
|
||||
asyncTaskId: 'invalid-uuid', // 外键约束错误
|
||||
fileId: 'non-existent-file', // 外键约束错误
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**外键约束处理策略**:
|
||||
|
||||
1. **使用 null 值**: 对于可选的外键字段,使用 null 避免约束错误
|
||||
2. **创建关联记录**: 如果需要测试关联关系,先创建被引用的记录
|
||||
3. **理解约束关系**: 了解哪些字段有外键约束,避免引用不存在的记录
|
||||
|
||||
```typescript
|
||||
// 外键约束处理示例
|
||||
beforeEach(async () => {
|
||||
// 清理数据库
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试用户
|
||||
await serverDB.insert(users).values([{ id: userId }]);
|
||||
|
||||
// 如果需要测试文件关联,创建文件记录
|
||||
if (needsFileAssociation) {
|
||||
await serverDB.insert(files).values({
|
||||
id: 'test-file-id',
|
||||
userId,
|
||||
name: 'test.jpg',
|
||||
url: 'test-url',
|
||||
size: 1024,
|
||||
fileType: 'image/jpeg',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**排序测试的可预测性**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用明确的时间戳确保排序结果可预测
|
||||
it('should find batches by topic id in correct order', async () => {
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
|
||||
const batch1 = { ...testBatch, prompt: 'First batch', userId, createdAt: oldDate };
|
||||
const batch2 = { ...testBatch, prompt: 'Second batch', userId, createdAt: newDate };
|
||||
|
||||
await serverDB.insert(generationBatches).values([batch1, batch2]);
|
||||
|
||||
const results = await generationBatchModel.findByTopicId(testTopic.id);
|
||||
|
||||
expect(results[0].prompt).toBe('Second batch'); // 最新优先 (desc order)
|
||||
expect(results[1].prompt).toBe('First batch');
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:依赖数据库的默认时间戳,结果不可预测
|
||||
it('should find batches by topic id', async () => {
|
||||
const batch1 = { ...testBatch, prompt: 'First batch', userId };
|
||||
const batch2 = { ...testBatch, prompt: 'Second batch', userId };
|
||||
|
||||
await serverDB.insert(generationBatches).values([batch1, batch2]);
|
||||
|
||||
// 插入顺序和数据库时间戳可能不一致,导致测试不稳定
|
||||
const results = await generationBatchModel.findByTopicId(testTopic.id);
|
||||
expect(results[0].prompt).toBe('Second batch'); // 可能失败
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 常见问题和解决方案 💡
|
||||
|
||||
#### 问题 1:权限检查缺失导致安全漏洞
|
||||
|
||||
**现象**: 测试失败,用户能修改其他用户的数据
|
||||
**解决**: 在 Model 的 `update` 和 `delete` 方法中添加 `and(eq(table.id, id), eq(table.userId, this.userId))`
|
||||
|
||||
#### 问题 2:Mock 未生效或验证失败
|
||||
|
||||
**现象**: `undefined is not a spy` 错误
|
||||
**解决**: 检查 Mock 设置位置和方式,确保在测试文件顶部设置,在 `beforeEach` 中重置
|
||||
|
||||
#### 问题 3:测试数据污染
|
||||
|
||||
**现象**: 测试间相互影响,结果不稳定
|
||||
**解决**: 在 `beforeEach` 和 `afterEach` 中正确清理数据库状态
|
||||
|
||||
#### 问题 4:外部依赖导致测试失败
|
||||
|
||||
**现象**: 因为真实的外部服务调用导致测试不稳定
|
||||
**解决**: Mock 所有外部依赖,使测试更可控和快速
|
||||
|
||||
#### 问题 5:外键约束违反导致测试失败
|
||||
|
||||
**现象**: `insert or update on table "xxx" violates foreign key constraint`
|
||||
**解决**:
|
||||
- 将可选外键字段设为 `null` 而不是无效的字符串值
|
||||
- 或者先创建被引用的记录,再创建当前记录
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:无效的外键值
|
||||
const testData = {
|
||||
asyncTaskId: 'invalid-uuid', // 表中不存在此记录
|
||||
fileId: 'non-existent-file', // 表中不存在此记录
|
||||
};
|
||||
|
||||
// ✅ 正确:使用 null 值
|
||||
const testData = {
|
||||
asyncTaskId: null, // 避免外键约束
|
||||
fileId: null, // 避免外键约束
|
||||
};
|
||||
|
||||
// ✅ 或者:先创建被引用的记录
|
||||
beforeEach(async () => {
|
||||
const [asyncTask] = await serverDB.insert(asyncTasks).values({
|
||||
id: 'valid-task-id',
|
||||
status: 'pending',
|
||||
type: 'generation',
|
||||
}).returning();
|
||||
|
||||
const testData = {
|
||||
asyncTaskId: asyncTask.id, // 使用有效的外键值
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### 问题 6:排序测试结果不一致
|
||||
|
||||
**现象**: 相同的测试有时通过,有时失败,特别是涉及排序的测试
|
||||
**解决**: 使用明确的时间戳,不要依赖数据库的默认时间戳
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:依赖插入顺序和默认时间戳
|
||||
await serverDB.insert(table).values([data1, data2]); // 时间戳不可预测
|
||||
|
||||
// ✅ 正确:明确指定时间戳
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
await serverDB.insert(table).values([
|
||||
{ ...data1, createdAt: oldDate },
|
||||
{ ...data2, createdAt: newDate },
|
||||
]);
|
||||
```
|
||||
|
||||
#### 问题 7:Mock 验证失败或调用次数不匹配
|
||||
|
||||
**现象**: `expect(mockFunction).toHaveBeenCalledWith(...)` 失败
|
||||
**解决**:
|
||||
- 检查 Mock 函数的实际调用参数和期望参数是否完全匹配
|
||||
- 确认 Mock 在正确的时机被重置和配置
|
||||
- 使用 `toHaveBeenCalledTimes()` 验证调用次数
|
||||
|
||||
```typescript
|
||||
// 在 beforeEach 中正确配置 Mock
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 重置所有 Mock
|
||||
|
||||
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
|
||||
mockTransformGeneration.mockResolvedValue({
|
||||
id: 'test-id',
|
||||
// ... 其他字段
|
||||
});
|
||||
});
|
||||
|
||||
// 测试中验证 Mock 调用
|
||||
it('should call FileService with correct parameters', async () => {
|
||||
await model.someMethod();
|
||||
|
||||
// 验证调用参数
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
|
||||
// 验证调用次数
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Model 测试检查清单 ✅
|
||||
|
||||
创建 Model 测试时,请确保以下各项都已完成:
|
||||
|
||||
#### 🔧 基础配置
|
||||
- [ ] **双环境验证** - 在客户端环境 (vitest.config.ts) 和服务端环境 (vitest.config.server.ts) 下都测试通过
|
||||
- [ ] 参考了 `_template.ts` 和现有 Model 的实现模式
|
||||
- [ ] **使用正确的 Schema 类型** - 测试数据使用 `NewXxx` 类型声明,如 `NewGenerationBatch`、`NewGeneration`
|
||||
|
||||
#### 🔒 安全测试
|
||||
- [ ] **所有涉及用户数据的操作都包含用户权限检查**
|
||||
- [ ] 包含了用户权限隔离的安全测试
|
||||
- [ ] 测试了用户无法访问其他用户数据的场景
|
||||
|
||||
#### 🗃️ 数据处理
|
||||
- [ ] **正确处理外键约束** - 使用 `null` 值或先创建被引用记录
|
||||
- [ ] **排序测试使用明确时间戳** - 不依赖数据库默认时间,确保结果可预测
|
||||
- [ ] 在 `beforeEach` 和 `afterEach` 中正确管理数据库状态
|
||||
- [ ] 所有测试都能独立运行且互不干扰
|
||||
|
||||
#### 🎭 Mock 和外部依赖
|
||||
- [ ] 正确 Mock 了外部依赖服务 (如 FileService、GenerationModel)
|
||||
- [ ] 在 `beforeEach` 中重置和配置 Mock
|
||||
- [ ] 验证了 Mock 服务的调用参数和次数
|
||||
- [ ] 测试了外部服务错误场景的处理
|
||||
|
||||
#### 📋 测试覆盖
|
||||
- [ ] 测试覆盖了所有主要方法 (create, query, update, delete)
|
||||
- [ ] 测试了边界条件和错误场景
|
||||
- [ ] 包含了空结果处理的测试
|
||||
- [ ] **确认两个环境下的测试结果一致**
|
||||
|
||||
#### 🚨 常见问题检查
|
||||
- [ ] 没有外键约束违反错误
|
||||
- [ ] 排序测试结果稳定可预测
|
||||
- [ ] Mock 验证无失败
|
||||
- [ ] 无测试数据污染问题
|
||||
|
||||
### 安全警告 ⚠️
|
||||
|
||||
**数据库 Model 层是安全的第一道防线**。如果 Model 层缺少用户权限检查:
|
||||
|
||||
1. **任何用户都能访问和修改其他用户的数据**
|
||||
2. **即使上层有权限检查,也可能被绕过**
|
||||
3. **可能导致严重的数据泄露和安全事故**
|
||||
|
||||
因此,**每个涉及用户数据的 Model 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
修复测试时,记住以下关键点:
|
||||
|
||||
- **使用正确的命令**: `npx vitest run --config [config-file]`
|
||||
- **理解测试意图**: 先读懂测试再修复
|
||||
- **查看最近修改**: 检查相关文件的 git 修改记录,判断问题根源
|
||||
- **选择正确环境**: 客户端测试用 `vitest.config.ts`,服务端用 `vitest.config.server.ts`
|
||||
- **专注单一问题**: 只修复当前的测试失败
|
||||
- **验证修复结果**: 确保修复后测试通过且无副作用
|
||||
- **提供修复总结**: 说明错误原因和修复方法
|
||||
- **Model 测试安全第一**: 必须包含用户权限检查和对应的安全测试
|
||||
- **Model 双环境验证**: 必须在 PGLite 和 PostgreSQL 两个环境下都验证通过
|
||||
@@ -0,0 +1,453 @@
|
||||
---
|
||||
globs: src/database/**/*.test.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## 🗃️ 数据库 Model 测试指南
|
||||
|
||||
### 测试环境选择 💡
|
||||
|
||||
数据库 Model 层通过环境变量控制数据库类型,在两种测试环境下有不同的数据库后端:客户端环境 (PGLite) 和 服务端环境 (PostgreSQL)
|
||||
|
||||
### ⚠️ 双环境验证要求
|
||||
|
||||
**对于所有 Model 测试,必须在两个环境下都验证通过**:
|
||||
|
||||
#### 完整验证流程
|
||||
|
||||
```bash
|
||||
# 1. 先在客户端环境测试(快速验证)
|
||||
npx vitest run --config vitest.config.ts src/database/models/__tests__/myModel.test.ts
|
||||
|
||||
# 2. 再在服务端环境测试(兼容性验证)
|
||||
npx vitest run --config vitest.config.server.ts src/database/models/__tests__/myModel.test.ts
|
||||
```
|
||||
|
||||
### 创建新 Model 测试的最佳实践 📋
|
||||
|
||||
#### 1. 参考现有实现和测试模板
|
||||
|
||||
创建新 Model 测试前,**必须先参考现有的实现模式**:
|
||||
|
||||
- **Model 实现参考**:
|
||||
- **测试模板参考**:
|
||||
- **复杂示例参考**:
|
||||
|
||||
#### 2. 用户权限检查 - 安全第一 🔒
|
||||
|
||||
这是**最关键的安全要求**。所有涉及用户数据的操作都必须包含用户权限检查:
|
||||
|
||||
**❌ 错误示例 - 存在安全漏洞**:
|
||||
|
||||
```typescript
|
||||
// 危险:缺少用户权限检查,任何用户都能操作任何数据
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(eq(myTable.id, id)) // ❌ 只检查 ID,没有检查 userId
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
**✅ 正确示例 - 安全的实现**:
|
||||
|
||||
```typescript
|
||||
// 安全:必须同时匹配 ID 和 userId
|
||||
update = async (id: string, data: Partial<MyModel>) => {
|
||||
return this.db
|
||||
.update(myTable)
|
||||
.set(data)
|
||||
.where(
|
||||
and(
|
||||
eq(myTable.id, id),
|
||||
eq(myTable.userId, this.userId), // ✅ 用户权限检查
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
};
|
||||
```
|
||||
|
||||
**必须进行用户权限检查的方法**:
|
||||
|
||||
- `update()` - 更新操作
|
||||
- `delete()` - 删除操作
|
||||
- `findById()` - 查找特定记录
|
||||
- 任何涉及特定记录的查询或修改操作
|
||||
|
||||
#### 3. 测试文件结构和必测场景
|
||||
|
||||
**基本测试结构**:
|
||||
|
||||
```typescript
|
||||
// @vitest-environment node
|
||||
describe('MyModel', () => {
|
||||
describe('create', () => {
|
||||
it('should create a new record');
|
||||
it('should handle edge cases');
|
||||
});
|
||||
|
||||
describe('queryAll', () => {
|
||||
it('should return records for current user only');
|
||||
it('should handle empty results');
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update own records');
|
||||
it('should NOT update other users records'); // 🔒 安全测试
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete own records');
|
||||
it('should NOT delete other users records'); // 🔒 安全测试
|
||||
});
|
||||
|
||||
describe('user isolation', () => {
|
||||
it('should enforce user data isolation'); // 🔒 核心安全测试
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**必须测试的安全场景** 🔒:
|
||||
|
||||
```typescript
|
||||
it('should not update records of other users', async () => {
|
||||
// 创建其他用户的记录
|
||||
const [otherUserRecord] = await serverDB
|
||||
.insert(myTable)
|
||||
.values({ userId: 'other-user', data: 'original' })
|
||||
.returning();
|
||||
|
||||
// 尝试更新其他用户的记录
|
||||
const result = await myModel.update(otherUserRecord.id, { data: 'hacked' });
|
||||
|
||||
// 应该返回 undefined 或空数组(因为权限检查失败)
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// 验证原始数据未被修改
|
||||
const unchanged = await serverDB.query.myTable.findFirst({
|
||||
where: eq(myTable.id, otherUserRecord.id),
|
||||
});
|
||||
expect(unchanged?.data).toBe('original'); // 数据应该保持不变
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. Mock 外部依赖服务
|
||||
|
||||
如果 Model 依赖外部服务(如 FileService),需要正确 Mock:
|
||||
|
||||
**设置 Mock**:
|
||||
|
||||
```typescript
|
||||
// 在文件顶部设置 Mock
|
||||
const mockGetFullFileUrl = vi.fn();
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({
|
||||
getFullFileUrl: mockGetFullFileUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
// 在 beforeEach 中重置和配置 Mock
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
|
||||
});
|
||||
```
|
||||
|
||||
**验证 Mock 调用**:
|
||||
|
||||
```typescript
|
||||
it('should process URLs through FileService', async () => {
|
||||
// ... 测试逻辑
|
||||
|
||||
// 验证 Mock 被正确调用
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. 数据库状态管理
|
||||
|
||||
**正确的数据清理模式**:
|
||||
|
||||
```typescript
|
||||
const userId = 'test-user';
|
||||
const otherUserId = 'other-user';
|
||||
|
||||
beforeEach(async () => {
|
||||
// 清理用户表(级联删除相关数据)
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试用户
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 清理测试数据
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
```
|
||||
|
||||
#### 6. 测试数据类型和外键约束处理 ⚠️
|
||||
|
||||
**必须使用 Schema 导出的类型**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用 schema 导出的类型
|
||||
import { NewGeneration, NewGenerationBatch } from '../../schemas';
|
||||
|
||||
const testBatch: NewGenerationBatch = {
|
||||
userId,
|
||||
generationTopicId: 'test-topic-id',
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
prompt: 'Test prompt for image generation',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
config: {
|
||||
/* ... */
|
||||
},
|
||||
};
|
||||
|
||||
const testGeneration: NewGeneration = {
|
||||
id: 'test-gen-id',
|
||||
generationBatchId: 'test-batch-id',
|
||||
asyncTaskId: null, // 处理外键约束
|
||||
fileId: null, // 处理外键约束
|
||||
seed: 12345,
|
||||
userId,
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:没有类型声明或使用错误类型
|
||||
const testBatch = {
|
||||
// 缺少类型声明
|
||||
generationTopicId: 'test-topic-id',
|
||||
// ...
|
||||
};
|
||||
|
||||
const testGeneration = {
|
||||
// 缺少类型声明
|
||||
asyncTaskId: 'invalid-uuid', // 外键约束错误
|
||||
fileId: 'non-existent-file', // 外键约束错误
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**外键约束处理策略**:
|
||||
|
||||
1. **使用 null 值**: 对于可选的外键字段,使用 null 避免约束错误
|
||||
2. **创建关联记录**: 如果需要测试关联关系,先创建被引用的记录
|
||||
3. **理解约束关系**: 了解哪些字段有外键约束,避免引用不存在的记录
|
||||
|
||||
```typescript
|
||||
// 外键约束处理示例
|
||||
beforeEach(async () => {
|
||||
// 清理数据库
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试用户
|
||||
await serverDB.insert(users).values([{ id: userId }]);
|
||||
|
||||
// 如果需要测试文件关联,创建文件记录
|
||||
if (needsFileAssociation) {
|
||||
await serverDB.insert(files).values({
|
||||
id: 'test-file-id',
|
||||
userId,
|
||||
name: 'test.jpg',
|
||||
url: 'test-url',
|
||||
size: 1024,
|
||||
fileType: 'image/jpeg',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**排序测试的可预测性**:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用明确的时间戳确保排序结果可预测
|
||||
it('should find batches by topic id in correct order', async () => {
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
|
||||
const batch1 = { ...testBatch, prompt: 'First batch', userId, createdAt: oldDate };
|
||||
const batch2 = { ...testBatch, prompt: 'Second batch', userId, createdAt: newDate };
|
||||
|
||||
await serverDB.insert(generationBatches).values([batch1, batch2]);
|
||||
|
||||
const results = await generationBatchModel.findByTopicId(testTopic.id);
|
||||
|
||||
expect(results[0].prompt).toBe('Second batch'); // 最新优先 (desc order)
|
||||
expect(results[1].prompt).toBe('First batch');
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:依赖数据库的默认时间戳,结果不可预测
|
||||
it('should find batches by topic id', async () => {
|
||||
const batch1 = { ...testBatch, prompt: 'First batch', userId };
|
||||
const batch2 = { ...testBatch, prompt: 'Second batch', userId };
|
||||
|
||||
await serverDB.insert(generationBatches).values([batch1, batch2]);
|
||||
|
||||
// 插入顺序和数据库时间戳可能不一致,导致测试不稳定
|
||||
const results = await generationBatchModel.findByTopicId(testTopic.id);
|
||||
expect(results[0].prompt).toBe('Second batch'); // 可能失败
|
||||
});
|
||||
```
|
||||
|
||||
### 常见问题和解决方案 💡
|
||||
|
||||
#### 问题 1:权限检查缺失导致安全漏洞
|
||||
|
||||
**现象**: 测试失败,用户能修改其他用户的数据 **解决**: 在 Model 的 `update` 和 `delete` 方法中添加 `and(eq(table.id, id), eq(table.userId, this.userId))`
|
||||
|
||||
#### 问题 2:Mock 未生效或验证失败
|
||||
|
||||
**现象**: `undefined is not a spy` 错误 **解决**: 检查 Mock 设置位置和方式,确保在测试文件顶部设置,在 `beforeEach` 中重置
|
||||
|
||||
#### 问题 3:测试数据污染
|
||||
|
||||
**现象**: 测试间相互影响,结果不稳定 **解决**: 在 `beforeEach` 和 `afterEach` 中正确清理数据库状态
|
||||
|
||||
#### 问题 4:外部依赖导致测试失败
|
||||
|
||||
**现象**: 因为真实的外部服务调用导致测试不稳定 **解决**: Mock 所有外部依赖,使测试更可控和快速
|
||||
|
||||
#### 问题 5:外键约束违反导致测试失败
|
||||
|
||||
**现象**: `insert or update on table "xxx" violates foreign key constraint` **解决**:
|
||||
|
||||
- 将可选外键字段设为 `null` 而不是无效的字符串值
|
||||
- 或者先创建被引用的记录,再创建当前记录
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:无效的外键值
|
||||
const testData = {
|
||||
asyncTaskId: 'invalid-uuid', // 表中不存在此记录
|
||||
fileId: 'non-existent-file', // 表中不存在此记录
|
||||
};
|
||||
|
||||
// ✅ 正确:使用 null 值
|
||||
const testData = {
|
||||
asyncTaskId: null, // 避免外键约束
|
||||
fileId: null, // 避免外键约束
|
||||
};
|
||||
|
||||
// ✅ 或者:先创建被引用的记录
|
||||
beforeEach(async () => {
|
||||
const [asyncTask] = await serverDB.insert(asyncTasks).values({
|
||||
id: 'valid-task-id',
|
||||
status: 'pending',
|
||||
type: 'generation',
|
||||
}).returning();
|
||||
|
||||
const testData = {
|
||||
asyncTaskId: asyncTask.id, // 使用有效的外键值
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### 问题 6:排序测试结果不一致
|
||||
|
||||
**现象**: 相同的测试有时通过,有时失败,特别是涉及排序的测试 **解决**: 使用明确的时间戳,不要依赖数据库的默认时间戳
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:依赖插入顺序和默认时间戳
|
||||
await serverDB.insert(table).values([data1, data2]); // 时间戳不可预测
|
||||
|
||||
// ✅ 正确:明确指定时间戳
|
||||
const oldDate = new Date('2024-01-01T10:00:00Z');
|
||||
const newDate = new Date('2024-01-02T10:00:00Z');
|
||||
await serverDB.insert(table).values([
|
||||
{ ...data1, createdAt: oldDate },
|
||||
{ ...data2, createdAt: newDate },
|
||||
]);
|
||||
```
|
||||
|
||||
#### 问题 7:Mock 验证失败或调用次数不匹配
|
||||
|
||||
**现象**: `expect(mockFunction).toHaveBeenCalledWith(...)` 失败 **解决**:
|
||||
|
||||
- 检查 Mock 函数的实际调用参数和期望参数是否完全匹配
|
||||
- 确认 Mock 在正确的时机被重置和配置
|
||||
- 使用 `toHaveBeenCalledTimes()` 验证调用次数
|
||||
|
||||
```typescript
|
||||
// 在 beforeEach 中正确配置 Mock
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // 重置所有 Mock
|
||||
|
||||
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
|
||||
mockTransformGeneration.mockResolvedValue({
|
||||
id: 'test-id',
|
||||
// ... 其他字段
|
||||
});
|
||||
});
|
||||
|
||||
// 测试中验证 Mock 调用
|
||||
it('should call FileService with correct parameters', async () => {
|
||||
await model.someMethod();
|
||||
|
||||
// 验证调用参数
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledWith('expected-url');
|
||||
// 验证调用次数
|
||||
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Model 测试检查清单 ✅
|
||||
|
||||
创建 Model 测试时,请确保以下各项都已完成:
|
||||
|
||||
#### 🔧 基础配置
|
||||
|
||||
- [ ] **双环境验证** - 在客户端环境 (vitest.config.ts) 和服务端环境 (vitest.config.server.ts) 下都测试通过
|
||||
- [ ] 参考了 `_template.ts` 和现有 Model 的实现模式
|
||||
- [ ] **使用正确的 Schema 类型** - 测试数据使用 `NewXxx` 类型声明,如 `NewGenerationBatch`、`NewGeneration`
|
||||
|
||||
#### 🔒 安全测试
|
||||
|
||||
- [ ] **所有涉及用户数据的操作都包含用户权限检查**
|
||||
- [ ] 包含了用户权限隔离的安全测试
|
||||
- [ ] 测试了用户无法访问其他用户数据的场景
|
||||
|
||||
#### 🗃️ 数据处理
|
||||
|
||||
- [ ] **正确处理外键约束** - 使用 `null` 值或先创建被引用记录
|
||||
- [ ] **排序测试使用明确时间戳** - 不依赖数据库默认时间,确保结果可预测
|
||||
- [ ] 在 `beforeEach` 和 `afterEach` 中正确管理数据库状态
|
||||
- [ ] 所有测试都能独立运行且互不干扰
|
||||
|
||||
#### 🎭 Mock 和外部依赖
|
||||
|
||||
- [ ] 正确 Mock 了外部依赖服务 (如 FileService、GenerationModel)
|
||||
- [ ] 在 `beforeEach` 中重置和配置 Mock
|
||||
- [ ] 验证了 Mock 服务的调用参数和次数
|
||||
- [ ] 测试了外部服务错误场景的处理
|
||||
|
||||
#### 📋 测试覆盖
|
||||
|
||||
- [ ] 测试覆盖了所有主要方法 (create, query, update, delete)
|
||||
- [ ] 测试了边界条件和错误场景
|
||||
- [ ] 包含了空结果处理的测试
|
||||
- [ ] **确认两个环境下的测试结果一致**
|
||||
|
||||
#### 🚨 常见问题检查
|
||||
|
||||
- [ ] 没有外键约束违反错误
|
||||
- [ ] 排序测试结果稳定可预测
|
||||
- [ ] Mock 验证无失败
|
||||
- [ ] 无测试数据污染问题
|
||||
|
||||
### 安全警告 ⚠️
|
||||
|
||||
**数据库 Model 层是安全的第一道防线**。如果 Model 层缺少用户权限检查:
|
||||
|
||||
1. **任何用户都能访问和修改其他用户的数据**
|
||||
2. **即使上层有权限检查,也可能被绕过**
|
||||
3. **可能导致严重的数据泄露和安全事故**
|
||||
|
||||
因此,**每个涉及用户数据的 Model 方法都必须包含用户权限检查,且必须有对应的安全测试来验证这些检查的有效性**。
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
description: Electron IPC 接口测试策略
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
### Electron IPC 接口测试策略 🖥️
|
||||
|
||||
对于涉及 Electron IPC 接口的测试,由于提供真实的 Electron 环境比较复杂,采用 **Mock 返回值** 的方式进行测试。
|
||||
|
||||
#### 基本 Mock 设置
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
|
||||
|
||||
// Mock Electron IPC 客户端
|
||||
vi.mock('@/server/modules/ElectronIPCClient', () => ({
|
||||
electronIpcClient: {
|
||||
getFilePathById: vi.fn(),
|
||||
deleteFiles: vi.fn(),
|
||||
// 根据需要添加其他 IPC 方法
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
#### 在测试中设置 Mock 行为
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
// 重置所有 Mock
|
||||
vi.resetAllMocks();
|
||||
|
||||
// 设置默认的 Mock 返回值
|
||||
vi.mocked(electronIpcClient.getFilePathById).mockResolvedValue('/path/to/file.txt');
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 测试不同场景的示例
|
||||
|
||||
```typescript
|
||||
it('应该处理文件删除成功的情况', async () => {
|
||||
// 设置成功场景的 Mock
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const result = await service.deleteFiles(['desktop://file1.txt']);
|
||||
|
||||
expect(electronIpcClient.deleteFiles).toHaveBeenCalledWith(['desktop://file1.txt']);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理文件删除失败的情况', async () => {
|
||||
// 设置失败场景的 Mock
|
||||
vi.mocked(electronIpcClient.deleteFiles).mockRejectedValue(new Error('删除失败'));
|
||||
|
||||
const result = await service.deleteFiles(['desktop://file1.txt']);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
#### Mock 策略的优势
|
||||
|
||||
1. **环境简化**: 避免了复杂的 Electron 环境搭建
|
||||
2. **测试可控**: 可以精确控制 IPC 调用的返回值和行为
|
||||
3. **场景覆盖**: 容易测试各种成功/失败场景
|
||||
4. **执行速度**: Mock 调用比真实 IPC 调用更快
|
||||
|
||||
#### 注意事项
|
||||
|
||||
- **Mock 准确性**: 确保 Mock 的行为与真实 IPC 接口行为一致
|
||||
- **类型安全**: 使用 `vi.mocked()` 确保类型安全
|
||||
- **Mock 重置**: 在 `beforeEach` 中重置 Mock 状态,避免测试间干扰
|
||||
- **调用验证**: 不仅要验证返回值,还要验证 IPC 方法是否被正确调用
|
||||
@@ -0,0 +1,401 @@
|
||||
---
|
||||
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)
|
||||
|
||||
## 🚀 测试运行命令
|
||||
|
||||
### 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"
|
||||
}
|
||||
```
|
||||
|
||||
### 推荐的测试运行方式
|
||||
|
||||
#### ⚠️ 重要提醒
|
||||
|
||||
**🚨 性能警告**:
|
||||
|
||||
- **永远不要直接运行整个项目的所有测试用例** - 项目包含 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 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
|
||||
# ❌ 这些命令会运行所有 3000+ 测试用例,耗时约 10 分钟!
|
||||
npm test
|
||||
npm run test
|
||||
pnpm test
|
||||
pnpm run test
|
||||
|
||||
# ❌ 这些命令看似针对单个文件,但实际会运行所有测试用例!, 需要直接运行 vitest 命令不要使用 test npm script
|
||||
npm test src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts
|
||||
pnpm test src/components/Button/index.test.tsx
|
||||
|
||||
# ❌ 不要使用 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` 了解执行流程
|
||||
|
||||
### 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`,测试仍能有效验证功能正确性
|
||||
|
||||
### 检查最近修改记录 🔍
|
||||
|
||||
为了更好地判断测试失败的根本原因,需要**系统性地检查相关文件的修改历史**。这是问题定位的关键步骤。
|
||||
|
||||
#### 第一步:确定需要检查的文件范围
|
||||
|
||||
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 path/to/component.ts < recent-commit-hash > -- | 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
|
||||
```
|
||||
|
||||
## 特殊场景的测试
|
||||
|
||||
针对一些特殊场景的测试,需要阅读相关文件:
|
||||
|
||||
- [Electron IPC 接口测试策略](mdc:./electron-ipc-test.mdc)
|
||||
- [数据库 Model 测试指南](mdc:./db-model-test.mdc)
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
修复测试时,记住以下关键点:
|
||||
|
||||
- **使用正确的命令**: `npx vitest run --config [config-file]`
|
||||
- **理解测试意图**: 先读懂测试再修复
|
||||
- **查看最近修改**: 检查相关文件的 git 修改记录,判断问题根源
|
||||
- **选择正确环境**: 客户端测试用 `vitest.config.ts`,服务端用 `vitest.config.server.ts`
|
||||
- **专注单一问题**: 只修复当前的测试失败
|
||||
- **验证修复结果**: 确保修复后测试通过且无副作用
|
||||
- **提供修复总结**: 说明错误原因和修复方法
|
||||
- **Model 测试安全第一**: 必须包含用户权限检查和对应的安全测试
|
||||
- **Model 双环境验证**: 必须在 PGLite 和 PostgreSQL 两个环境下都验证通过
|
||||
+1
-1
@@ -326,7 +326,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dbdocs": "^0.14.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"dpdm-fast": "^1.0.7",
|
||||
"dpdm-fast": "1.0.7",
|
||||
"drizzle-dbml-generator": "^0.10.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"eslint": "^8.57.1",
|
||||
|
||||
@@ -835,11 +835,27 @@ export const openaiSTTModels: AISTTModelCard[] = [
|
||||
|
||||
// 图像生成模型
|
||||
export const openaiImageModels: AIImageModelCard[] = [
|
||||
// https://platform.openai.com/docs/models/gpt-image-1
|
||||
{
|
||||
description: 'ChatGPT 原生多模态图片生成模型',
|
||||
displayName: 'GPT Image 1',
|
||||
enabled: true,
|
||||
id: 'gpt-image-1',
|
||||
parameters: gptImage1ParamsSchema,
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'最新的 DALL·E 模型,于2023年11月发布。支持更真实、准确的图像生成,具有更强的细节表现力',
|
||||
displayName: 'DALL·E 3',
|
||||
id: 'dall-e-3',
|
||||
parameters: {
|
||||
prompt: { default: '' },
|
||||
size: {
|
||||
default: '1024x1024',
|
||||
enum: ['1024x1024', '1792x1024', '1024x1792'],
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
hd: 0.08,
|
||||
standard: 0.04,
|
||||
@@ -851,21 +867,20 @@ export const openaiImageModels: AIImageModelCard[] = [
|
||||
description: '第二代 DALL·E 模型,支持更真实、准确的图像生成,分辨率是第一代的4倍',
|
||||
displayName: 'DALL·E 2',
|
||||
id: 'dall-e-2',
|
||||
parameters: {
|
||||
imageUrl: { default: null },
|
||||
prompt: { default: '' },
|
||||
size: {
|
||||
default: '1024x1024',
|
||||
enum: ['256x256', '512x512', '1024x1024'],
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
input: 0.02, // $0.020 per image (1024×1024)
|
||||
},
|
||||
resolutions: ['256x256', '512x512', '1024x1024'],
|
||||
type: 'image',
|
||||
},
|
||||
// https://platform.openai.com/docs/models/gpt-image-1
|
||||
{
|
||||
description: 'ChatGPT 原生多模态图片生成模型',
|
||||
displayName: 'GPT Image 1',
|
||||
enabled: true,
|
||||
id: 'gpt-image-1',
|
||||
parameters: gptImage1ParamsSchema,
|
||||
type: 'image',
|
||||
},
|
||||
];
|
||||
|
||||
// GPT-4o 和 GPT-4o-mini 实时模型
|
||||
|
||||
@@ -43,6 +43,7 @@ export abstract class LobeOpenAICompatibleRuntime {
|
||||
abstract client: OpenAI;
|
||||
|
||||
abstract chat(payload: ChatStreamPayload, options?: ChatMethodOptions): Promise<Response>;
|
||||
abstract createImage(payload: CreateImagePayload): Promise<CreateImageResponse>;
|
||||
|
||||
abstract models(): Promise<ChatModelCard[]>;
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ export const LobeHunyuanAI = createOpenAICompatibleRuntime({
|
||||
chatCompletion: {
|
||||
handlePayload: (payload) => {
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const { enabledSearch, frequency_penalty, model, presence_penalty, thinking, ...rest } = payload;
|
||||
const { enabledSearch, frequency_penalty, model, presence_penalty, thinking, ...rest } =
|
||||
payload;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
@@ -30,11 +31,8 @@ export const LobeHunyuanAI = createOpenAICompatibleRuntime({
|
||||
search_info: true,
|
||||
}),
|
||||
...(model === 'hunyuan-a13b' && {
|
||||
enable_thinking: thinking?.type === 'enabled'
|
||||
? true
|
||||
: thinking?.type === 'disabled'
|
||||
? false
|
||||
: undefined
|
||||
enable_thinking:
|
||||
thinking?.type === 'enabled' ? true : thinking?.type === 'disabled' ? false : undefined,
|
||||
}),
|
||||
} as any;
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ exports[`NovitaAI > models > should get models 1`] = `
|
||||
"id": "meta-llama/llama-3-8b-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -22,6 +23,7 @@ exports[`NovitaAI > models > should get models 1`] = `
|
||||
"id": "meta-llama/llama-3-70b-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -33,6 +35,7 @@ exports[`NovitaAI > models > should get models 1`] = `
|
||||
"id": "meta-llama/llama-3.1-8b-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -44,6 +47,7 @@ exports[`NovitaAI > models > should get models 1`] = `
|
||||
"id": "meta-llama/llama-3.1-70b-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -55,6 +59,7 @@ exports[`NovitaAI > models > should get models 1`] = `
|
||||
"id": "meta-llama/llama-3.1-405b-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -67,6 +72,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "google/gemma-2-9b-it",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -78,6 +84,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "jondurbin/airoboros-l2-70b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -89,6 +96,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "nousresearch/hermes-2-pro-llama-3-8b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -100,6 +108,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "mistralai/mistral-7b-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -111,6 +120,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "cognitivecomputations/dolphin-mixtral-8x22b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -122,6 +132,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "sao10k/l3-70b-euryale-v2.1",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": true,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -133,6 +144,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "sophosympatheia/midnight-rose-70b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -144,6 +156,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "gryphe/mythomax-l2-13b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -155,6 +168,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "nousresearch/nous-hermes-llama2-13b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -166,6 +180,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "Nous-Hermes-2-Mixtral-8x7B-DPO",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -177,6 +192,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "lzlv_70b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -188,6 +204,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "teknium/openhermes-2.5-mistral-7b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -199,6 +216,7 @@ Designed for a wide variety of tasks, it empowers developers and researchers to
|
||||
"id": "microsoft/wizardlm-2-8x22b",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "whisper-1",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "stt",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -20,6 +21,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "davinci-002",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -30,6 +32,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -40,6 +43,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "dall-e-2",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "image",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -50,6 +54,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-16k",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -60,6 +65,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "tts-1-hd-1106",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -70,6 +76,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "tts-1-hd",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "tts",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -80,6 +87,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-16k-0613",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -90,6 +98,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "text-embedding-3-large",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "embedding",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -100,6 +109,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4-1106-vision-preview",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -110,6 +120,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-instruct-0914",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -120,6 +131,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4-0125-preview",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -130,6 +142,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4-turbo-preview",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -140,6 +153,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-instruct",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -150,6 +164,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-0301",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -160,6 +175,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-0613",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -170,6 +186,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "tts-1",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "tts",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -180,6 +197,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "dall-e-3",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "image",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -190,6 +208,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-1106",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -200,6 +219,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4-1106-preview",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -210,6 +230,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "babbage-002",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -220,6 +241,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "tts-1-1106",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -230,6 +252,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4-vision-preview",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": true,
|
||||
},
|
||||
{
|
||||
@@ -240,6 +263,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "text-embedding-3-small",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "embedding",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -250,6 +274,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4",
|
||||
"maxOutput": 4096,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": true,
|
||||
},
|
||||
{
|
||||
@@ -260,6 +285,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "text-embedding-ada-002",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -270,6 +296,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-3.5-turbo-0125",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -280,6 +307,7 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
||||
"id": "gpt-4-0613",
|
||||
"maxOutput": undefined,
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// 引入模块以便于对函数进行spy
|
||||
import { ChatStreamCallbacks } from '@/libs/model-runtime';
|
||||
import * as openai from '@/libs/model-runtime/openai';
|
||||
|
||||
import * as debugStreamModule from '../utils/debugStream';
|
||||
import officalOpenAIModels from './fixtures/openai-models.json';
|
||||
@@ -13,22 +12,13 @@ import { LobeOpenAI } from './index';
|
||||
// Mock the console.error to avoid polluting test output
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock fetch for most tests, but will be restored for convertImageUrlToFile tests
|
||||
// Mock fetch for most tests, but will be restored for real network tests
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const convertImageUrlToFileSpy = vi.spyOn(openai, 'convertImageUrlToFile');
|
||||
|
||||
describe('LobeOpenAI', () => {
|
||||
let instance: InstanceType<typeof LobeOpenAI>;
|
||||
|
||||
// Create mock params for createImage tests - only gpt-image-1 supported params
|
||||
const mockParams = {
|
||||
prompt: 'test prompt',
|
||||
imageUrls: [] as string[],
|
||||
size: '1024x1024' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new LobeOpenAI({ apiKey: 'test' });
|
||||
|
||||
@@ -40,27 +30,6 @@ describe('LobeOpenAI', () => {
|
||||
|
||||
// Mock responses.create for responses API tests
|
||||
vi.spyOn(instance['client'].responses, 'create').mockResolvedValue(new ReadableStream() as any);
|
||||
|
||||
// Mock convertImageUrlToFile to return a mock File object
|
||||
convertImageUrlToFileSpy.mockResolvedValue({
|
||||
name: 'image.png',
|
||||
type: 'image/png',
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
// Mock fetch response for most tests
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === 'content-type') {
|
||||
return 'image/png';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -282,312 +251,6 @@ describe('LobeOpenAI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createImage', () => {
|
||||
it('should generate an image with gpt-image-1', async () => {
|
||||
// Arrange
|
||||
const mockResponse = { data: [{ b64_json: 'test-base64-string' }] };
|
||||
const generateSpy = vi
|
||||
.spyOn(instance['client'].images, 'generate')
|
||||
.mockResolvedValue(mockResponse as any);
|
||||
|
||||
// Act
|
||||
const result = await instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: {
|
||||
...mockParams,
|
||||
prompt: 'A cute cat',
|
||||
size: '1024x1024',
|
||||
},
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(generateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'A cute cat',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
}),
|
||||
);
|
||||
expect(result.imageUrl).toBe('data:image/png;base64,test-base64-string');
|
||||
});
|
||||
|
||||
it('should edit an image from a URL', async () => {
|
||||
// Arrange
|
||||
const mockResponse = { data: [{ b64_json: 'edited-base64-string' }] };
|
||||
const editSpy = vi
|
||||
.spyOn(instance['client'].images, 'edit')
|
||||
.mockResolvedValue(mockResponse as any);
|
||||
|
||||
// Temporarily restore the spy to use real implementation
|
||||
convertImageUrlToFileSpy.mockRestore();
|
||||
|
||||
const imageUrl = 'https://lobehub.com/_next/static/media/logo.98482105.png';
|
||||
|
||||
// Act
|
||||
const result = await instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: {
|
||||
...mockParams,
|
||||
prompt: 'A cat in a hat',
|
||||
imageUrls: [imageUrl],
|
||||
},
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(editSpy).toHaveBeenCalled();
|
||||
const callArg = editSpy.mock.calls[0][0];
|
||||
expect(callArg.model).toBe('gpt-image-1');
|
||||
expect(callArg.prompt).toBe('A cat in a hat');
|
||||
expect(result.imageUrl).toBe('data:image/png;base64,edited-base64-string');
|
||||
|
||||
// Restore the spy for other tests
|
||||
convertImageUrlToFileSpy.mockResolvedValue({
|
||||
name: 'image.png',
|
||||
type: 'image/png',
|
||||
size: 1024,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should handle `size` set to `auto`', async () => {
|
||||
// Arrange
|
||||
const mockResponse = { data: [{ b64_json: 'test-base64-string' }] };
|
||||
const generateSpy = vi
|
||||
.spyOn(instance['client'].images, 'generate')
|
||||
.mockResolvedValue(mockResponse as any);
|
||||
|
||||
// Act
|
||||
await instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: {
|
||||
...mockParams,
|
||||
prompt: 'A cute cat',
|
||||
size: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(generateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'A cute cat',
|
||||
n: 1,
|
||||
}),
|
||||
);
|
||||
// Should not include size when it's 'auto'
|
||||
expect(generateSpy.mock.calls[0][0]).not.toHaveProperty('size');
|
||||
});
|
||||
|
||||
it('should throw an error if convertImageUrlToFile fails', async () => {
|
||||
// Arrange
|
||||
const imageUrl = 'https://example.com/test-image.png';
|
||||
|
||||
// Mock fetch to fail for the image URL, which will cause convertImageUrlToFile to fail
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
// Mock the OpenAI API methods to ensure they don't get called
|
||||
const generateSpy = vi.spyOn(instance['client'].images, 'generate');
|
||||
const editSpy = vi.spyOn(instance['client'].images, 'edit');
|
||||
|
||||
// Act & Assert - Note: imageUrls must be non-empty array to trigger isImageEdit = true
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: {
|
||||
prompt: 'A cat in a hat',
|
||||
imageUrls: [imageUrl], // This is the key - non-empty array
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Failed to convert image URLs to File objects: Error: Network error');
|
||||
|
||||
// Verify that OpenAI API methods were not called since conversion failed
|
||||
expect(generateSpy).not.toHaveBeenCalled();
|
||||
expect(editSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error when image response is missing data array', async () => {
|
||||
// Arrange
|
||||
const mockInvalidResponse = {}; // missing data property
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: { ...mockParams, prompt: 'A cute cat' },
|
||||
}),
|
||||
).rejects.toThrow('Invalid image response: missing or empty data array');
|
||||
});
|
||||
|
||||
it('should throw an error when image response data array is empty', async () => {
|
||||
// Arrange
|
||||
const mockInvalidResponse = { data: [] }; // empty data array
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: { ...mockParams, prompt: 'A cute cat' },
|
||||
}),
|
||||
).rejects.toThrow('Invalid image response: missing or empty data array');
|
||||
});
|
||||
|
||||
it('should throw an error when first data item is null', async () => {
|
||||
// Arrange
|
||||
const mockInvalidResponse = { data: [null] }; // first item is null
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: { ...mockParams, prompt: 'A cute cat' },
|
||||
}),
|
||||
).rejects.toThrow('Invalid image response: first data item is null or undefined');
|
||||
});
|
||||
|
||||
it('should throw an error when first data item is undefined', async () => {
|
||||
// Arrange
|
||||
const mockInvalidResponse = { data: [undefined] }; // first item is undefined
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: { ...mockParams, prompt: 'A cute cat' },
|
||||
}),
|
||||
).rejects.toThrow('Invalid image response: first data item is null or undefined');
|
||||
});
|
||||
|
||||
it('should re-throw OpenAI API errors during image generation', async () => {
|
||||
// Arrange
|
||||
const apiError = new OpenAI.APIError(
|
||||
400,
|
||||
{ error: { message: 'Bad Request' } },
|
||||
'Error message',
|
||||
{},
|
||||
);
|
||||
vi.spyOn(instance['client'].images, 'generate').mockRejectedValue(apiError);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: { ...mockParams, prompt: 'A cute cat' },
|
||||
}),
|
||||
).rejects.toThrow(apiError);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid image response', async () => {
|
||||
// Arrange
|
||||
const mockInvalidResponse = { data: [{ url: 'some_url' }] }; // missing b64_json
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockInvalidResponse as any);
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
instance.createImage({
|
||||
model: 'gpt-image-1',
|
||||
params: { ...mockParams, prompt: 'A cute cat' },
|
||||
}),
|
||||
).rejects.toThrow('Invalid image response: missing b64_json field');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertImageUrlToFile', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the spy to use the real implementation for these tests
|
||||
convertImageUrlToFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the spy for other tests
|
||||
convertImageUrlToFileSpy.mockResolvedValue({
|
||||
name: 'image.png',
|
||||
type: 'image/png',
|
||||
size: 1024,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should convert the real lobehub logo URL to a FileLike object', async () => {
|
||||
const imageUrl = 'https://lobehub.com/_next/static/media/logo.98482105.png';
|
||||
const file = await openai.convertImageUrlToFile(imageUrl);
|
||||
|
||||
expect(file).toBeDefined();
|
||||
expect((file as any).name).toBe('image.png');
|
||||
expect((file as any).type).toMatch(/^image\//);
|
||||
expect((file as any).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should convert a base64 data URL to a FileLike object', async () => {
|
||||
const dataUrl =
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z';
|
||||
const file = await openai.convertImageUrlToFile(dataUrl);
|
||||
|
||||
expect(file).toBeDefined();
|
||||
expect((file as any).name).toBe('image.jpeg');
|
||||
expect((file as any).type).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle different image mime types from data URL', async () => {
|
||||
const webpDataUrl = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==';
|
||||
const file = await openai.convertImageUrlToFile(webpDataUrl);
|
||||
|
||||
expect(file).toBeDefined();
|
||||
expect((file as any).name).toBe('image.webp');
|
||||
expect((file as any).type).toBe('image/webp');
|
||||
});
|
||||
});
|
||||
|
||||
// Separate describe block for mocked fetch scenarios
|
||||
describe('convertImageUrlToFile - mocked scenarios', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the spy to use the real implementation
|
||||
convertImageUrlToFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the spy for other tests
|
||||
convertImageUrlToFileSpy.mockResolvedValue({
|
||||
name: 'image.png',
|
||||
type: 'image/png',
|
||||
size: 1024,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should throw an error if fetching an image from a URL fails', async () => {
|
||||
// Use vi.mocked for type-safe mocking
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found',
|
||||
} as any);
|
||||
|
||||
const imageUrl = 'https://example.com/invalid-image.png';
|
||||
|
||||
await expect(openai.convertImageUrlToFile(imageUrl)).rejects.toThrow(
|
||||
'Failed to fetch image from https://example.com/invalid-image.png: Not Found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use a default mime type of image/png if content-type header is not available', async () => {
|
||||
// Use vi.mocked for type-safe mocking
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
const imageUrl = 'https://example.com/image-no-content-type';
|
||||
const file = await openai.convertImageUrlToFile(imageUrl);
|
||||
|
||||
expect(file).toBeDefined();
|
||||
expect((file as any).type).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('responses.handlePayload', () => {
|
||||
it('should add web_search_preview tool when enabledSearch is true', async () => {
|
||||
const payload = {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import debug from 'debug';
|
||||
import { toFile } from 'openai';
|
||||
import { FileLike } from 'openai/uploads';
|
||||
|
||||
import { responsesAPIModels } from '@/const/models';
|
||||
import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/meta-schema';
|
||||
|
||||
import { ChatStreamPayload, ModelProvider } from '../types';
|
||||
import { processMultiProviderModelList } from '../utils/modelParse';
|
||||
@@ -15,45 +10,8 @@ export interface OpenAIModelCard {
|
||||
}
|
||||
|
||||
const prunePrefixes = ['o1', 'o3', 'o4', 'codex', 'computer-use'];
|
||||
|
||||
const oaiSearchContextSize = process.env.OPENAI_SEARCH_CONTEXT_SIZE; // low, medium, high
|
||||
|
||||
const log = debug('lobe-image:openai');
|
||||
|
||||
/**
|
||||
* 将图片 URL 转换为 File 对象
|
||||
* @param imageUrl - 图片 URL(可以是 HTTP URL 或 base64 data URL)
|
||||
* @returns FileLike 对象
|
||||
*/
|
||||
export const convertImageUrlToFile = async (imageUrl: string): Promise<FileLike> => {
|
||||
log('Converting image URL to File: %s', imageUrl.startsWith('data:') ? 'base64 data' : imageUrl);
|
||||
|
||||
let buffer: Buffer;
|
||||
let mimeType: string;
|
||||
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// 处理 base64 data URL
|
||||
log('Processing base64 image data');
|
||||
const [mimeTypePart, base64Data] = imageUrl.split(',');
|
||||
mimeType = mimeTypePart.split(':')[1].split(';')[0];
|
||||
buffer = Buffer.from(base64Data, 'base64');
|
||||
} else {
|
||||
// 处理 HTTP URL
|
||||
log('Fetching image from URL: %s', imageUrl);
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image from ${imageUrl}: ${response.statusText}`);
|
||||
}
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
mimeType = response.headers.get('content-type') || 'image/png';
|
||||
}
|
||||
|
||||
log('Successfully converted image to buffer, size: %s, mimeType: %s', buffer.length, mimeType);
|
||||
|
||||
// 使用 OpenAI 的 toFile 方法创建 File 对象
|
||||
return toFile(buffer, `image.${mimeType.split('/')[1]}`, { type: mimeType });
|
||||
};
|
||||
|
||||
export const LobeOpenAI = createOpenAICompatibleRuntime({
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
chatCompletion: {
|
||||
@@ -88,91 +46,6 @@ export const LobeOpenAI = createOpenAICompatibleRuntime({
|
||||
return { ...rest, model, stream: payload.stream ?? true };
|
||||
},
|
||||
},
|
||||
createImage: async (payload) => {
|
||||
const { model, params, client } = payload;
|
||||
log('Creating image with model: %s and params: %O', model, params);
|
||||
|
||||
const defaultInput = {
|
||||
n: 1,
|
||||
};
|
||||
|
||||
// 映射参数名称,将 imageUrls 映射为 image
|
||||
const paramsMap = new Map<RuntimeImageGenParamsValue, string>([['imageUrls', 'image']]);
|
||||
const userInput: Record<string, any> = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [
|
||||
paramsMap.get(key as RuntimeImageGenParamsValue) ?? key,
|
||||
value,
|
||||
]),
|
||||
);
|
||||
|
||||
const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
|
||||
// 如果有 imageUrls 参数,将其转换为 File 对象
|
||||
if (isImageEdit) {
|
||||
log('Converting imageUrls to File objects: %O', userInput.image);
|
||||
try {
|
||||
// 转换所有图片 URL 为 File 对象
|
||||
const imageFiles = await Promise.all(
|
||||
userInput.image.map((url: string) => convertImageUrlToFile(url)),
|
||||
);
|
||||
|
||||
log('Successfully converted %d images to File objects', imageFiles.length);
|
||||
|
||||
// 根据官方文档,如果有多个图片,传递数组;如果只有一个,传递单个 File
|
||||
userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
|
||||
} catch (error) {
|
||||
log('Error converting imageUrls to File objects: %O', error);
|
||||
throw new Error(`Failed to convert image URLs to File objects: ${error}`);
|
||||
}
|
||||
} else {
|
||||
delete userInput.image;
|
||||
}
|
||||
|
||||
if (userInput.size === 'auto') {
|
||||
delete userInput.size;
|
||||
}
|
||||
|
||||
const options = {
|
||||
model,
|
||||
...defaultInput,
|
||||
...(userInput as any),
|
||||
};
|
||||
|
||||
log('options: %O', options);
|
||||
|
||||
// 判断是否为图片编辑操作
|
||||
const img = isImageEdit
|
||||
? await client.images.edit(options)
|
||||
: await client.images.generate(options);
|
||||
|
||||
// 检查响应数据的完整性
|
||||
if (!img || !img.data || !Array.isArray(img.data) || img.data.length === 0) {
|
||||
log('Invalid image response: missing data array');
|
||||
throw new Error('Invalid image response: missing or empty data array');
|
||||
}
|
||||
|
||||
const imageData = img.data[0];
|
||||
if (!imageData) {
|
||||
log('Invalid image response: first data item is null/undefined');
|
||||
throw new Error('Invalid image response: first data item is null or undefined');
|
||||
}
|
||||
|
||||
if (!imageData.b64_json) {
|
||||
log('Invalid image response: missing b64_json field');
|
||||
throw new Error('Invalid image response: missing b64_json field');
|
||||
}
|
||||
|
||||
// 确定图片的 MIME 类型,默认为 PNG
|
||||
const mimeType = 'image/png'; // OpenAI 图片生成默认返回 PNG 格式
|
||||
|
||||
// 将 base64 字符串转换为完整的 data URL
|
||||
const dataUrl = `data:${mimeType};base64,${imageData.b64_json}`;
|
||||
|
||||
log('Successfully converted base64 to data URL, length: %d', dataUrl.length);
|
||||
|
||||
return {
|
||||
imageUrl: dataUrl,
|
||||
};
|
||||
},
|
||||
debug: {
|
||||
chatCompletion: () => process.env.DEBUG_OPENAI_CHAT_COMPLETION === '1',
|
||||
responses: () => process.env.DEBUG_OPENAI_RESPONSES === '1',
|
||||
|
||||
@@ -21,6 +21,7 @@ _These are free, rate-limited endpoints for [Reflection 70B](/models/mattshumer/
|
||||
},
|
||||
"reasoning": true,
|
||||
"releasedAt": "2024-09-06",
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
]
|
||||
@@ -47,6 +48,7 @@ _These are free, rate-limited endpoints for [Reflection 70B](/models/mattshumer/
|
||||
},
|
||||
"reasoning": false,
|
||||
"releasedAt": "2024-09-06",
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
]
|
||||
@@ -73,6 +75,7 @@ _These are free, rate-limited endpoints for [Reflection 70B](/models/mattshumer/
|
||||
},
|
||||
"reasoning": false,
|
||||
"releasedAt": "2024-09-06",
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ exports[`PPIO > models > should get models 1`] = `
|
||||
"functionCall": false,
|
||||
"id": "deepseek/deepseek-r1/community",
|
||||
"reasoning": true,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
{
|
||||
@@ -20,6 +21,7 @@ exports[`PPIO > models > should get models 1`] = `
|
||||
"functionCall": false,
|
||||
"id": "deepseek/deepseek-v3/community",
|
||||
"reasoning": false,
|
||||
"type": "chat",
|
||||
"vision": false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -136,6 +136,7 @@ const processModelCard = (
|
||||
reasoningKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)) ||
|
||||
knownModel?.abilities?.reasoning ||
|
||||
false,
|
||||
type: model.type || knownModel?.type || 'chat',
|
||||
vision:
|
||||
(visionKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)) &&
|
||||
!isExcludedModel) ||
|
||||
|
||||
@@ -14,6 +14,7 @@ import officalOpenAIModels from '@/libs/model-runtime/openai/fixtures/openai-mod
|
||||
import { sleep } from '@/utils/sleep';
|
||||
|
||||
import * as debugStreamModule from '../debugStream';
|
||||
import * as openaiHelpers from '../openaiHelpers';
|
||||
import { createOpenAICompatibleRuntime } from './index';
|
||||
|
||||
const provider = 'groq';
|
||||
@@ -978,6 +979,329 @@ describe('LobeOpenAICompatibleFactory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createImage', () => {
|
||||
beforeEach(() => {
|
||||
// Mock convertImageUrlToFile since it's already tested in openaiHelpers.test.ts
|
||||
vi.spyOn(openaiHelpers, 'convertImageUrlToFile').mockResolvedValue(
|
||||
new File(['mock-file-content'], 'test-image.jpg', { type: 'image/jpeg' }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('basic image generation', () => {
|
||||
it('should generate image successfully without imageUrls', async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
b64_json:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: {
|
||||
prompt: 'A beautiful sunset',
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await (instance as any).createImage(payload);
|
||||
|
||||
expect(instance['client'].images.generate).toHaveBeenCalledWith({
|
||||
model: 'dall-e-3',
|
||||
n: 1,
|
||||
prompt: 'A beautiful sunset',
|
||||
size: '1024x1024',
|
||||
quality: 'standard',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
imageUrl:
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle size auto parameter correctly', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ b64_json: 'mock-base64-data' }],
|
||||
};
|
||||
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: {
|
||||
prompt: 'A beautiful sunset',
|
||||
size: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
await (instance as any).createImage(payload);
|
||||
|
||||
// size: 'auto' should be removed from the options
|
||||
expect(instance['client'].images.generate).toHaveBeenCalledWith({
|
||||
model: 'dall-e-3',
|
||||
n: 1,
|
||||
prompt: 'A beautiful sunset',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add response_format parameter for gpt-image-1 model', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ b64_json: 'gpt-image-1-base64-data' }],
|
||||
};
|
||||
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'gpt-image-1',
|
||||
params: {
|
||||
prompt: 'A modern digital artwork',
|
||||
size: '1024x1024',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await (instance as any).createImage(payload);
|
||||
|
||||
// gpt-image-1 model should not include response_format parameter
|
||||
expect(instance['client'].images.generate).toHaveBeenCalledWith({
|
||||
model: 'gpt-image-1',
|
||||
n: 1,
|
||||
prompt: 'A modern digital artwork',
|
||||
size: '1024x1024',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
imageUrl: 'data:image/png;base64,gpt-image-1-base64-data',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('image editing', () => {
|
||||
it('should edit image with single imageUrl', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ b64_json: 'edited-image-base64' }],
|
||||
};
|
||||
|
||||
vi.spyOn(instance['client'].images, 'edit').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-2',
|
||||
params: {
|
||||
prompt: 'Add a rainbow to this image',
|
||||
imageUrls: ['https://example.com/image1.jpg'],
|
||||
mask: 'https://example.com/mask.jpg',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await (instance as any).createImage(payload);
|
||||
|
||||
expect(openaiHelpers.convertImageUrlToFile).toHaveBeenCalledWith(
|
||||
'https://example.com/image1.jpg',
|
||||
);
|
||||
expect(instance['client'].images.edit).toHaveBeenCalledWith({
|
||||
model: 'dall-e-2',
|
||||
n: 1,
|
||||
prompt: 'Add a rainbow to this image',
|
||||
image: expect.any(File),
|
||||
mask: 'https://example.com/mask.jpg',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
imageUrl: 'data:image/png;base64,edited-image-base64',
|
||||
});
|
||||
});
|
||||
|
||||
it('should edit image with multiple imageUrls', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ b64_json: 'edited-multiple-images-base64' }],
|
||||
};
|
||||
|
||||
const mockFile1 = new File(['content1'], 'image1.jpg', { type: 'image/jpeg' });
|
||||
const mockFile2 = new File(['content2'], 'image2.jpg', { type: 'image/jpeg' });
|
||||
|
||||
vi.mocked(openaiHelpers.convertImageUrlToFile)
|
||||
.mockResolvedValueOnce(mockFile1)
|
||||
.mockResolvedValueOnce(mockFile2);
|
||||
|
||||
vi.spyOn(instance['client'].images, 'edit').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-2',
|
||||
params: {
|
||||
prompt: 'Merge these images',
|
||||
imageUrls: ['https://example.com/image1.jpg', 'https://example.com/image2.jpg'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await (instance as any).createImage(payload);
|
||||
|
||||
expect(openaiHelpers.convertImageUrlToFile).toHaveBeenCalledTimes(2);
|
||||
expect(openaiHelpers.convertImageUrlToFile).toHaveBeenCalledWith(
|
||||
'https://example.com/image1.jpg',
|
||||
);
|
||||
expect(openaiHelpers.convertImageUrlToFile).toHaveBeenCalledWith(
|
||||
'https://example.com/image2.jpg',
|
||||
);
|
||||
|
||||
expect(instance['client'].images.edit).toHaveBeenCalledWith({
|
||||
model: 'dall-e-2',
|
||||
n: 1,
|
||||
prompt: 'Merge these images',
|
||||
image: [mockFile1, mockFile2],
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
imageUrl: 'data:image/png;base64,edited-multiple-images-base64',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle convertImageUrlToFile error', async () => {
|
||||
vi.mocked(openaiHelpers.convertImageUrlToFile).mockRejectedValue(
|
||||
new Error('Failed to download image'),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-2',
|
||||
params: {
|
||||
prompt: 'Edit this image',
|
||||
imageUrls: ['https://invalid-url.com/image.jpg'],
|
||||
},
|
||||
};
|
||||
|
||||
await expect((instance as any).createImage(payload)).rejects.toThrow(
|
||||
'Failed to convert image URLs to File objects: Error: Failed to download image',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error when API response is invalid - no data', async () => {
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({} as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: { prompt: 'Test prompt' },
|
||||
};
|
||||
|
||||
await expect((instance as any).createImage(payload)).rejects.toThrow(
|
||||
'Invalid image response: missing or empty data array',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when API response is invalid - empty data array', async () => {
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({
|
||||
data: [],
|
||||
} as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: { prompt: 'Test prompt' },
|
||||
};
|
||||
|
||||
await expect((instance as any).createImage(payload)).rejects.toThrow(
|
||||
'Invalid image response: missing or empty data array',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when first data item is null', async () => {
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({
|
||||
data: [null],
|
||||
} as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: { prompt: 'Test prompt' },
|
||||
};
|
||||
|
||||
await expect((instance as any).createImage(payload)).rejects.toThrow(
|
||||
'Invalid image response: first data item is null or undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when b64_json is missing', async () => {
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({
|
||||
data: [{ url: 'https://example.com/image.jpg' }],
|
||||
} as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: { prompt: 'Test prompt' },
|
||||
};
|
||||
|
||||
await expect((instance as any).createImage(payload)).rejects.toThrow(
|
||||
'Invalid image response: missing b64_json field',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter mapping', () => {
|
||||
it('should map imageUrls parameter to image', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ b64_json: 'test-base64' }],
|
||||
};
|
||||
|
||||
vi.spyOn(instance['client'].images, 'edit').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-2',
|
||||
params: {
|
||||
prompt: 'Test prompt',
|
||||
imageUrls: ['https://example.com/image.jpg'],
|
||||
customParam: 'should remain unchanged',
|
||||
},
|
||||
};
|
||||
|
||||
await (instance as any).createImage(payload);
|
||||
|
||||
expect(instance['client'].images.edit).toHaveBeenCalledWith({
|
||||
model: 'dall-e-2',
|
||||
n: 1,
|
||||
prompt: 'Test prompt',
|
||||
image: expect.any(File),
|
||||
customParam: 'should remain unchanged',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle parameters without imageUrls', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ b64_json: 'test-base64' }],
|
||||
};
|
||||
|
||||
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(mockResponse as any);
|
||||
|
||||
const payload = {
|
||||
model: 'dall-e-3',
|
||||
params: {
|
||||
prompt: 'Test prompt',
|
||||
quality: 'hd',
|
||||
style: 'vivid',
|
||||
},
|
||||
};
|
||||
|
||||
await (instance as any).createImage(payload);
|
||||
|
||||
expect(instance['client'].images.generate).toHaveBeenCalledWith({
|
||||
model: 'dall-e-3',
|
||||
n: 1,
|
||||
prompt: 'Test prompt',
|
||||
quality: 'hd',
|
||||
style: 'vivid',
|
||||
response_format: 'b64_json',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('models', () => {
|
||||
it('should get models with third party model list', async () => {
|
||||
vi.spyOn(instance['client'].models, 'list').mockResolvedValue({
|
||||
@@ -993,54 +1317,82 @@ describe('LobeOpenAICompatibleFactory', () => {
|
||||
|
||||
expect(list).toEqual([
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
vision: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'gpt-4o',
|
||||
},
|
||||
contextWindowTokens: 128000,
|
||||
releasedAt: '2023-10-25',
|
||||
description:
|
||||
'ChatGPT-4o 是一款动态模型,实时更新以保持当前最新版本。它结合了强大的语言理解与生成能力,适合于大规模应用场景,包括客户服务、教育和技术支持。',
|
||||
displayName: 'GPT-4o',
|
||||
enabled: true,
|
||||
functionCall: true,
|
||||
id: 'gpt-4o',
|
||||
maxOutput: 4096,
|
||||
pricing: {
|
||||
cachedInput: 1.25,
|
||||
input: 2.5,
|
||||
output: 10,
|
||||
},
|
||||
vision: true,
|
||||
providerId: 'azure',
|
||||
releasedAt: '2024-05-13',
|
||||
source: 'builtin',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 200000,
|
||||
description:
|
||||
'Claude 3 Haiku 是 Anthropic 的最快且最紧凑的模型,旨在实现近乎即时的响应。它具有快速且准确的定向性能。',
|
||||
displayName: 'Claude 3 Haiku',
|
||||
functionCall: true,
|
||||
enabled: false,
|
||||
id: 'claude-3-haiku-20240307',
|
||||
maxOutput: 4096,
|
||||
pricing: {
|
||||
input: 0.25,
|
||||
output: 1.25,
|
||||
},
|
||||
providerId: 'anthropic',
|
||||
releasedAt: '2024-03-07',
|
||||
vision: true,
|
||||
settings: {
|
||||
extendParams: ['disableContextCaching'],
|
||||
},
|
||||
source: 'builtin',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
vision: true,
|
||||
},
|
||||
config: {
|
||||
deploymentName: 'gpt-4o-mini',
|
||||
},
|
||||
contextWindowTokens: 128000,
|
||||
description:
|
||||
'GPT-4o mini是OpenAI在GPT-4 Omni之后推出的最新模型,支持图文输入并输出文本。作为他们最先进的小型模型,它比其他近期的前沿模型便宜很多,并且比GPT-3.5 Turbo便宜超过60%。它保持了最先进的智能,同时具有显著的性价比。GPT-4o mini在MMLU测试中获得了 82% 的得分,目前在聊天偏好上排名高于 GPT-4。',
|
||||
displayName: 'GPT-4o mini',
|
||||
enabled: true,
|
||||
functionCall: true,
|
||||
description: 'GPT-4o Mini,小型高效模型,具备与GPT-4o相似的卓越性能。',
|
||||
displayName: 'GPT 4o Mini',
|
||||
enabled: false,
|
||||
id: 'gpt-4o-mini',
|
||||
maxOutput: 16385,
|
||||
maxOutput: 4096,
|
||||
pricing: {
|
||||
cachedInput: 0.075,
|
||||
input: 0.15,
|
||||
output: 0.6,
|
||||
},
|
||||
providerId: 'azure',
|
||||
releasedAt: '2023-10-26',
|
||||
vision: true,
|
||||
source: 'builtin',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
releasedAt: '2025-01-10',
|
||||
type: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import createDebug from 'debug';
|
||||
import OpenAI, { ClientOptions } from 'openai';
|
||||
import { Stream } from 'openai/streaming';
|
||||
|
||||
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/modelProviders';
|
||||
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
|
||||
import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/meta-schema';
|
||||
import type { ChatModelCard } from '@/types/llm';
|
||||
|
||||
import { LobeRuntimeAI } from '../../BaseAI';
|
||||
@@ -27,7 +29,11 @@ import { AgentRuntimeError } from '../createError';
|
||||
import { debugResponse, debugStream } from '../debugStream';
|
||||
import { desensitizeUrl } from '../desensitizeUrl';
|
||||
import { handleOpenAIError } from '../handleOpenAIError';
|
||||
import { convertOpenAIMessages, convertOpenAIResponseInputs } from '../openaiHelpers';
|
||||
import {
|
||||
convertImageUrlToFile,
|
||||
convertOpenAIMessages,
|
||||
convertOpenAIResponseInputs,
|
||||
} from '../openaiHelpers';
|
||||
import { StreamingResponse } from '../response';
|
||||
import { OpenAIResponsesStream, OpenAIStream, OpenAIStreamOptions } from '../streams';
|
||||
|
||||
@@ -168,7 +174,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
||||
debug,
|
||||
constructorOptions,
|
||||
chatCompletion,
|
||||
createImage,
|
||||
models,
|
||||
customClient,
|
||||
responses,
|
||||
@@ -311,58 +316,155 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
||||
}
|
||||
|
||||
async createImage(payload: CreateImagePayload) {
|
||||
return createImage!({
|
||||
...payload,
|
||||
client: this.client,
|
||||
});
|
||||
const { model, params } = payload;
|
||||
const log = createDebug(`lobe-image:model-runtime`);
|
||||
|
||||
log('Creating image with model: %s and params: %O', model, params);
|
||||
|
||||
const defaultInput = {
|
||||
n: 1,
|
||||
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
|
||||
};
|
||||
|
||||
// 映射参数名称,将 imageUrls 映射为 image
|
||||
const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
|
||||
['imageUrls', 'image'],
|
||||
['imageUrl', 'image'],
|
||||
]);
|
||||
const userInput: Record<string, any> = Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [
|
||||
paramsMap.get(key as RuntimeImageGenParamsValue) ?? key,
|
||||
value,
|
||||
]),
|
||||
);
|
||||
|
||||
const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
|
||||
// 如果有 imageUrls 参数,将其转换为 File 对象
|
||||
if (isImageEdit) {
|
||||
log('Converting imageUrls to File objects: %O', userInput.image);
|
||||
try {
|
||||
// 转换所有图片 URL 为 File 对象
|
||||
const imageFiles = await Promise.all(
|
||||
userInput.image.map((url: string) => convertImageUrlToFile(url)),
|
||||
);
|
||||
|
||||
log('Successfully converted %d images to File objects', imageFiles.length);
|
||||
|
||||
// 根据官方文档,如果有多个图片,传递数组;如果只有一个,传递单个 File
|
||||
userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
|
||||
} catch (error) {
|
||||
log('Error converting imageUrls to File objects: %O', error);
|
||||
throw new Error(`Failed to convert image URLs to File objects: ${error}`);
|
||||
}
|
||||
} else {
|
||||
delete userInput.image;
|
||||
}
|
||||
|
||||
if (userInput.size === 'auto') {
|
||||
delete userInput.size;
|
||||
}
|
||||
|
||||
const options = {
|
||||
model,
|
||||
...defaultInput,
|
||||
...userInput,
|
||||
};
|
||||
|
||||
log('options: %O', options);
|
||||
|
||||
// 判断是否为图片编辑操作
|
||||
const img = isImageEdit
|
||||
? await this.client.images.edit(options as any)
|
||||
: await this.client.images.generate(options as any);
|
||||
|
||||
// 检查响应数据的完整性
|
||||
if (!img || !img.data || !Array.isArray(img.data) || img.data.length === 0) {
|
||||
log('Invalid image response: missing data array');
|
||||
throw new Error('Invalid image response: missing or empty data array');
|
||||
}
|
||||
|
||||
const imageData = img.data[0];
|
||||
if (!imageData) {
|
||||
log('Invalid image response: first data item is null/undefined');
|
||||
throw new Error('Invalid image response: first data item is null or undefined');
|
||||
}
|
||||
|
||||
if (!imageData.b64_json) {
|
||||
log('Invalid image response: missing b64_json field');
|
||||
throw new Error('Invalid image response: missing b64_json field');
|
||||
}
|
||||
|
||||
// 确定图片的 MIME 类型,默认为 PNG
|
||||
const mimeType = 'image/png'; // OpenAI 图片生成默认返回 PNG 格式
|
||||
|
||||
// 将 base64 字符串转换为完整的 data URL
|
||||
const dataUrl = `data:${mimeType};base64,${imageData.b64_json}`;
|
||||
|
||||
log('Successfully converted base64 to data URL, length: %d', dataUrl.length);
|
||||
|
||||
return {
|
||||
imageUrl: dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async models() {
|
||||
if (typeof models === 'function') return models({ client: this.client });
|
||||
|
||||
const list = await this.client.models.list();
|
||||
|
||||
return list.data
|
||||
.filter((model) => {
|
||||
return CHAT_MODELS_BLOCK_LIST.every(
|
||||
(keyword) => !model.id.toLowerCase().includes(keyword),
|
||||
);
|
||||
})
|
||||
.map((item) => {
|
||||
if (models?.transformModel) {
|
||||
return models.transformModel(item);
|
||||
}
|
||||
|
||||
const toReleasedAt = () => {
|
||||
if (!item.created) return;
|
||||
dayjs.extend(utc);
|
||||
|
||||
// guarantee item.created in Date String format
|
||||
if (
|
||||
typeof (item.created as any) === 'string' ||
|
||||
// or in milliseconds
|
||||
item.created.toFixed(0).length === 13
|
||||
) {
|
||||
return dayjs.utc(item.created).format('YYYY-MM-DD');
|
||||
let resultModels: ChatModelCard[] = [];
|
||||
if (typeof models === 'function') {
|
||||
resultModels = await models({ client: this.client });
|
||||
} else {
|
||||
const list = await this.client.models.list();
|
||||
resultModels = list.data
|
||||
.filter((model) => {
|
||||
return CHAT_MODELS_BLOCK_LIST.every(
|
||||
(keyword) => !model.id.toLowerCase().includes(keyword),
|
||||
);
|
||||
})
|
||||
.map((item) => {
|
||||
if (models?.transformModel) {
|
||||
return models.transformModel(item);
|
||||
}
|
||||
|
||||
// by default, the created time is in seconds
|
||||
return dayjs.utc(item.created * 1000).format('YYYY-MM-DD');
|
||||
};
|
||||
const toReleasedAt = () => {
|
||||
if (!item.created) return;
|
||||
dayjs.extend(utc);
|
||||
|
||||
// TODO: should refactor after remove v1 user/modelList code
|
||||
const knownModel = LOBE_DEFAULT_MODEL_LIST.find((model) => model.id === item.id);
|
||||
// guarantee item.created in Date String format
|
||||
if (
|
||||
typeof (item.created as any) === 'string' ||
|
||||
// or in milliseconds
|
||||
item.created.toFixed(0).length === 13
|
||||
) {
|
||||
return dayjs.utc(item.created).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
if (knownModel) {
|
||||
const releasedAt = knownModel.releasedAt ?? toReleasedAt();
|
||||
// by default, the created time is in seconds
|
||||
return dayjs.utc(item.created * 1000).format('YYYY-MM-DD');
|
||||
};
|
||||
|
||||
return { ...knownModel, releasedAt };
|
||||
}
|
||||
// TODO: should refactor after remove v1 user/modelList code
|
||||
const knownModel = LOBE_DEFAULT_MODEL_LIST.find((model) => model.id === item.id);
|
||||
|
||||
return { id: item.id, releasedAt: toReleasedAt() };
|
||||
})
|
||||
if (knownModel) {
|
||||
const releasedAt = knownModel.releasedAt ?? toReleasedAt();
|
||||
|
||||
.filter(Boolean) as ChatModelCard[];
|
||||
return { ...knownModel, releasedAt };
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
releasedAt: toReleasedAt(),
|
||||
};
|
||||
})
|
||||
|
||||
.filter(Boolean) as ChatModelCard[];
|
||||
}
|
||||
|
||||
return resultModels.map((model) => {
|
||||
return {
|
||||
...model,
|
||||
type: model.type || LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.type,
|
||||
};
|
||||
}) as ChatModelCard[];
|
||||
}
|
||||
|
||||
async embeddings(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { imageUrlToBase64 } from '@/utils/imageToBase64';
|
||||
|
||||
import {
|
||||
convertImageUrlToFile,
|
||||
convertMessageContent,
|
||||
convertOpenAIMessages,
|
||||
convertOpenAIResponseInputs,
|
||||
@@ -288,3 +289,153 @@ describe('convertOpenAIResponseInputs', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertImageUrlToFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Data URL handling', () => {
|
||||
it('should convert PNG data URL to File object correctly', async () => {
|
||||
const base64Data =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
const dataUrl = `data:image/png;base64,${base64Data}`;
|
||||
|
||||
const result = await convertImageUrlToFile(dataUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('name', 'image.png');
|
||||
expect(result).toHaveProperty('type', 'image/png');
|
||||
expect(result).toHaveProperty('size');
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should convert JPEG data URL to File object correctly', async () => {
|
||||
const base64Data =
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA9BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
||||
const dataUrl = `data:image/jpeg;base64,${base64Data}`;
|
||||
|
||||
const result = await convertImageUrlToFile(dataUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('name', 'image.jpeg');
|
||||
expect(result).toHaveProperty('type', 'image/jpeg');
|
||||
expect(result).toHaveProperty('size');
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should convert WebP data URL to File object correctly', async () => {
|
||||
const base64Data = 'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAAAAJaQAA6g=';
|
||||
const dataUrl = `data:image/webp;base64,${base64Data}`;
|
||||
|
||||
const result = await convertImageUrlToFile(dataUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('name', 'image.webp');
|
||||
expect(result).toHaveProperty('type', 'image/webp');
|
||||
expect(result).toHaveProperty('size');
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP URL handling', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock global fetch using vi.stubGlobal for better isolation
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should convert HTTP URL to File object correctly', async () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(8);
|
||||
const mockHeaders = new Headers();
|
||||
mockHeaders.set('content-type', 'image/jpeg');
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
headers: mockHeaders,
|
||||
} satisfies Partial<Response>);
|
||||
|
||||
const result = await convertImageUrlToFile('https://example.com/image.jpg');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('name', 'image.jpeg');
|
||||
expect(result).toHaveProperty('type', 'image/jpeg');
|
||||
expect(result).toHaveProperty('size');
|
||||
expect(result.size).toEqual(8);
|
||||
});
|
||||
|
||||
it('should handle different content types from HTTP response headers', async () => {
|
||||
const testCases = [
|
||||
{ contentType: 'image/jpeg', expectedExtension: 'jpeg' },
|
||||
{ contentType: 'image/png', expectedExtension: 'png' },
|
||||
{ contentType: 'image/webp', expectedExtension: 'webp' },
|
||||
{ contentType: null, expectedExtension: 'png' }, // default fallback
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const mockArrayBuffer = new ArrayBuffer(8);
|
||||
const mockHeaders = new Headers();
|
||||
if (testCase.contentType) {
|
||||
mockHeaders.set('content-type', testCase.contentType);
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(mockArrayBuffer),
|
||||
headers: mockHeaders,
|
||||
} satisfies Partial<Response>);
|
||||
|
||||
const result = await convertImageUrlToFile('https://example.com/image.jpg');
|
||||
|
||||
expect(result).toHaveProperty('name', `image.${testCase.expectedExtension}`);
|
||||
expect(result).toHaveProperty('type', testCase.contentType || 'image/png');
|
||||
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when HTTP request fails', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: 'Not Found',
|
||||
} satisfies Partial<Response>);
|
||||
|
||||
await expect(convertImageUrlToFile('https://example.com/nonexistent.jpg')).rejects.toThrow(
|
||||
'Failed to fetch image from https://example.com/nonexistent.jpg: Not Found',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg');
|
||||
});
|
||||
|
||||
it('should throw error when network request fails', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(convertImageUrlToFile('https://example.com/image.jpg')).rejects.toThrow(
|
||||
'Network error',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle malformed data URL gracefully', async () => {
|
||||
const malformedDataUrl = 'data:invalid-format';
|
||||
|
||||
// 这个测试可能会抛出错误,我们需要适当处理
|
||||
await expect(convertImageUrlToFile(malformedDataUrl)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import OpenAI from 'openai';
|
||||
import OpenAI, { toFile } from 'openai';
|
||||
|
||||
import { disableStreamModels, systemToUserModels } from '@/const/models';
|
||||
import { ChatStreamPayload, OpenAIChatMessage } from '@/libs/model-runtime';
|
||||
@@ -119,3 +119,28 @@ export const pruneReasoningPayload = (payload: ChatStreamPayload) => {
|
||||
top_p: 1,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert image URL (data URL or HTTP URL) to File object for OpenAI API
|
||||
*/
|
||||
export const convertImageUrlToFile = async (imageUrl: string) => {
|
||||
let buffer: Buffer;
|
||||
let mimeType: string;
|
||||
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// a base64 image
|
||||
const [mimeTypePart, base64Data] = imageUrl.split(',');
|
||||
mimeType = mimeTypePart.split(':')[1].split(';')[0];
|
||||
buffer = Buffer.from(base64Data, 'base64');
|
||||
} else {
|
||||
// a http url
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image from ${imageUrl}: ${response.statusText}`);
|
||||
}
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
mimeType = response.headers.get('content-type') || 'image/png';
|
||||
}
|
||||
|
||||
return toFile(buffer, `image.${mimeType.split('/')[1]}`, { type: mimeType });
|
||||
};
|
||||
|
||||
@@ -7,10 +7,7 @@ export interface XAIModelCard {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const GrokReasoningModels = new Set([
|
||||
'grok-3-mini',
|
||||
'grok-4',
|
||||
]);
|
||||
export const GrokReasoningModels = new Set(['grok-3-mini', 'grok-4']);
|
||||
|
||||
export const isGrokReasoningModel = (model: string) =>
|
||||
Array.from(GrokReasoningModels).some((id) => model.includes(id));
|
||||
|
||||
@@ -84,7 +84,7 @@ export const createAiModelSlice: StateCreator<
|
||||
},
|
||||
enabled: model.enabled || false,
|
||||
source: 'remote',
|
||||
type: 'chat',
|
||||
type: model.type || 'chat',
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -203,6 +203,8 @@ export const createAiProviderSlice: StateCreator<
|
||||
onSuccess: async (data) => {
|
||||
if (!data) return;
|
||||
|
||||
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
||||
|
||||
const getModelListByType = (providerId: string, type: string) => {
|
||||
const models = data.enabledAiModels
|
||||
.filter((model) => model.providerId === providerId && model.type === type)
|
||||
@@ -212,7 +214,9 @@ export const createAiProviderSlice: StateCreator<
|
||||
displayName: model.displayName ?? '',
|
||||
id: model.id,
|
||||
...(model.type === 'image' && {
|
||||
parameters: (model as AIImageModelCard).parameters,
|
||||
parameters:
|
||||
(model as AIImageModelCard).parameters ||
|
||||
LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.parameters,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -231,7 +235,6 @@ export const createAiProviderSlice: StateCreator<
|
||||
children: getModelListByType(provider.id, 'image'),
|
||||
name: provider.name || provider.id,
|
||||
}));
|
||||
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
||||
|
||||
set(
|
||||
{
|
||||
|
||||
@@ -268,6 +268,7 @@ export interface AiFullModelCard extends AIBaseModelCard {
|
||||
displayName?: string;
|
||||
id: string;
|
||||
maxDimension?: number;
|
||||
parameters?: ModelParamsSchema;
|
||||
pricing?: ChatModelPricing;
|
||||
type: AiModelType;
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ChatModelPricing } from '@/types/aiModel';
|
||||
import { AiModelType, ChatModelPricing } from '@/types/aiModel';
|
||||
import { AiProviderSettings } from '@/types/aiProvider';
|
||||
|
||||
export type ModelPriceCurrency = 'CNY' | 'USD';
|
||||
@@ -53,6 +53,8 @@ export interface ChatModelCard {
|
||||
*/
|
||||
releasedAt?: string;
|
||||
|
||||
type?: AiModelType;
|
||||
|
||||
/**
|
||||
* whether model supports vision
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user