feat: add ComfyUI integration Phase1(RFC-128) (#9043)

Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
This commit is contained in:
Maple Gao 2025-10-21 08:34:57 +01:00 committed by GitHub
parent 2606f93146
commit 15ffe289f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 22066 additions and 32 deletions

View file

@ -165,6 +165,9 @@ ENV \
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
# Cohere
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
# ComfyUI
COMFYUI_BASE_URL="" COMFYUI_AUTH_TYPE="" \
COMFYUI_API_KEY="" COMFYUI_USERNAME="" COMFYUI_PASSWORD="" COMFYUI_CUSTOM_HEADERS="" \
# DeepSeek
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
# Fireworks AI

View file

@ -218,6 +218,9 @@ ENV \
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
# Cohere
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
# ComfyUI
COMFYUI_BASE_URL="" COMFYUI_AUTH_TYPE="" \
COMFYUI_API_KEY="" COMFYUI_USERNAME="" COMFYUI_PASSWORD="" COMFYUI_CUSTOM_HEADERS="" \
# DeepSeek
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
# Fireworks AI

View file

@ -167,6 +167,9 @@ ENV \
CLOUDFLARE_API_KEY="" CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID="" CLOUDFLARE_MODEL_LIST="" \
# Cohere
COHERE_API_KEY="" COHERE_MODEL_LIST="" COHERE_PROXY_URL="" \
# ComfyUI
COMFYUI_BASE_URL="" COMFYUI_AUTH_TYPE="" \
COMFYUI_API_KEY="" COMFYUI_USERNAME="" COMFYUI_PASSWORD="" COMFYUI_CUSTOM_HEADERS="" \
# DeepSeek
DEEPSEEK_API_KEY="" DEEPSEEK_MODEL_LIST="" \
# Fireworks AI

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,998 @@
---
title: ComfyUI 扩展开发指南
description: 学习如何为 LobeChat ComfyUI 集成添加新模型、工作流和功能扩展
tags:
- ComfyUI
- 开发指南
- 模型扩展
- 工作流开发
---
# ComfyUI 扩展开发指南
本指南基于实际代码实现,帮助开发者扩展 LobeChat 的 ComfyUI 集成功能。
## 架构概览
LobeChat ComfyUI 集成采用四层服务架构,围绕 `LobeComfyUI` 主类构建:
```plaintext
packages/model-runtime/src/providers/comfyui/
├── index.ts # LobeComfyUI 主类入口
├── services/ # 四大核心服务
│ ├── comfyuiClient.ts # ComfyUIClientService - 客户端和认证
│ ├── modelResolver.ts # ModelResolverService - 模型解析
│ ├── workflowBuilder.ts # WorkflowBuilderService - 工作流构建
│ └── imageService.ts # ImageService - 图像生成
├── config/ # 配置系统
│ ├── modelRegistry.ts # 主模型注册表222个模型
│ ├── fluxModelRegistry.ts # 130个FLUX模型配置
│ ├── sdModelRegistry.ts # 92个SD系列模型配置
│ ├── systemComponents.ts # VAE/CLIP/T5/LoRA/ControlNet组件
│ └── workflowRegistry.ts # 工作流路由配置
├── workflows/ # 工作流实现
│ ├── flux-dev.ts # FLUX Dev 20步工作流
│ ├── flux-schnell.ts # FLUX Schnell 4步快速工作流
│ ├── flux-kontext.ts # FLUX Kontext 填充工作流
│ ├── sd35.ts # SD3.5 外部编码器工作流
│ ├── simple-sd.ts # 通用SD工作流
│ └── index.ts # 工作流导出
├── utils/ # 工具层
│ ├── staticModelLookup.ts # 模型查找函数
│ ├── workflowDetector.ts # 模型架构检测
│ ├── promptSplitter.ts # FLUX双提示词分割
│ ├── seedGenerator.ts # 随机种子生成
│ ├── cacheManager.ts # TTL缓存管理
│ └── workflowUtils.ts # 工作流工具函数
└── errors/ # 错误处理
├── base.ts # 基础错误类
├── modelResolverError.ts # 模型解析错误
├── workflowError.ts # 工作流错误
└── servicesError.ts # 服务错误
src/server/services/comfyui/ # 服务端实现
├── core/ # 核心服务器服务
│ ├── comfyUIAuthService.ts # 认证服务
│ ├── comfyUIClientService.ts # 客户端服务
│ ├── comfyUIConnectionService.ts # 连接服务
│ ├── errorHandlerService.ts # 错误处理服务
│ ├── imageService.ts # 图像生成服务
│ ├── modelResolverService.ts # 模型解析服务
│ └── workflowBuilderService.ts # 工作流构建服务
├── config/ # 服务器端配置
│ ├── constants.ts # 常量和默认值
│ ├── modelRegistry.ts # 模型注册表
│ ├── fluxModelRegistry.ts # FLUX模型
│ ├── sdModelRegistry.ts # SD模型
│ ├── systemComponents.ts # 系统组件
│ └── workflowRegistry.ts # 工作流注册表
├── workflows/ # 服务端工作流实现
│ ├── flux-dev.ts # FLUX Dev 工作流
│ ├── flux-schnell.ts # FLUX Schnell 工作流
│ ├── flux-kontext.ts # FLUX Kontext 工作流
│ ├── sd35.ts # SD3.5 工作流
│ └── simple-sd.ts # Simple SD 工作流
├── utils/ # 服务器工具
│ ├── cacheManager.ts # 缓存管理
│ ├── componentInfo.ts # 组件信息
│ ├── imageResizer.ts # 图像调整
│ ├── promptSplitter.ts # 提示词分割
│ ├── staticModelLookup.ts # 模型查找
│ ├── weightDType.ts # 权重数据类型工具
│ ├── workflowDetector.ts # 工作流检测
│ └── workflowUtils.ts # 工作流工具
└── errors/ # 服务器错误处理
├── base.ts # 基础错误类
├── configError.ts # 配置错误
├── modelResolverError.ts # 模型解析器错误
├── servicesError.ts # 服务错误
├── utilsError.ts # 工具错误
└── workflowError.ts # 工作流错误
packages/model-runtime/src/utils/ # 共享工具
└── comfyuiErrorParser.ts # 客户端/服务器统一错误解析器
```
### 核心服务架构
`LobeComfyUI` 主类初始化四个核心服务:
```typescript
// packages/model-runtime/src/providers/comfyui/index.ts
export class LobeComfyUI implements LobeRuntimeAI, AuthenticatedImageRuntime {
constructor(options: ComfyUIKeyVault = {}) {
// 1. 客户端服务 - 处理认证和API调用
this.clientService = new ComfyUIClientService(options);
// 2. 模型解析服务 - 模型查找和组件选择
const modelResolverService = new ModelResolverService(this.clientService);
// 3. 工作流构建服务 - 路由和构建工作流
const workflowBuilderService = new WorkflowBuilderService({
clientService: this.clientService,
modelResolverService: modelResolverService,
});
// 4. 图像服务 - 统一的图像生成入口
this.imageService = new ImageService(
this.clientService,
modelResolverService,
workflowBuilderService,
);
}
}
```
## 认证系统
ComfyUI 集成支持四种认证方式,由 `ComfyUIClientService` 内的 `AuthManager` 处理:
### 支持的认证类型
```typescript
interface ComfyUIKeyVault {
baseURL: string;
authType?: 'none' | 'basic' | 'bearer' | 'custom';
// Basic Auth
username?: string;
password?: string;
// Bearer Token
apiKey?: string;
// Custom Headers
customHeaders?: Record<string, string>;
}
```
### 认证配置示例
```typescript
// 无认证
const comfyUI = new LobeComfyUI({
baseURL: 'http://localhost:8000',
authType: 'none'
});
// 基础认证
const comfyUI = new LobeComfyUI({
baseURL: 'https://your-comfyui-server.com',
authType: 'basic',
username: 'your-username',
password: 'your-password'
});
// Bearer Token
const comfyUI = new LobeComfyUI({
baseURL: 'https://your-comfyui-server.com',
authType: 'bearer',
apiKey: 'your-api-key'
});
// 自定义头部
const comfyUI = new LobeComfyUI({
baseURL: 'https://your-comfyui-server.com',
authType: 'custom',
customHeaders: {
'X-API-Key': 'your-custom-key',
'Authorization': 'Custom your-token'
}
});
```
## WebAPI 路由
ComfyUI 提供了用于图像生成的 REST WebAPI 路由,支持常规认证和内部服务认证:
### 路由详情
```typescript
// src/app/(backend)/webapi/create-image/comfyui/route.ts
export const runtime = 'nodejs';
export const maxDuration = 300; // 最长5分钟
// POST /api/create-image/comfyui
{
model: string; // 模型标识符
params: { // 生成参数
prompt: string;
width?: number;
height?: number;
// ... 其他参数
};
options?: { // 可选生成选项
// ... 额外选项
};
}
```
### 认证中间件
WebAPI 路由使用 `checkAuth` 中间件进行认证:
```typescript
import { checkAuth } from '@/app/(backend)/middleware/auth';
// 路由自动验证 JWT 令牌
// 并将认证上下文传递给 tRPC 调用器
```
### 错误处理
WebAPI 路由提供结构化的错误响应:
```typescript
// 从 TRPCError 的 cause 中提取 AgentRuntimeError
if (agentError && 'errorType' in agentError) {
// 将 errorType 转换为适当的 HTTP 状态码
// 401 对应 InvalidProviderAPIKey
// 403 对应 PermissionDenied
// 404 对应 NotFound
// 500+ 对应服务器错误
}
```
## 添加新模型
### 1. 理解模型注册表结构
模型配置存储在配置文件中:
```typescript
// packages/model-runtime/src/providers/comfyui/config/modelRegistry.ts
export interface ModelConfig {
modelFamily: 'FLUX' | 'SD1' | 'SDXL' | 'SD3';
priority: number; // 1=官方, 2=企业, 3=社区
recommendedDtype?: 'default' | 'fp8_e4m3fn' | 'fp8_e5m2';
variant: string; // 模型变体标识符
}
```
### 2. 添加 FLUX 模型
在 `fluxModelRegistry.ts` 中添加新模型:
```typescript
// packages/model-runtime/src/providers/comfyui/config/fluxModelRegistry.ts
export const FLUX_MODEL_REGISTRY: Record<string, ModelConfig> = {
// 现有模型...
// 添加新的FLUX Dev模型
'your-custom-flux-dev.safetensors': {
modelFamily: 'FLUX',
priority: 2, // 企业级模型
variant: 'dev',
recommendedDtype: 'default',
},
// 添加量化版本
'your-custom-flux-dev-fp8.safetensors': {
modelFamily: 'FLUX',
priority: 2,
variant: 'dev',
recommendedDtype: 'fp8_e4m3fn',
},
};
```
### 3. 添加 SD 系列模型
在 `sdModelRegistry.ts` 中添加:
```typescript
// packages/model-runtime/src/providers/comfyui/config/sdModelRegistry.ts
export const SD_MODEL_REGISTRY: Record<string, ModelConfig> = {
// 现有模型...
// 添加新的SD3.5模型
'your-custom-sd35.safetensors': {
modelFamily: 'SD3',
priority: 2,
variant: 'sd35',
recommendedDtype: 'default',
},
};
```
### 4. 更新模型 ID 映射(可选)
如果需要为前端提供友好的模型 ID在 `modelRegistry.ts` 中添加映射:
```typescript
// packages/model-runtime/src/providers/comfyui/config/modelRegistry.ts
export const MODEL_ID_VARIANT_MAP: Record<string, string> = {
// 现有映射...
// 添加新模型的友好ID
'my-custom-flux': 'dev', // 映射到dev变体
'my-custom-sd35': 'sd35', // 映射到sd35变体
};
```
## 创建新工作流
### 工作流创建原理
**重要:工作流节点结构来自 ComfyUI 原生导出**
1. 在 ComfyUI 界面中设计工作流
2. 使用 "Export (API Format)" 导出 JSON
3. 将 JSON 结构复制到 TypeScript 文件
4. 使用`PromptBuilder`包装并参数化
### 1. 从 ComfyUI 导出工作流
在 ComfyUI 界面中:
1. 拖拽节点构建所需工作流
2. 连接各节点的输入输出
3. 右键点击空白处 → "Export (API Format)"
4. 复制生成的 JSON 结构
### 2. 工作流文件模板
创建新文件 `workflows/your-workflow.ts`
```typescript
import { PromptBuilder } from '@saintno/comfyui-sdk';
import type { WorkflowContext } from '../services/workflowBuilder';
import { generateUniqueSeeds } from '../utils/seedGenerator';
import { getWorkflowFilenamePrefix } from '../utils/workflowUtils';
/**
* 构建自定义工作流
* @param modelFileName - 模型文件名
* @param params - 生成参数
* @param context - 工作流上下文
*/
export async function buildYourCustomWorkflow(
modelFileName: string,
params: Record<string, any>,
context: WorkflowContext,
): Promise<PromptBuilder<any, any, any>> {
// 从ComfyUI "Export (API Format)" 获得的JSON结构
const workflow = {
'1': {
_meta: { title: 'Load Checkpoint' },
class_type: 'CheckpointLoaderSimple',
inputs: {
ckpt_name: modelFileName,
},
},
'2': {
_meta: { title: 'CLIP Text Encode' },
class_type: 'CLIPTextEncode',
inputs: {
clip: ['1', 1], // 连接到节点1的CLIP输出
text: params.prompt,
},
},
'3': {
_meta: { title: 'Empty Latent' },
class_type: 'EmptyLatentImage',
inputs: {
width: params.width,
height: params.height,
batch_size: 1,
},
},
'4': {
_meta: { title: 'KSampler' },
class_type: 'KSampler',
inputs: {
model: ['1', 0], // 连接到节点1的MODEL输出
positive: ['2', 0], // 连接到节点2的CONDITIONING输出
negative: ['2', 0], // 可以配置负面提示词
latent_image: ['3', 0],
seed: params.seed ?? generateUniqueSeeds(1)[0],
steps: params.steps,
cfg: params.cfg,
sampler_name: 'euler',
scheduler: 'normal',
denoise: 1.0,
},
},
'5': {
_meta: { title: 'VAE Decode' },
class_type: 'VAEDecode',
inputs: {
samples: ['4', 0],
vae: ['1', 2], // 连接到节点1的VAE输出
},
},
'6': {
_meta: { title: 'Save Image' },
class_type: 'SaveImage',
inputs: {
filename_prefix: getWorkflowFilenamePrefix('buildYourCustomWorkflow', context.variant),
images: ['5', 0],
},
},
};
// 使用PromptBuilder包装静态JSON
const builder = new PromptBuilder(
workflow,
['width', 'height', 'steps', 'cfg', 'seed'], // 输入参数
['images'], // 输出参数
);
// 设置输出节点
builder.setOutputNode('images', '6');
// 设置输入节点路径
builder.setInputNode('width', '3.inputs.width');
builder.setInputNode('height', '3.inputs.height');
builder.setInputNode('steps', '4.inputs.steps');
builder.setInputNode('cfg', '4.inputs.cfg');
builder.setInputNode('seed', '4.inputs.seed');
// 设置参数值
builder
.input('width', params.width)
.input('height', params.height)
.input('steps', params.steps)
.input('cfg', params.cfg)
.input('seed', params.seed ?? generateUniqueSeeds(1)[0]);
return builder;
}
```
### 3. 注册新工作流
在 `workflowRegistry.ts` 中添加工作流映射:
```typescript
// packages/model-runtime/src/providers/comfyui/config/workflowRegistry.ts
import { buildYourCustomWorkflow } from '../workflows/your-workflow';
export const VARIANT_WORKFLOW_MAP: Record<string, WorkflowBuilder> = {
// 现有映射...
// 添加新工作流
'your-variant': buildYourCustomWorkflow,
};
```
### 4. 实际工作流示例
参考 `flux-dev.ts` 的真实实现:
```typescript
// packages/model-runtime/src/providers/comfyui/workflows/flux-dev.ts (简化版)
export async function buildFluxDevWorkflow(
modelFileName: string,
params: Record<string, any>,
context: WorkflowContext,
): Promise<PromptBuilder<any, any, any>> {
// 获取所需组件
const selectedT5Model = await context.modelResolverService.getOptimalComponent('t5', 'FLUX');
const selectedVAE = await context.modelResolverService.getOptimalComponent('vae', 'FLUX');
const selectedCLIP = await context.modelResolverService.getOptimalComponent('clip', 'FLUX');
// 处理双提示词分割
const { t5xxlPrompt, clipLPrompt } = splitPromptForDualCLIP(params.prompt);
// 静态工作流定义来自ComfyUI导出
const workflow = {
'1': {
class_type: 'DualCLIPLoader',
inputs: {
clip_name1: selectedT5Model,
clip_name2: selectedCLIP,
type: 'flux',
},
},
// ... 更多节点
};
// 参数注入必须在workflow文件内完成
workflow['5'].inputs.clip_l = clipLPrompt;
workflow['5'].inputs.t5xxl = t5xxlPrompt;
workflow['4'].inputs.width = params.width;
workflow['4'].inputs.height = params.height;
// 创建并配置PromptBuilder
const builder = new PromptBuilder(workflow, inputs, outputs);
// 配置输入输出映射...
return builder;
}
```
## 系统组件管理
### 组件配置结构
所有系统组件VAE、CLIP、T5、LoRA、ControlNet统一配置在 `systemComponents.ts`
```typescript
// packages/model-runtime/src/providers/comfyui/config/systemComponents.ts
export interface ComponentConfig {
modelFamily: string; // 模型家族
priority: number; // 1=必需, 2=标准, 3=可选
type: string; // 组件类型
compatibleVariants?: string[]; // 兼容变体LoRA/ControlNet
controlnetType?: string; // ControlNet类型
}
export const SYSTEM_COMPONENTS: Record<string, ComponentConfig> = {
// VAE组件
'ae.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 'vae',
},
// CLIP组件
'clip_l.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 'clip',
},
// T5编码器
't5xxl_fp16.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 't5',
},
// LoRA适配器
'realism_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
// ControlNet模型
'flux-controlnet-canny-v3.safetensors': {
compatibleVariants: ['dev'],
controlnetType: 'canny',
modelFamily: 'FLUX',
priority: 1,
type: 'controlnet',
},
};
```
### 添加新组件
```typescript
// 添加新的LoRA
'your-custom-lora.safetensors': {
compatibleVariants: ['dev', 'schnell'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
// 添加新的ControlNet
'your-controlnet-pose.safetensors': {
compatibleVariants: ['dev'],
controlnetType: 'pose',
modelFamily: 'FLUX',
priority: 2,
type: 'controlnet',
},
```
### 组件查询 API
```typescript
import { getAllComponentsWithNames, getOptimalComponent } from '../config/systemComponents';
// 获取最优组件
const bestVAE = getOptimalComponent('vae', 'FLUX');
const bestT5 = getOptimalComponent('t5', 'FLUX');
// 查询特定类型的组件
const availableLoras = getAllComponentsWithNames({
type: 'lora',
modelFamily: 'FLUX',
compatibleVariant: 'dev'
});
// 查询ControlNet
const cannyControlNets = getAllComponentsWithNames({
type: 'controlnet',
controlnetType: 'canny',
modelFamily: 'FLUX'
});
```
## 模型解析和查找
### ModelResolverService 工作原理
```typescript
// packages/model-runtime/src/providers/comfyui/services/modelResolver.ts
export class ModelResolverService {
async resolveModelFileName(modelId: string): Promise<string | undefined> {
// 1. 清理模型ID
const cleanId = modelId.replace(/^comfyui\//, '');
// 2. 检查模型ID映射
const mappedVariant = MODEL_ID_VARIANT_MAP[cleanId];
if (mappedVariant) {
const prioritizedModels = getModelsByVariant(mappedVariant);
const serverModels = await this.getAvailableModelFiles();
// 按优先级查找第一个可用模型
for (const filename of prioritizedModels) {
if (serverModels.includes(filename)) {
return filename;
}
}
}
// 3. 直接注册表查找
if (MODEL_REGISTRY[cleanId]) {
return cleanId;
}
// 4. 检查服务器文件存在性
if (isModelFile(cleanId)) {
const serverModels = await this.getAvailableModelFiles();
if (serverModels.includes(cleanId)) {
return cleanId;
}
}
return undefined;
}
}
```
### 模型查找示例
```typescript
// 实际使用示例
const resolver = new ModelResolverService(clientService);
// 友好ID查找
const fluxDevFile = await resolver.resolveModelFileName('flux-dev');
// 返回: 'flux1-dev.safetensors' (如果存在)
// 直接文件名查找
const directFile = await resolver.resolveModelFileName('my-custom-model.safetensors');
// 返回: 'my-custom-model.safetensors' (如果存在)
// 变体查找
const devModels = getModelsByVariant('dev');
console.log(devModels.slice(0, 3));
// 输出: ['flux1-dev.safetensors', 'flux1-dev-fp8.safetensors', ...]
```
## 错误处理
### 错误类型层次
```plaintext
// packages/model-runtime/src/providers/comfyui/errors/
ComfyUIInternalError // 基础错误
├── ModelResolverError // 模型解析错误
├── WorkflowError // 工作流错误
├── ServicesError // 服务错误
└── UtilsError // 工具错误
```
### 错误处理示例
```typescript
import { ModelResolverError, WorkflowError } from '../errors';
try {
const result = await comfyUI.createImage({
model: 'nonexistent-model',
params: { prompt: '测试' }
});
} catch (error) {
if (error instanceof ModelResolverError) {
console.log('模型解析失败:', error.message);
console.log('错误原因:', error.reason);
console.log('错误详情:', error.details);
} else if (error instanceof WorkflowError) {
console.log('工作流错误:', error.message);
}
}
```
### 统一错误解析器
客户端和服务器端错误处理可使用共享的错误解析器:
```typescript
// packages/model-runtime/src/utils/comfyuiErrorParser.ts
import { parseComfyUIErrorMessage, cleanComfyUIErrorMessage } from '../utils/comfyuiErrorParser';
// 解析错误消息并确定错误类型
const { error, errorType } = parseComfyUIErrorMessage(rawError);
// 清理 ComfyUI 格式的错误消息
const cleanMessage = cleanComfyUIErrorMessage(errorMessage);
```
错误解析器处理:
- HTTP 状态码映射到错误类型
- 服务器端错误增强
- 模型文件缺失检测
- 网络错误识别
- 工作流验证错误
## 测试架构与开发
### 测试架构概述
ComfyUI 集成使用了统一的测试架构,确保测试的可维护性和定制友好性。该架构包括:
- **统一 Mock 系统**:集中管理所有外部依赖的模拟
- **参数化测试**:自动适应新模型,无需修改现有测试
- **夹具系统**:从配置文件中获取测试数据,确保准确性
- **覆盖率目标**ComfyUI 模块维持 97%+ 覆盖率
### 测试文件结构
```plaintext
packages/model-runtime/src/providers/comfyui/__tests__/
├── setup/
│ └── unifiedMocks.ts # 统一Mock配置
├── fixtures/
│ ├── parameters.fixture.ts # 参数测试夹具
│ └── workflow.fixture.ts # 工作流测试夹具
├── integration/
│ ├── parameterMapping.test.ts # 参数映射集成测试
│ └── workflowBuilder.test.ts # 工作流构建测试
├── services/ # 各服务单元测试
└── workflows/ # 工作流单元测试
```
### 添加新模型测试
当添加新模型时,测试会自动识别并运行相应的参数映射测试。你只需要:
#### 1. 在模型配置中添加参数架构
```typescript
// packages/model-bank/src/aiModels/comfyui.ts
export const myNewModelParamsSchema = {
prompt: { type: 'string', required: true },
steps: { type: 'number', default: 20, min: 1, max: 150 },
cfg: { type: 'number', default: 7.0, min: 1.0, max: 30.0 }
};
```
#### 2. 创建工作流构建器
```typescript
// packages/model-runtime/src/providers/comfyui/workflows/myNewModel.ts
export async function buildMyNewModelWorkflow(
modelName: string,
params: MyNewModelParams,
context: ComfyUIContext
) {
const workflow = { /* 工作流定义 */ };
// 参数注入
workflow['1'].inputs.prompt = params.prompt;
workflow['2'].inputs.steps = params.steps;
return workflow;
}
```
#### 3. 在夹具中注册模型
```typescript
// packages/model-runtime/src/providers/comfyui/__tests__/fixtures/parameters.fixture.ts
import {
myNewModelParamsSchema,
// ... 其他架构
} from '../../../../../model-bank/src/aiModels/comfyui';
export const parametersFixture = {
models: {
'my-new-model': {
schema: myNewModelParamsSchema,
defaults: {
steps: myNewModelParamsSchema.steps.default,
cfg: myNewModelParamsSchema.cfg.default,
},
boundaries: {
min: { steps: myNewModelParamsSchema.steps.min },
max: { steps: myNewModelParamsSchema.steps.max }
}
}
}
};
```
### 测试最佳实践
#### 使用统一 Mock 系统
```typescript
import { setupAllMocks } from '../setup/unifiedMocks';
describe('MyTest', () => {
const mocks = setupAllMocks();
beforeEach(() => {
vi.clearAllMocks();
});
});
```
#### 编写参数映射测试
参数映射测试会自动运行,验证前端参数正确注入到工作流中:
```typescript
// 测试会自动包含新注册的模型
describe.each(
Object.entries(models).filter(([name]) => workflowBuilders[name])
)(
'%s parameter mapping',
(modelName, modelConfig) => {
it('should map schema parameters to workflow', async () => {
const params = {
prompt: 'test prompt',
...modelConfig.defaults,
};
const workflow = await builder(`${modelName}.safetensors`, params, mockContext);
expect(workflow).toBeDefined();
});
}
);
```
#### 定制友好的测试原则
- **不测试工作流结构**:工作流是 ComfyUI 官方格式,只测试参数映射
- **使用配置驱动的数据**:测试数据来自模型配置文件,确保一致性
- **避免脆性断言**:不检查具体的节点 ID 或内部结构
- **支持扩展**:新增模型应该只影响覆盖率,不破坏现有测试
### 运行测试
```bash
# 运行 ComfyUI 相关测试
cd packages/model-runtime
bunx vitest run --silent='passed-only' 'src/comfyui'
# 查看覆盖率
bunx vitest run --coverage 'src/comfyui'
# 运行特定测试文件
bunx vitest run 'src/comfyui/__tests__/integration/parameterMapping.test.ts'
```
### 覆盖率目标
- **整体覆盖率**ComfyUI 模块维持 97%+ 覆盖率
- **核心功能**100% 分支覆盖率
- **新增功能**:保持或提升现有覆盖率水平
## 开发和测试
### 1. 本地开发设置
```bash
# 启动ComfyUI调试模式
DEBUG=lobe-image:* pnpm dev
```
### 2. 测试新功能
```typescript
// 创建测试文件
import { buildYourCustomWorkflow } from './your-workflow';
describe('Custom Workflow', () => {
test('should build workflow correctly', async () => {
const mockContext = {
clientService: mockClientService,
modelResolverService: mockModelResolver,
};
const workflow = await buildYourCustomWorkflow(
'test-model.safetensors',
{ prompt: '测试', width: 512, height: 512 },
mockContext
);
expect(workflow).toBeDefined();
// 验证工作流结构...
});
});
```
### 3. 模型配置测试
```typescript
import { getModelConfig, getAllModelNames } from '../config/modelRegistry';
describe('Model Registry', () => {
test('should find new model', () => {
const config = getModelConfig('your-new-model.safetensors');
expect(config).toBeDefined();
expect(config?.variant).toBe('dev');
expect(config?.modelFamily).toBe('FLUX');
});
});
```
## 完整使用示例
### 基础图像生成
```typescript
import { LobeComfyUI } from '@/libs/model-runtime/comfyui';
const comfyUI = new LobeComfyUI({
baseURL: 'http://localhost:8000',
authType: 'none'
});
// FLUX Dev模型生成
const result = await comfyUI.createImage({
model: 'flux-dev',
params: {
prompt: '美丽的风景画,高质量,详细',
width: 1024,
height: 1024,
steps: 20,
cfg: 3.5,
seed: -1
}
});
console.log('生成图像URL:', result.imageUrl);
```
### SD3.5 模型使用
```typescript
// SD3.5会自动检测可用编码器
const sd35Result = await comfyUI.createImage({
model: 'stable-diffusion-35',
params: {
prompt: '未来主义城市景观',
width: 1344,
height: 768,
steps: 28,
cfg: 4.5
}
});
```
### 企业优化模型
```typescript
// 系统会自动选择最佳可用变体如FP8量化版本
const optimizedResult = await comfyUI.createImage({
model: 'flux-dev',
params: {
prompt: '专业商务肖像',
width: 768,
height: 1024,
steps: 15 // FP8模型可以用更少步数
}
});
```
## 注意事项
- 确保 ComfyUI 服务正常运行并可访问
- 检查所有必需的模型文件是否已正确安装
- 注意模型文件的命名规范和路径配置
- 定期检查和更新工作流配置以支持新功能
- 注意不同模型系列的参数差异和兼容性
- 添加新模型时,请遵循测试架构指南确保测试完整性
- 在提交代码前务必运行相关测试确保覆盖率达标
通过遵循这些指南,开发者可以有效地在 LobeChat 中使用和扩展 ComfyUI 功能,为用户提供强大的图像生成和处理能力。

View file

@ -651,6 +651,58 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
The above example disables all models first, then enables `fal-ai/flux/schnell` and `fal-ai/flux-pro/kontext` (displayed as `FLUX.1 Kontext [pro]`).
## ComfyUI
### `COMFYUI_BASE_URL`
- Type: Optional
- Description: The base URL address of the ComfyUI service
- Default: `http://localhost:8188`
- Example: `http://192.168.1.100:8188` or `https://my-comfyui-server.com`
### `COMFYUI_AUTH_TYPE`
- Type: Optional
- Description: The authentication type for ComfyUI, supporting 4 authentication methods
- `none`: No authentication (default)
- `basic`: Basic authentication (username + password)
- `bearer`: Bearer Token authentication (API key)
- `custom`: Custom request header authentication
- Default: `none`
- Example: `basic`
### `COMFYUI_API_KEY`
- Type: Optional
- Description: The API key used when the authentication type is `bearer`
- Default: -
- Example: `sk-xxxxxx...xxxxxx`
### `COMFYUI_USERNAME`
- Type: Optional
- Description: The username used when the authentication type is `basic`
- Default: -
- Example: `admin`
### `COMFYUI_PASSWORD`
- Type: Optional
- Description: The password used when the authentication type is `basic`
- Default: -
- Example: `password123`
### `COMFYUI_CUSTOM_HEADERS`
- Type: Optional
- Description: Custom request headers used when the authentication type is `custom`, requires JSON format string
- Default: -
- Example: `{"X-Auth-Token": "your-token", "X-Custom-Header": "value"}`
<Callout type={'info'}>
ComfyUI supports multiple authentication methods. Please choose the appropriate authentication type and corresponding authentication parameters according to your ComfyUI service configuration. If your ComfyUI service has no authentication set up, you can skip configuring authentication-related environment variables.
</Callout>
## BFL
### `ENABLED_BFL`

View file

@ -165,6 +165,58 @@ LobeChat 在部署时提供了丰富的模型服务商相关的环境变量,
- 默认值:`us-east-1`
- 示例:`us-east-1`
## ComfyUI
### `COMFYUI_BASE_URL`
- 类型:可选
- 描述ComfyUI 服务的基础 URL 地址
- 默认值:`http://localhost:8000`
- 示例:`http://192.168.1.100:8000` 或 `https://my-comfyui-server.com`
### `COMFYUI_AUTH_TYPE`
- 类型:可选
- 描述ComfyUI 的认证类型,支持 4 种认证方式
- `none`: 无认证(默认)
- `basic`: 基础认证(用户名 + 密码)
- `bearer`: Bearer Token 认证API 密钥)
- `custom`: 自定义请求头认证
- 默认值:`none`
- 示例:`basic`
### `COMFYUI_API_KEY`
- 类型:可选
- 描述:当认证类型为 `bearer` 时使用的 API 密钥
- 默认值:-
- 示例:`sk-xxxxxx...xxxxxx`
### `COMFYUI_USERNAME`
- 类型:可选
- 描述:当认证类型为 `basic` 时使用的用户名
- 默认值:-
- 示例:`admin`
### `COMFYUI_PASSWORD`
- 类型:可选
- 描述:当认证类型为 `basic` 时使用的密码
- 默认值:-
- 示例:`password123`
### `COMFYUI_CUSTOM_HEADERS`
- 类型:可选
- 描述:当认证类型为 `custom` 时使用的自定义请求头,需要使用 JSON 格式字符串
- 默认值:-
- 示例:`{"X-Auth-Token": "your-token", "X-Custom-Header": "value"}`
<Callout type={'info'}>
ComfyUI 支持多种认证方式,请根据您的 ComfyUI 服务配置选择合适的认证类型和相应的认证参数。如果您的 ComfyUI 服务没有设置认证,可以不配置认证相关的环境变量。
</Callout>
## DeepSeek AI
### `DEEPSEEK_PROXY_URL`

View file

@ -0,0 +1,816 @@
---
title: Using ComfyUI for Image Generation in LobeChat
description: Learn how to configure and use ComfyUI service in LobeChat, supporting FLUX series models for high-quality image generation and editing features
tags:
- ComfyUI
- FLUX
- Text-to-Image
- Image Editing
- AI Image Generation
---
# Using ComfyUI in LobeChat
<Image alt={'Using ComfyUI in LobeChat'} cover src={'https://github.com/lobehub/lobe-chat/assets/17870709/c9e5eafc-ca22-496b-a88d-cc0ae53bf720'} />
This documentation will guide you on how to use [ComfyUI](https://github.com/comfyanonymous/ComfyUI) in LobeChat for high-quality AI image generation and editing.
## ComfyUI Overview
ComfyUI is a powerful stable diffusion and flow diffusion GUI that provides a node-based workflow interface. LobeChat integrates with ComfyUI, supporting complete FLUX series models, including text-to-image generation and image editing capabilities.
### Key Features
- **Extensive Model Support**: Supports 223 models, including FLUX series (130) and SD series (93)
- **Configuration-Driven Architecture**: Registry system provides intelligent model selection
- **Multi-Format Support**: Supports .safetensors and .gguf formats with various quantization levels
- **Dynamic Precision Selection**: Supports default, fp8\_e4m3fn, fp8\_e5m2, fp8\_e4m3fn\_fast precision
- **Multiple Authentication Methods**: Supports no authentication, basic authentication, Bearer Token, and custom authentication
- **Intelligent Component Selection**: Automatically selects optimal T5, CLIP, VAE encoder combinations
- **Enterprise-Grade Optimization**: Includes NF4, SVDQuant, TorchAO, MFLUX optimization variants
## Quick Start
### Step 1: Configure ComfyUI in LobeChat
#### 1. Open Settings Interface
- Access LobeChat's `Settings` interface
- Find the `ComfyUI` setting item under `AI Providers`
<Image alt={'ComfyUI Settings Interface'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/3f31bc33-509f-4ad2-ba81-280c2a6ec5fa'} />
#### 2. Configure Connection Parameters
**Basic Configuration**:
- **Server Address**: Enter ComfyUI server address, e.g., `http://localhost:8188`
- **Authentication Type**: Select appropriate authentication method (default: no authentication)
### Step 2: Select Model and Start Generating Images
#### 1. Select FLUX Model
In the conversation interface:
- Click the model selection button
- Select the desired FLUX model from the ComfyUI category
<Image alt={'Select FLUX Model'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff7ebacf-27f0-42d7-810b-00314499a084'} />
#### 2. Text-to-Image Generation
**Using FLUX Schnell (Fast Generation)**:
```plaintext
Generate an image: A cute orange cat sitting on a sunny windowsill, warm lighting, detailed fur texture
```
**Using FLUX Dev (High Quality Generation)**:
```plaintext
Generate high quality image: City skyline at sunset, cyberpunk style, neon lights, 4K high resolution, detailed architecture
```
#### 3. Image Editing
**Using FLUX Kontext-dev for Image Editing**:
```plaintext
Edit this image: Change the background to a starry night sky, keep the main subject, cosmic atmosphere
```
Then upload the original image you want to edit.
<Callout type={'info'}>
Image editing functionality requires uploading the original image first, then describing the modifications you want to make.
</Callout>
## Authentication Configuration Guide
ComfyUI supports four authentication methods. Choose the appropriate method based on your server configuration and security requirements:
### No Authentication (none)
**Use Cases**:
- Local development environment (localhost)
- Internal network with trusted users
- Personal single-machine deployment
**Configuration**:
```yaml
Authentication Type: None
Server Address: http://localhost:8188
```
### Basic Authentication (basic)
**Use Cases**:
- Deployments using Nginx reverse proxy
- Team internal use requiring basic access control
**Configuration**:
1. **Create User Password**:
```bash
# Install apache2-utils
sudo apt-get install apache2-utils
# Create user 'admin'
sudo htpasswd -c /etc/nginx/.htpasswd admin
```
2. **LobeChat Configuration**:
```yaml
Authentication Type: Basic Authentication
Server Address: http://your-domain.com
Username: admin
Password: your_secure_password
```
### Bearer Token (bearer)
**Use Cases**:
- API-driven application integration
- Enterprise environments requiring Token authentication
**Generate Token**:
```python
import jwt
import datetime
payload = {
'user': 'admin',
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30)
}
secret_key = "your-secret-key"
token = jwt.encode(payload, secret_key, algorithm='HS256')
print(f"Bearer Token: {token}")
```
**LobeChat Configuration**:
```yaml
Authentication Type: Bearer Token
Server Address: http://your-server:8188
API Key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### Custom Authentication (custom)
**Use Cases**:
- Integration with existing enterprise authentication systems
- Systems requiring multiple authentication headers
**LobeChat Configuration**:
```yaml
Authentication Type: Custom
Server Address: http://your-server:8188
Custom Headers:
{
"X-API-Key": "your_api_key",
"X-Client-ID": "lobechat"
}
```
## Common Issues Resolution
### 1. How to Install Comfy-Manager
Comfy-Manager is ComfyUI's extension manager that allows you to easily install and manage various nodes, models, and extensions.
<details>
<summary><b>📦 Install Comfy-Manager Steps</b></summary>
#### Method 1: Manual Installation (Recommended)
```bash
# Navigate to ComfyUI's custom_nodes directory
cd ComfyUI/custom_nodes
# Clone Comfy-Manager repository
git clone https://github.com/ltdrdata/ComfyUI-Manager.git
# Restart ComfyUI server
# After restart, you'll see the Manager button in the UI
```
#### Method 2: One-Click Installation Script
```bash
# Execute in ComfyUI root directory
curl -fsSL https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/install.sh | bash
```
#### Verify Installation
1. Restart ComfyUI server
2. Visit `http://localhost:8188`
3. You should see the "Manager" button in the bottom-right corner
#### Using Comfy-Manager
**Install Models**:
1. Click "Manager" button
2. Select "Install Models"
3. Search for needed models (e.g., FLUX, SD3.5)
4. Click "Install" to automatically download to correct directory
**Install Node Extensions**:
1. Click "Manager" button
2. Select "Install Custom Nodes"
3. Search for needed nodes (e.g., ControlNet, AnimateDiff)
4. Click "Install" and restart server
**Manage Installed Content**:
1. Click "Manager" button
2. Select "Installed" to view installed extensions
3. Update, disable, or uninstall extensions
</details>
### 2. How to Handle "Model not found" Errors
When you see errors like `Model not found: flux1-dev.safetensors, flux1-krea-dev.safetensors, flux1-schnell.safetensors`, it means the required model files are missing from the server.
<details>
<summary><b>🔧 Resolve Model not found Errors</b></summary>
#### Error Example
```plaintext
Model not found: flux1-dev.safetensors, flux1-krea-dev.safetensors, flux1-schnell.safetensors
```
This error indicates the system expects to find these model files but couldn't locate them on the server.
#### Resolution Methods
**Method 1: Download using Comfy-Manager (Recommended)**
1. Open ComfyUI interface
2. Click "Manager" → "Install Models"
3. Search for the model name from the error (e.g., "flux1-dev")
4. Click "Install" to automatically download
**Method 2: Manual Model Download**
1. **Download Model Files**:
- Visit [Hugging Face](https://huggingface.co/black-forest-labs/FLUX.1-dev) or other model sources
- Download the files mentioned in the error (e.g., `flux1-dev.safetensors`)
2. **Place in Correct Directory**:
```bash
# FLUX and SD3.5 main models go to
ComfyUI/models/diffusion_models/flux1-dev.safetensors
# SD1.5 and SDXL models go to
ComfyUI/models/checkpoints/
```
3. **Verify Files**:
```bash
# Check if file exists
ls -la ComfyUI/models/diffusion_models/flux1-dev.safetensors
# Check file integrity (optional)
sha256sum flux1-dev.safetensors
```
4. **Restart ComfyUI Server**
**Method 3: Direct Download with wget/curl**
```bash
# Navigate to models directory
cd ComfyUI/models/diffusion_models/
# Download using wget (replace with actual download link)
wget https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors
# Or use curl
curl -L -o flux1-dev.safetensors https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors
```
#### Common Model Download Sources
- **Hugging Face**: [https://huggingface.co/models](https://huggingface.co/models)
- **Civitai**: [https://civitai.com/models](https://civitai.com/models)
- **Official Sources**:
- FLUX: [https://huggingface.co/black-forest-labs](https://huggingface.co/black-forest-labs)
- SD3.5: [https://huggingface.co/stabilityai](https://huggingface.co/stabilityai)
#### Prevention Measures
1. **Basic Model Package**: Download at least one base model
- FLUX: `flux1-schnell.safetensors` (fast) or `flux1-dev.safetensors` (high quality)
- SD3.5: `sd3.5_large.safetensors`
2. **Check Disk Space**:
```bash
# Check available space
df -h ComfyUI/models/
```
3. **Set Model Path** (optional):
If your models are stored elsewhere, create symbolic links:
```bash
ln -s /path/to/your/models ComfyUI/models/diffusion_models/
```
</details>
### 3. How to Handle Missing System Component Errors
When you see errors like `Missing VAE encoder: ae.safetensors` or other component files missing, you need to download the corresponding system components.
<details>
<summary><b>🛠️ Resolve Missing System Component Errors</b></summary>
#### Common Component Errors
```plaintext
Missing VAE encoder: ae.safetensors. Please download and place it in the models/vae folder.
Missing CLIP encoder: clip_l.safetensors. Please download and place it in the models/clip folder.
Missing T5 encoder: t5xxl_fp16.safetensors. Please download and place it in the models/clip folder.
```
#### Component Types Description
| Component Type | Example Filename | Purpose | Storage Directory |
| -------------- | ------------------------------ | ----------------------- | ------------------ |
| **VAE** | ae.safetensors | Image encoding/decoding | models/vae/ |
| **CLIP** | clip\_l.safetensors | Text encoding (CLIP) | models/clip/ |
| **T5** | t5xxl\_fp16.safetensors | Text encoding (T5) | models/clip/ |
| **ControlNet** | flux-controlnet-\*.safetensors | Control networks | models/controlnet/ |
#### Resolution Methods
**Method 1: Use Comfy-Manager (Recommended)**
1. Click "Manager" → "Install Models"
2. Select component type in "Filter" (VAE/CLIP/T5)
3. Download corresponding component files
**Method 2: Manual Component Download**
##### FLUX Required Components
```bash
# 1. VAE Encoder
cd ComfyUI/models/vae/
wget https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/ae.safetensors
# 2. CLIP-L Encoder
cd ComfyUI/models/clip/
wget https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors
# 3. T5-XXL Encoder (choose different precisions)
# FP16 version (recommended, balanced performance)
wget https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors
# Or FP8 version (saves VRAM)
wget https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp8_e4m3fn.safetensors
```
##### SD3.5 Required Components
```bash
# SD3.5 uses different encoders
cd ComfyUI/models/clip/
# CLIP-G Encoder
wget https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/text_encoders/clip_g.safetensors
# CLIP-L Encoder
wget https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/text_encoders/clip_l.safetensors
# T5-XXL Encoder
wget https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/text_encoders/t5xxl_fp16.safetensors
```
##### SDXL Required Components
```bash
# SDXL VAE
cd ComfyUI/models/vae/
wget https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors
# SDXL uses built-in CLIP encoders, usually no separate download needed
```
#### Component Compatibility Matrix
| Model Series | Required VAE | Required CLIP | Required T5 | Optional Components |
| ------------ | -------------- | ------------------- | ----------------------- | ------------------- |
| **FLUX** | ae.safetensors | clip\_l.safetensors | t5xxl\_fp16.safetensors | ControlNet |
| **SD3.5** | Built-in | clip\_g + clip\_l | t5xxl\_fp16 | - |
| **SDXL** | sdxl\_vae | Built-in | - | Refiner |
| **SD1.5** | vae-ft-mse | Built-in | - | ControlNet |
#### Precision Selection Recommendations
**T5 Encoder Precision Selection**:
| VRAM Capacity | Recommended Version | Filename |
| ------------- | ------------------- | ------------------------------ |
| \< 12GB | FP8 Quantized | t5xxl\_fp8\_e4m3fn.safetensors |
| 12-16GB | FP16 | t5xxl\_fp16.safetensors |
| > 16GB | FP32 | t5xxl.safetensors |
#### Verify Component Installation
```bash
# Check all required components
echo "=== VAE Components ==="
ls -la ComfyUI/models/vae/
echo "=== CLIP/T5 Components ==="
ls -la ComfyUI/models/clip/
echo "=== ControlNet Components ==="
ls -la ComfyUI/models/controlnet/
```
#### Troubleshooting
**Issue: Still getting errors after download**
1. **Check File Permissions**:
```bash
chmod 644 ComfyUI/models/vae/*.safetensors
chmod 644 ComfyUI/models/clip/*.safetensors
```
2. **Clear Cache**:
```bash
# Clear ComfyUI cache
rm -rf ComfyUI/temp/*
rm -rf ComfyUI/__pycache__/*
```
3. **Restart Server**:
```bash
# Fully restart ComfyUI
pkill -f "python main.py"
python main.py --listen 0.0.0.0 --port 8188
```
**Issue: Insufficient VRAM**
Use quantized component versions:
- T5: Use `t5xxl_fp8_e4m3fn.safetensors` instead of FP16/FP32
- VAE: Some models support FP16 VAE versions
**Issue: Slow Downloads**
1. Use mirror sources (if applicable)
2. Use download tools (like aria2c) with resume support:
```bash
aria2c -x 16 -s 16 -k 1M [download_link]
```
</details>
## ComfyUI Server Installation
<details>
<summary><b>🚀 Install and Configure ComfyUI Server</b></summary>
### 1. Install ComfyUI
```bash
# Clone ComfyUI repository
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
# Install dependencies
pip install -r requirements.txt
# Optional: Install JWT support (for Token authentication)
pip install PyJWT
# Start ComfyUI server
python main.py --listen 0.0.0.0 --port 8188
```
### 2. Download Model Files
**Recommended Basic Configuration** (Minimal installation):
**Main Models** (place in `models/diffusion_models/` directory):
- `flux1-schnell.safetensors` - Fast generation (4 steps)
- `flux1-dev.safetensors` - High-quality creation (20 steps)
**Required Components** (place in respective directories):
- `models/vae/ae.safetensors` - VAE encoder
- `models/clip/clip_l.safetensors` - CLIP text encoder
- `models/clip/t5xxl_fp16.safetensors` - T5 text encoder
### 3. Verify Server Running
Visit `http://localhost:8188` to confirm ComfyUI interface loads properly.
<Callout type={'info'}>
**Smart Model Selection**: LobeChat will automatically select the best model based on available model files on the server. You don't need to download all models; the system will automatically choose from available models by priority (Official > Enterprise > Community).
</Callout>
</details>
## Supported Models
LobeChat's ComfyUI integration uses a configuration-driven architecture, supporting **223 models**, providing complete coverage from official models to community-optimized versions.
### FLUX Series Recommended Parameters
| Model Type | Recommended Steps | CFG Scale | Resolution Range |
| ----------- | ----------------- | --------- | -------------------- |
| **Schnell** | 4 steps | - | 512×512 to 1536×1536 |
| **Dev** | 20 steps | 3.5 | 512×512 to 2048×2048 |
| **Kontext** | 20 steps | 3.5 | 512×512 to 2048×2048 |
| **Krea** | 20 steps | 4.5 | 512×512 to 2048×2048 |
### SD3.5 Series Parameters
| Model Type | Recommended Steps | CFG Scale | Resolution Range |
| --------------- | ----------------- | --------- | -------------------- |
| **Large** | 25 steps | 7.0 | 512×512 to 2048×2048 |
| **Large Turbo** | 8 steps | 3.5 | 512×512 to 1536×1536 |
| **Medium** | 20 steps | 6.0 | 512×512 to 1536×1536 |
<details>
<summary><b>📋 Complete Supported Model List</b></summary>
### Model Classification System
#### Priority 1: Official Core Models
**FLUX.1 Official Series**:
- `flux1-dev.safetensors` - High-quality creation model
- `flux1-schnell.safetensors` - Fast generation model
- `flux1-kontext-dev.safetensors` - Image editing model
- `flux1-krea-dev.safetensors` - Safety-enhanced model
**SD3.5 Official Series**:
- `sd3.5_large.safetensors` - SD3.5 large base model
- `sd3.5_large_turbo.safetensors` - Fast generation version
- `sd3.5_medium.safetensors` - Medium-scale model
#### Priority 2: Enterprise Optimized Models (106 FLUX)
**Quantization Optimization Series**:
- **GGUF Quantization**: Each variant supports 11 quantization levels (F16, Q8\_0, Q6\_K, Q5\_K\_M, Q5\_K\_S, Q4\_K\_M, Q4\_K\_S, Q4\_0, Q3\_K\_M, Q3\_K\_S, Q2\_K)
- **FP8 Precision**: fp8\_e4m3fn, fp8\_e5m2 optimized versions
- **Enterprise Lightweight**: FLUX.1-lite-8B series
- **Technical Experiments**: NF4, SVDQuant, TorchAO, optimum-quanto, MFLUX optimized versions
#### Priority 3: Community Fine-tuned Models (48 FLUX)
**Community Optimization Series**:
- **Jib Mix Flux** Series: High-quality mixed models
- **Real Dream FLUX** Series: Realism style
- **Vision Realistic** Series: Visual realism
- **PixelWave FLUX** Series: Pixel art optimization
- **Fluxmania** Series: Diverse style support
### SD Series Model Support (93 models)
**SD3.5 Series**: 5 models
**SD1.5 Series**: 37 models (including official, quantized, and community versions)
**SDXL Series**: 50 models (including base, Refiner, and Playground models)
### Workflow Support
System supports **6 workflows**:
- **flux-dev**: High-quality creation workflow
- **flux-schnell**: Fast generation workflow
- **flux-kontext**: Image editing workflow
- **sd35**: SD3.5 dedicated workflow
- **simple-sd**: Simple SD workflow
- **index**: Workflow entry point
</details>
## Performance Optimization Recommendations
### Hardware Requirements
**Minimum Configuration** (GGUF quantized models):
- GPU: 6GB VRAM (using Q4 quantization)
- RAM: 12GB
- Storage: 30GB available space
**Recommended Configuration** (standard models):
- GPU: 12GB+ VRAM (RTX 4070 Ti or higher)
- RAM: 24GB+
- Storage: SSD 100GB+ available space
### VRAM Optimization Strategy
| VRAM Capacity | Recommended Quantization | Model Example | Performance Characteristics |
| ------------- | ------------------------ | ---------------------------------- | --------------------------- |
| **6-8GB** | Q4\_0, Q4\_K\_S | `flux1-dev-Q4_0.gguf` | Minimal VRAM usage |
| **10-12GB** | Q6\_K, Q8\_0 | `flux1-dev-Q6_K.gguf` | Balance performance/quality |
| **16GB+** | FP8, FP16 | `flux1-dev-fp8-e4m3fn.safetensors` | Near-original quality |
| **24GB+** | Full model | `flux1-dev.safetensors` | Best quality |
## Custom Model Usage
<details>
<summary><b>🎨 Configure Custom SD Models</b></summary>
LobeChat supports using custom Stable Diffusion models. The system uses fixed filenames to identify custom models.
### 1. Model File Preparation
**Required Files**:
- **Main Model File**: `custom_sd_lobe.safetensors`
- **VAE File (Optional)**: `custom_sd_vae_lobe.safetensors`
### 2. Add Custom Model
**Method 1: Rename Existing Model**
```bash
# Rename your model to fixed filename
mv your_custom_model.safetensors custom_sd_lobe.safetensors
# Move to correct directory
mv custom_sd_lobe.safetensors ComfyUI/models/diffusion_models/
```
**Method 2: Create Symbolic Link (Recommended)**
```bash
# Create soft link for easy model switching
ln -s /path/to/your_model.safetensors ComfyUI/models/diffusion_models/custom_sd_lobe.safetensors
```
### 3. Use Custom Model
In LobeChat, custom models will appear as:
- **stable-diffusion-custom**: Standard custom model
- **stable-diffusion-custom-refiner**: Refiner custom model
### Custom Model Parameter Recommendations
| Parameter | SD 1.5 Models | SDXL Models |
| ---------- | ------------- | ----------- |
| **steps** | 20-30 | 25-40 |
| **cfg** | 7.0 | 6.0-8.0 |
| **width** | 512 | 1024 |
| **height** | 512 | 1024 |
</details>
## Troubleshooting
### Smart Error Diagnosis System
LobeChat integrates a smart error handling system that can automatically diagnose and provide targeted solutions.
#### Error Types and Solutions
| Error Type | User Prompt | Automatic Diagnosis |
| ------------------ | ---------------------------------- | --------------------------------------------------- |
| **Connection** | "Cannot connect to ComfyUI server" | Auto-detect server status and connectivity |
| **Authentication** | "API key invalid or expired" | Auto-verify authentication credentials |
| **Permissions** | "Access permissions insufficient" | Auto-check user permissions and file access |
| **Model Issues** | "Cannot find specified model file" | Auto-scan available models and suggest alternatives |
| **Configuration** | "Configuration file error" | Auto-verify config completeness and syntax |
<details>
<summary><b>🔍 Traditional Troubleshooting Methods</b></summary>
#### 1. Connection Failure
**Issue**: Cannot connect to ComfyUI server
**Solution**:
```bash
# Confirm server running
curl http://localhost:8188/system_stats
# Check port
netstat -tulpn | grep 8188
```
#### 2. Out of Memory
**Issue**: Memory errors during generation
**Solution**:
- Lower image resolution
- Reduce generation steps
- Use quantized models
#### 3. Authentication Failure
**Issue**: 401 or 403 errors
**Solution**:
- Verify authentication configuration
- Check if Token is expired
- Confirm user permissions
</details>
## Best Practices
### Prompt Writing
1. **Detailed Description**: Provide clear, detailed image descriptions
2. **Style Specification**: Clearly specify artistic style, color style, etc.
3. **Quality Keywords**: Add "4K", "high quality", "detailed" keywords
4. **Avoid Contradictions**: Ensure description content is logically consistent
**Example**:
```plaintext
A young woman with flowing long hair, wearing an elegant blue dress, standing in a cherry blossom park,
sunlight filtering through leaves, warm atmosphere, cinematic lighting, 4K high resolution, detailed, photorealistic
```
### Parameter Optimization
1. **FLUX Schnell**: Suitable for quick previews, use 4-step generation
2. **FLUX Dev**: Balance quality and speed, CFG 3.5, 20 steps
3. **FLUX Krea-dev**: Safe creation, CFG 4.5, note content filtering
4. **FLUX Kontext-dev**: Image editing, strength 0.6-0.9
<Callout type={'warning'}>
Please note during use:
- FLUX Dev, Krea-dev, Kontext-dev models are for non-commercial use only
- Generated content must comply with relevant laws and platform policies
- Large model generation may take considerable time, please be patient
</Callout>
## API Reference
<details>
<summary><b>📚 API Documentation</b></summary>
### Request Format
```typescript
interface ComfyUIRequest {
model: string; // Model ID, e.g., 'flux-schnell'
prompt: string; // Text prompt
width: number; // Image width
height: number; // Image height
steps: number; // Generation steps
seed: number; // Random seed
cfg?: number; // CFG Scale (Dev/Krea/Kontext specific)
strength?: number; // Edit strength (Kontext specific)
imageUrl?: string; // Input image (Kontext specific)
}
```
### Response Format
```typescript
interface ComfyUIResponse {
images: Array<{
url: string; // Generated image URL
filename: string; // Filename
subfolder: string; // Subdirectory
type: string; // File type
}>;
prompt_id: string; // Prompt ID
}
```
### Error Codes
| Error Code | Description | Resolution Suggestions |
| ---------- | ------------------------ | -------------------------------- |
| `400` | Invalid parameters | Check parameter format and range |
| `401` | Authentication failed | Verify API key and auth config |
| `403` | Insufficient permissions | Check user permissions |
| `404` | Model not found | Confirm model file exists |
| `500` | Server error | Check ComfyUI logs |
</details>
You can now use ComfyUI in LobeChat for high-quality AI image generation and editing. If you encounter issues, please refer to the troubleshooting section or consult the [ComfyUI official documentation](https://github.com/comfyanonymous/ComfyUI).

View file

@ -0,0 +1,816 @@
---
title: 在 LobeChat 中使用 ComfyUI 生成图像
description: 学习如何在 LobeChat 中配置和使用 ComfyUI 服务,支持 FLUX 系列模型的高质量图像生成和编辑功能
tags:
- ComfyUI
- FLUX
- 文生图
- 图像编辑
- AI 图像生成
---
# 在 LobeChat 中使用 ComfyUI
<Image alt={'在 LobeChat 中使用 ComfyUI'} cover src={'https://github.com/lobehub/lobe-chat/assets/17870709/c9e5eafc-ca22-496b-a88d-cc0ae53bf720'} />
本文档将指导你如何在 LobeChat 中使用 [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 进行高质量的 AI 图像生成和编辑。
## ComfyUI 简介
ComfyUI 是一个功能强大的稳定扩散和流扩散 GUI提供基于节点的工作流界面。LobeChat 集成了 ComfyUI支持完整的 FLUX 系列模型,包括文本生成图像和图像编辑功能。
### 主要特性
- **广泛模型支持**:支持 223 个模型,包含 FLUX 系列130 个)和 SD 系列93 个)
- **配置驱动架构**:注册表系统提供智能模型选择
- **多格式支持**:支持 .safetensors 和 .gguf 格式,包含多种量化级别
- **动态精度选择**:支持 default、fp8\_e4m3fn、fp8\_e5m2、fp8\_e4m3fn\_fast 精度
- **多种认证方式**支持无认证、基本认证、Bearer Token 和自定义认证
- **智能组件选择**:自动选择最优的 T5、CLIP、VAE 编码器组合
- **企业级优化**:包含 NF4、SVDQuant、TorchAO、MFLUX 等优化变体
## 快速开始
### 步骤一:在 LobeChat 中配置 ComfyUI
#### 1. 打开设置界面
- 访问 LobeChat 的 `设置` 界面
- 在 `AI 服务商` 下找到 `ComfyUI` 的设置项
<Image alt={'ComfyUI 设置界面'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/3f31bc33-509f-4ad2-ba81-280c2a6ec5fa'} />
#### 2. 配置连接参数
**基本配置**
- **服务器地址**:输入 ComfyUI 服务器地址,如 `http://localhost:8000`
- **认证类型**:选择合适的认证方式(默认无认证)
### 步骤二:选择模型并开始生成图像
#### 1. 选择 FLUX 模型
在对话界面中:
- 点击模型选择按钮
- 从 ComfyUI 分类中选择所需的 FLUX 模型
<Image alt={'选择 FLUX 模型'} inStep src={'https://github.com/lobehub/lobe-chat/assets/17870709/ff7ebacf-27f0-42d7-810b-00314499a084'} />
#### 2. 文本生成图像
**使用 FLUX Schnell快速生成**
```plaintext
Generate an image: A cute orange cat sitting on a sunny windowsill, warm lighting, detailed fur texture
```
**使用 FLUX Dev高质量生成**
```plaintext
Generate high quality image: City skyline at sunset, cyberpunk style, neon lights, 4K high resolution, detailed architecture
```
#### 3. 图像编辑
**使用 FLUX Kontext-dev 编辑图像**
```plaintext
Edit this image: Change the background to a starry night sky, keep the main subject, cosmic atmosphere
```
然后上传需要编辑的原始图像。
<Callout type={'info'}>
图像编辑功能需要先上传原始图像,然后描述你希望进行的修改。
</Callout>
## 认证配置指南
ComfyUI 支持四种认证方式,请根据你的服务器配置和安全需求选择合适的认证方式:
### 无认证 (none)
**适用场景**
- 本地开发环境localhost
- 内网环境且信任所有用户
- 个人使用的单机部署
**配置方法**
```yaml
认证类型:无认证
服务器地址http://localhost:8000
```
### 基本认证 (basic)
**适用场景**
- 使用 Nginx 反向代理的部署
- 团队内部使用且需要基础访问控制
**配置方法**
1. **创建用户密码**
```bash
# 安装 apache2-utils
sudo apt-get install apache2-utils
# 创建用户 'admin'
sudo htpasswd -c /etc/nginx/.htpasswd admin
```
2. **LobeChat 配置**
```yaml
认证类型:基本认证
服务器地址https://your-domain.com
用户名admin
密码your_secure_password
```
### Bearer Token (bearer)
**适用场景**
- API 驱动的应用集成
- 需要 Token 认证的企业环境
**生成 Token**
```python
import jwt
import datetime
payload = {
'user': 'admin',
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=30)
}
secret_key = "your-secret-key"
token = jwt.encode(payload, secret_key, algorithm='HS256')
print(f"Bearer Token: {token}")
```
**LobeChat 配置**
```yaml
认证类型Bearer Token
服务器地址https://your-domain.com
API 密钥example-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### 自定义认证 (custom)
**适用场景**
- 集成现有企业认证系统
- 需要多重认证头的系统
**LobeChat 配置**
```yaml
认证类型:自定义
服务器地址https://your-domain.com
自定义请求头:
{
"X-API-Key": "your_api_key",
"X-Client-ID": "lobechat"
}
```
## 常见问题处理
### 1. 如何安装 Comfy-Manager
Comfy-Manager 是 ComfyUI 的扩展管理器,让你能够轻松安装和管理各种节点、模型和扩展。
<details>
<summary><b>📦 安装 Comfy-Manager 步骤</b></summary>
#### 方法一:手动安装(推荐)
```bash
# 进入 ComfyUI 的 custom_nodes 目录
cd ComfyUI/custom_nodes
# 克隆 Comfy-Manager 仓库
git clone https://github.com/ltdrdata/ComfyUI-Manager.git
# 重启 ComfyUI 服务器
# 重新启动后,你会在 UI 中看到 Manager 按钮
```
#### 方法二:使用一键安装脚本
```bash
# 在 ComfyUI 根目录下执行
curl -fsSL https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/install.sh | bash
```
#### 验证安装
1. 重启 ComfyUI 服务器
2. 访问 `http://localhost:8000`
3. 你应该能在界面右下角看到 "Manager" 按钮
#### 使用 Comfy-Manager
**安装模型**
1. 点击 "Manager" 按钮
2. 选择 "Install Models"
3. 搜索需要的模型(如 FLUX、SD3.5
4. 点击 "Install" 自动下载到正确目录
**安装节点扩展**
1. 点击 "Manager" 按钮
2. 选择 "Install Custom Nodes"
3. 搜索需要的节点(如 ControlNet、AnimateDiff
4. 点击 "Install" 并重启服务器
**管理已安装内容**
1. 点击 "Manager" 按钮
2. 选择 "Installed" 查看已安装的扩展
3. 可以更新、禁用或卸载扩展
</details>
### 2. 如何处理 "Model not found" 错误
当你看到类似 `Model not found: flux1-dev.safetensors, please install one first.` 的错误时,说明服务器上缺少所需的模型文件。
<details>
<summary><b>🔧 解决 Model not found 错误</b></summary>
#### 错误示例
```plaintext
Model not found: flux1-dev.safetensors, please install one first.
```
这个错误表示系统期望找到 `flux1-dev.safetensors` 模型文件,但在服务器上没有找到。
#### 解决方法
**方法一:使用 Comfy-Manager 下载(推荐)**
1. 打开 ComfyUI 界面
2. 点击 "Manager" → "Install Models"
3. 搜索错误提示中的模型名(如 "flux1-dev"
4. 点击 "Install" 自动下载
**方法二:手动下载模型**
1. **下载模型文件**
- 访问 [Hugging Face](https://huggingface.co/black-forest-labs/FLUX.1-dev) 或其他模型源
- 下载错误提示中的文件(如 `flux1-dev.safetensors`
2. **放置到正确目录**
```bash
# FLUX 和 SD3.5 主模型放入
ComfyUI/models/diffusion_models/flux1-dev.safetensors
# SD1.5 和 SDXL 模型放入
ComfyUI/models/checkpoints/
```
3. **验证文件**
```bash
# 检查文件是否存在
ls -la ComfyUI/models/diffusion_models/flux1-dev.safetensors
# 检查文件完整性(可选)
sha256sum flux1-dev.safetensors
```
4. **重启 ComfyUI 服务器**
**方法三:使用 wget/curl 直接下载**
```bash
# 进入模型目录
cd ComfyUI/models/diffusion_models/
# 使用 wget 下载(替换为实际下载链接)
wget https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors
# 或使用 curl
curl -L -o flux1-dev.safetensors https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors
```
#### 常见模型下载源
- **Hugging Face**[https://huggingface.co/models](https://huggingface.co/models)
- **Civitai**[https://civitai.com/models](https://civitai.com/models)
- **官方源**
- FLUX: [https://huggingface.co/black-forest-labs](https://huggingface.co/black-forest-labs)
- SD3.5: [https://huggingface.co/stabilityai](https://huggingface.co/stabilityai)
#### 预防措施
1. **基础模型包**:至少下载一个基础模型
- FLUX: `flux1-schnell.safetensors`(快速)或 `flux1-dev.safetensors`(高质量)
- SD3.5: `sd3.5_large.safetensors`
2. **检查磁盘空间**
```bash
# 检查可用空间
df -h ComfyUI/models/
```
3. **设置模型路径**(可选):
如果你的模型存储在其他位置,可以创建符号链接:
```bash
ln -s /path/to/your/models ComfyUI/models/diffusion_models/
```
</details>
### 3. 如何处理缺少 System Component 错误
当你看到类似 `Missing VAE encoder: ae.safetensors` 或其他组件文件缺失的错误时,需要下载相应的系统组件。
<details>
<summary><b>🛠️ 解决缺少 System Component 错误</b></summary>
#### 常见组件错误
```plaintext
Missing VAE encoder: ae.safetensors. Please download and place it in the models/vae folder.
Missing CLIP encoder: clip_l.safetensors. Please download and place it in the models/clip folder.
Missing T5 encoder: t5xxl_fp16.safetensors. Please download and place it in the models/clip folder.
```
#### 组件类型说明
| 组件类型 | 文件名示例 | 用途 | 存放目录 |
| -------------- | ------------------------------ | ---------- | ------------------ |
| **VAE** | ae.safetensors | 图像编码 / 解码 | models/vae/ |
| **CLIP** | clip\_l.safetensors | 文本编码CLIP | models/clip/ |
| **T5** | t5xxl\_fp16.safetensors | 文本编码T5 | models/clip/ |
| **ControlNet** | flux-controlnet-\*.safetensors | 控制网络 | models/controlnet/ |
#### 解决方法
**方法一:使用 Comfy-Manager推荐**
1. 点击 "Manager" → "Install Models"
2. 在 "Filter" 中选择组件类型VAE/CLIP/T5
3. 下载对应的组件文件
**方法二:手动下载必需组件**
##### FLUX 必需组件
```bash
# 1. VAE 编码器
cd ComfyUI/models/vae/
wget https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/ae.safetensors
# 2. CLIP-L 编码器
cd ComfyUI/models/clip/
wget https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors
# 3. T5-XXL 编码器(可选择不同精度)
# FP16 版本(推荐,平衡性能)
wget https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors
# 或 FP8 版本(节省显存)
wget https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp8_e4m3fn.safetensors
```
##### SD3.5 必需组件
```bash
# SD3.5 使用不同的编码器
cd ComfyUI/models/clip/
# CLIP-G 编码器
wget https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/text_encoders/clip_g.safetensors
# CLIP-L 编码器
wget https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/text_encoders/clip_l.safetensors
# T5-XXL 编码器
wget https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/text_encoders/t5xxl_fp16.safetensors
```
##### SDXL 必需组件
```bash
# SDXL VAE
cd ComfyUI/models/vae/
wget https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors
# SDXL 使用内置的 CLIP 编码器,通常不需要单独下载
```
#### 组件兼容性矩阵
| 模型系列 | 必需 VAE | 必需 CLIP | 必需 T5 | 可选组件 |
| --------- | -------------- | ------------------- | ----------------------- | ---------- |
| **FLUX** | ae.safetensors | clip\_l.safetensors | t5xxl\_fp16.safetensors | ControlNet |
| **SD3.5** | 内置 | clip\_g + clip\_l | t5xxl\_fp16 | - |
| **SDXL** | sdxl\_vae | 内置 | - | Refiner |
| **SD1.5** | vae-ft-mse | 内置 | - | ControlNet |
#### 精度选择建议
**T5 编码器精度选择**
| 显存容量 | 推荐版本 | 文件名 |
| ------- | ------ | ------------------------------ |
| \< 12GB | FP8 量化 | t5xxl\_fp8\_e4m3fn.safetensors |
| 12-16GB | FP16 | t5xxl\_fp16.safetensors |
| > 16GB | FP32 | t5xxl.safetensors |
#### 验证组件安装
```bash
# 检查所有必需组件
echo "=== VAE Components ==="
ls -la ComfyUI/models/vae/
echo "=== CLIP/T5 Components ==="
ls -la ComfyUI/models/clip/
echo "=== ControlNet Components ==="
ls -la ComfyUI/models/controlnet/
```
#### 故障排除
**问题:下载后仍然报错**
1. **检查文件权限**
```bash
chmod 644 ComfyUI/models/vae/*.safetensors
chmod 644 ComfyUI/models/clip/*.safetensors
```
2. **清除缓存**
```bash
# 清除 ComfyUI 缓存
rm -rf ComfyUI/temp/*
rm -rf ComfyUI/__pycache__/*
```
3. **重启服务器**
```bash
# 完全重启 ComfyUI
pkill -f "python main.py"
python main.py --listen 0.0.0.0 --port 8000
```
**问题:显存不足**
使用量化版本的组件:
- T5: 使用 `t5xxl_fp8_e4m3fn.safetensors` 而不是 FP16/FP32
- VAE: 某些模型支持 FP16 VAE 版本
**问题:下载速度慢**
1. 使用镜像源(如适用)
2. 使用下载工具(如 aria2c支持断点续传
```bash
aria2c -x 16 -s 16 -k 1M [下载链接]
```
</details>
## ComfyUI 服务器安装
<details>
<summary><b>🚀 安装和配置 ComfyUI 服务器</b></summary>
### 1. 安装 ComfyUI
```bash
# 克隆 ComfyUI 仓库
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
# 安装依赖
pip install -r requirements.txt
# 可选安装JWT支持用于Token认证
pip install PyJWT
# 启动 ComfyUI 服务器
python main.py --listen 0.0.0.0 --port 8000
```
### 2. 下载模型文件
**推荐基础配置** (最小化安装)
**主模型** (放入 `models/diffusion_models/` 目录)
- `flux1-schnell.safetensors` - 快速生成4 步)
- `flux1-dev.safetensors` - 高质量创作20 步)
**必需组件** (放入相应目录)
- `models/vae/ae.safetensors` - VAE 编码器
- `models/clip/clip_l.safetensors` - CLIP 文本编码器
- `models/clip/t5xxl_fp16.safetensors` - T5 文本编码器
### 3. 验证服务器运行
访问 `http://localhost:8000` 确认 ComfyUI 界面正常加载。
<Callout type={'info'}>
**智能模型选择**LobeChat 会根据服务器上可用的模型文件自动选择最佳模型。您无需下载所有模型,系统会在可用模型中按优先级(官方 > 企业 > 社区)自动选择。
</Callout>
</details>
## 支持的模型
LobeChat ComfyUI 集成采用配置驱动的架构,支持 **223 个模型**,提供从官方模型到社区优化版本的全覆盖。
### FLUX 系列推荐参数
| 模型类型 | 推荐步数 | CFG Scale | 分辨率范围 |
| ----------- | ---- | --------- | ------------------- |
| **Schnell** | 4 步 | - | 512×512 至 1536×1536 |
| **Dev** | 20 步 | 3.5 | 512×512 至 2048×2048 |
| **Kontext** | 20 步 | 3.5 | 512×512 至 2048×2048 |
| **Krea** | 20 步 | 4.5 | 512×512 至 2048×2048 |
### SD3.5 系列参数
| 模型类型 | 推荐步数 | CFG Scale | 分辨率范围 |
| --------------- | ---- | --------- | ------------------- |
| **Large** | 25 步 | 7.0 | 512×512 至 2048×2048 |
| **Large Turbo** | 8 步 | 3.5 | 512×512 至 1536×1536 |
| **Medium** | 20 步 | 6.0 | 512×512 至 1536×1536 |
<details>
<summary><b>📋 当前完整支持的模型列表</b></summary>
### 模型分类体系
#### 优先级 1官方核心模型
**FLUX.1 Official 系列**
- `flux1-dev.safetensors` - 高质量创作模型
- `flux1-schnell.safetensors` - 快速生成模型
- `flux1-kontext-dev.safetensors` - 图像编辑模型
- `flux1-krea-dev.safetensors` - 安全增强模型
**SD3.5 Official 系列**
- `sd3.5_large.safetensors` - SD3.5 大型基础模型
- `sd3.5_large_turbo.safetensors` - 快速生成版本
- `sd3.5_medium.safetensors` - 中等规模模型
#### 优先级 2企业优化模型106 个 FLUX
**量化优化系列**
- **GGUF 量化**:每个变体支持 11 种量化级别F16, Q8\_0, Q6\_K, Q5\_K\_M, Q5\_K\_S, Q4\_K\_M, Q4\_K\_S, Q4\_0, Q3\_K\_M, Q3\_K\_S, Q2\_K
- **FP8 精度**fp8\_e4m3fn、fp8\_e5m2 优化版本
- **企业轻量级**FLUX.1-lite-8B 系列
- **技术实验**NF4、SVDQuant、TorchAO、optimum-quanto、MFLUX 优化版本
#### 优先级 3社区精调模型48 个 FLUX
**社区优化系列**
- **Jib Mix Flux** 系列:高质量混合模型
- **Real Dream FLUX** 系列:现实主义风格
- **Vision Realistic** 系列:视觉现实化
- **PixelWave FLUX** 系列:像素艺术优化
- **Fluxmania** 系列:多样化风格支持
### SD 系列模型支持93 个)
**SD3.5 系列**5 个模型
**SD1.5 系列**37 个模型(包括官方、量化和社区版本)
**SDXL 系列**50 个模型包括基础、Refiner 和 Playground 模型)
### 工作流支持
系统支持 **6 种工作流**
- **flux-dev**:高质量创作工作流
- **flux-schnell**:快速生成工作流
- **flux-kontext**:图像编辑工作流
- **sd35**SD3.5 专用工作流
- **simple-sd**:简单 SD 工作流
- **index**:工作流入口
</details>
## 性能优化建议
### 硬件要求
**最低配置** (GGUF 量化模型)
- GPU6GB VRAM (使用 Q4 量化)
- RAM12GB
- 存储30GB 可用空间
**推荐配置** (标准模型)
- GPU12GB+ VRAM (RTX 4070 Ti 或更高)
- RAM24GB+
- 存储SSD 100GB+ 可用空间
### 显存优化策略
| 显存容量 | 推荐量化 | 模型示例 | 性能特点 |
| ----------- | --------------- | ---------------------------------- | ------- |
| **6-8GB** | Q4\_0, Q4\_K\_S | `flux1-dev-Q4_0.gguf` | 最小显存占用 |
| **10-12GB** | Q6\_K, Q8\_0 | `flux1-dev-Q6_K.gguf` | 平衡性能与质量 |
| **16GB+** | FP8, FP16 | `flux1-dev-fp8-e4m3fn.safetensors` | 接近原始质量 |
| **24GB+** | 完整模型 | `flux1-dev.safetensors` | 最佳质量 |
## 自定义模型使用
<details>
<summary><b>🎨 配置自定义 SD 模型</b></summary>
LobeChat 支持使用自定义的 Stable Diffusion 模型。系统使用固定的文件名来识别自定义模型。
### 1. 模型文件准备
**必需文件**
- **主模型文件**`custom_sd_lobe.safetensors`
- **VAE 文件(可选)**`custom_sd_vae_lobe.safetensors`
### 2. 添加自定义模型
**方法一:重命名现有模型**
```bash
# 将您的模型重命名为固定文件名
mv your_custom_model.safetensors custom_sd_lobe.safetensors
# 移动到正确目录
mv custom_sd_lobe.safetensors ComfyUI/models/diffusion_models/
```
**方法二:创建符号链接(推荐)**
```bash
# 创建软链接,方便切换不同模型
ln -s /path/to/your_model.safetensors ComfyUI/models/diffusion_models/custom_sd_lobe.safetensors
```
### 3. 使用自定义模型
在 LobeChat 中,自定义模型会显示为:
- **stable-diffusion-custom**:标准自定义模型
- **stable-diffusion-custom-refiner**Refiner 自定义模型
### 自定义模型参数建议
| 参数 | SD 1.5 模型 | SDXL 模型 |
| ---------- | --------- | ------- |
| **steps** | 20-30 | 25-40 |
| **cfg** | 7.0 | 6.0-8.0 |
| **width** | 512 | 1024 |
| **height** | 512 | 1024 |
</details>
## 故障排除
### 智能错误诊断系统
LobeChat 集成了智能错误处理系统,能够自动诊断并提供针对性的解决方案。
#### 错误类型与解决方案
| 错误类型 | 用户提示 | 自动诊断 |
| -------- | ------------------- | --------------- |
| **连接问题** | "无法连接到 ComfyUI 服务器" | 自动检测服务器状态和网络连通性 |
| **认证问题** | "API 密钥无效或已过期" | 自动验证认证凭据有效性 |
| **权限问题** | "访问权限不足" | 自动检查用户权限和文件访问权限 |
| **模型问题** | "找不到指定的模型文件" | 自动扫描可用模型并建议替代方案 |
| **配置问题** | "配置文件存在错误" | 自动验证配置完整性和语法正确性 |
<details>
<summary><b>🔍 传统故障排除方法</b></summary>
#### 1. 连接失败
**问题**:无法连接到 ComfyUI 服务器
**解决方案**
```bash
# 确认服务器运行
curl http://localhost:8000/system_stats
# 检查端口
netstat -tulpn | grep 8000
```
#### 2. 内存不足
**问题**:生成过程中出现内存错误
**解决方案**
- 降低图像分辨率
- 减少生成步数
- 使用量化模型
#### 3. 认证失败
**问题**401 或 403 错误
**解决方案**
- 验证认证配置
- 检查 Token 是否过期
- 确认用户权限
</details>
## 最佳实践
### 提示词编写
1. **详细描述**:提供清晰、详细的图像描述
2. **风格指定**:明确指定艺术风格、色彩风格等
3. **质量关键词**:添加 "4K", "high quality", "detailed" 等关键词
4. **避免矛盾**:确保描述内容逻辑一致
**示例**
```plaintext
A young woman with flowing long hair, wearing an elegant blue dress, standing in a cherry blossom park,
sunlight filtering through leaves, warm atmosphere, cinematic lighting, 4K high resolution, detailed, photorealistic
```
### 参数调优
1. **FLUX Schnell**:适合快速预览,使用 4 步生成
2. **FLUX Dev**平衡质量和速度CFG 3.5,步数 20
3. **FLUX Krea-dev**安全创作CFG 4.5,注意内容过滤
4. **FLUX Kontext-dev**图像编辑strength 0.6-0.9
<Callout type={'warning'}>
在使用过程中请注意:
- FLUX Dev、Krea-dev、Kontext-dev 模型仅限非商业使用
- 生成内容请遵守相关法律法规和平台政策
- 大型模型生成可能需要较长时间,请耐心等待
</Callout>
## API 参考
<details>
<summary><b>📚 API 文档</b></summary>
### 请求格式
```typescript
interface ComfyUIRequest {
model: string; // 模型 ID如 'flux-schnell'
prompt: string; // 文本提示词
width: number; // 图像宽度
height: number; // 图像高度
steps: number; // 生成步数
seed: number; // 随机种子
cfg?: number; // CFG Scale (Dev/Krea/Kontext 专用)
strength?: number; // 编辑强度 (Kontext 专用)
imageUrl?: string; // 输入图像 (Kontext 专用)
}
```
### 响应格式
```typescript
interface ComfyUIResponse {
images: Array<{
url: string; // 生成的图像 URL
filename: string; // 文件名
subfolder: string; // 子目录
type: string; // 文件类型
}>;
prompt_id: string; // 提示 ID
}
```
### 错误代码
| 错误代码 | 描述 | 解决建议 |
| ----- | ------ | -------------- |
| `400` | 请求参数无效 | 检查参数格式和范围 |
| `401` | 认证失败 | 验证 API 密钥和认证配置 |
| `403` | 权限不足 | 检查用户权限 |
| `404` | 模型未找到 | 确认模型文件存在 |
| `500` | 服务器错误 | 检查 ComfyUI 日志 |
</details>
至此你已经可以在 LobeChat 中使用 ComfyUI 进行高质量的 AI 图像生成和编辑了。如果遇到问题,请参考故障排除部分或查阅 [ComfyUI 官方文档](https://github.com/comfyanonymous/ComfyUI)。

View file

@ -82,6 +82,58 @@
"title": "Cloudflare Account ID / API Address"
}
},
"comfyui": {
"apiKey": {
"desc": "API key for Bearer Token authentication",
"placeholder": "Enter API key",
"title": "API Key"
},
"authType": {
"desc": "Choose authentication method for ComfyUI server",
"options": {
"basic": "Basic Authentication",
"bearer": "Bearer Token",
"custom": "Custom Authentication",
"none": "No Authentication"
},
"placeholder": "Select authentication method",
"title": "Authentication Type"
},
"baseURL": {
"desc": "ComfyUI server access address, e.g., http://localhost:8000",
"placeholder": "http://localhost:8000",
"title": "Server Address"
},
"checker": {
"desc": "Test whether the ComfyUI server can connect normally",
"title": "Connection Test"
},
"customHeaders": {
"addButton": "Add Header",
"deleteTooltip": "Delete this header",
"desc": "Custom HTTP request headers for custom authentication, in key-value format",
"duplicateKeyError": "Header names cannot be duplicated",
"keyPlaceholder": "Header name",
"title": "Custom Headers",
"valuePlaceholder": "Header value"
},
"password": {
"desc": "Password for basic authentication",
"placeholder": "Enter password",
"title": "Password"
},
"title": "ComfyUI",
"unlock": {
"customAuth": "Custom Authentication",
"description": "Configure ComfyUI server connection information to start image generation",
"title": "Use ComfyUI Image Generation"
},
"username": {
"desc": "Username for basic authentication",
"placeholder": "Enter username",
"title": "Username"
}
},
"createNewAiProvider": {
"apiKey": {
"placeholder": "Please enter your API Key",

View file

@ -82,6 +82,58 @@
"title": "Cloudflare 账户 ID / API 地址"
}
},
"comfyui": {
"apiKey": {
"desc": "Bearer Token 认证所需的 API 密钥",
"placeholder": "请输入 API 密钥",
"title": "API 密钥"
},
"authType": {
"desc": "选择 ComfyUI 服务器的认证方式",
"options": {
"basic": "账号/密码",
"bearer": "Bearer (API 密钥)",
"custom": "自定义请求头",
"none": "无需认证"
},
"placeholder": "请选择认证方式",
"title": "认证类型"
},
"baseURL": {
"desc": "ComfyUI 网页访问地址",
"placeholder": "http://localhost:8000",
"title": "访问地址"
},
"checker": {
"desc": "测试 ComfyUI 服务器是否可以正常连接",
"title": "连接测试"
},
"customHeaders": {
"addButton": "添加请求头",
"deleteTooltip": "删除此请求头",
"desc": "自定义认证方式下的HTTP请求头格式为键值对",
"duplicateKeyError": "请求头键名不能重复",
"keyPlaceholder": "请求头键名",
"title": "自定义请求头",
"valuePlaceholder": "请求头值"
},
"password": {
"desc": "基本认证所需的密码",
"placeholder": "请输入密码",
"title": "密码"
},
"title": "ComfyUI",
"unlock": {
"customAuth": "自定义认证",
"description": "配置 ComfyUI 服务器连接信息即可开始图像生成",
"title": "使用 ComfyUI 图像生成"
},
"username": {
"desc": "基本认证所需的用户名",
"placeholder": "请输入用户名",
"title": "用户名"
}
},
"createNewAiProvider": {
"apiKey": {
"placeholder": "请填写你的 API Key",

View file

@ -866,6 +866,46 @@
"cohere/embed-v4.0": {
"description": "一个允许对文本、图像或混合内容进行分类或转换为嵌入的模型。"
},
"comfyui/flux-dev": {
"description": "FLUX.1 Dev - 高质量文生图模型,支持 guidance scale 调节10-50步生成非商业许可适合高质量创作和艺术作品生成"
},
"comfyui/flux-kontext-dev": {
"description": "FLUX.1 Kontext-dev - 图像编辑模型,支持基于文本指令修改现有图像,支持局部修改和风格迁移,非商业许可"
},
"comfyui/flux-krea-dev": {
"description": "FLUX.1 Krea-dev - 增强安全的文生图模型,与 Krea 合作开发,内置安全过滤,避免生成不当内容,非商业许可"
},
"comfyui/flux-schnell": {
"description": "FLUX.1 Schnell - 超快速文生图模型1-4步即可生成高质量图像Apache 2.0开源许可,适合实时应用和快速原型制作"
},
"comfyui/stable-diffusion-15": {
"displayName": "SD 1.5",
"description": "Stable Diffusion 1.5 文生图模型经典的512x512分辨率文本到图像生成适合快速原型和创意实验。支持负向提示。"
},
"comfyui/stable-diffusion-35": {
"displayName": "Stable Diffusion 3.5",
"description": "Stable Diffusion 3.5 新一代文生图模型,支持 Large 和 Medium 两个版本,需要外部 CLIP 编码器文件,提供卓越的图像质量和提示词匹配度。"
},
"comfyui/stable-diffusion-35-inclclip": {
"displayName": "Stable Diffusion 3.5 (内置编码器)",
"description": "Stable Diffusion 3.5 内置 CLIP/T5 编码器版本,无需外部编码器文件,适用于 sd3.5_medium_incl_clips 等模型,资源占用更少。"
},
"comfyui/stable-diffusion-custom": {
"displayName": "Custom SD",
"description": "自定义 SD 文生图模型,模型文件名请使用 custom_sd_lobe.safetensors如有 VAE 请使用 custom_sd_vae_lobe.safetensors模型文件需要按照 Comfy 的要求放入对应文件夹。"
},
"comfyui/stable-diffusion-custom-refiner": {
"displayName": "Custom SD Refiner",
"description": "自定义 SD 图生图模型,模型文件名请使用 custom_sd_lobe.safetensors如有 VAE 请使用 custom_sd_vae_lobe.safetensors模型文件需要按照 Comfy 的要求放入对应文件夹。"
},
"comfyui/stable-diffusion-refiner": {
"displayName": "SDXL Image-to-Image",
"description": "SDXL 图生图模型,基于输入图像进行高质量的图像到图像转换,支持风格迁移、图像修复和创意变换。"
},
"comfyui/stable-diffusion-xl": {
"displayName": "SDXL Text-to-Image",
"description": "SDXL 文生图模型支持1024x1024高分辨率文本到图像生成提供更好的图像质量和细节表现。支持负向提示。"
},
"command": {
"description": "一个遵循指令的对话模型,在语言任务中表现出高质量、更可靠,并且相比我们的基础生成模型具有更长的上下文长度。"
},
@ -3017,6 +3057,9 @@
"sonar-reasoning-pro": {
"description": "支持搜索上下文的高级搜索产品,支持高级查询和跟进。"
},
"stable-diffusion-15": {
"description": "Stable Diffusion 1.5 文生图模型经典的512x512分辨率文本到图像生成适合快速原型和创意实验。支持负向提示。"
},
"stable-diffusion-3-medium": {
"description": "由 Stability AI 推出的最新文生图大模型。这一版本在继承了前代的优点上,对图像质量、文本理解和风格多样性等方面进行了显著改进,能够更准确地解读复杂的自然语言提示,并生成更为精确和多样化的图像。"
},
@ -3026,6 +3069,15 @@
"stable-diffusion-3.5-large-turbo": {
"description": "stable-diffusion-3.5-large-turbo 是在 stable-diffusion-3.5-large 的基础上采用对抗性扩散蒸馏ADD技术的模型具备更快的速度。"
},
"stable-diffusion-custom": {
"description": "自定义 SD 文生图模型,支持社区和第三方训练的 Stable Diffusion 文本到图像模型,提供灵活的参数配置。"
},
"stable-diffusion-custom-refiner": {
"description": "自定义 SD 图生图模型,支持社区和第三方训练的 Stable Diffusion 图像到图像模型,适合专业图像处理工作流。"
},
"stable-diffusion-refiner": {
"description": "SDXL 图生图模型,基于输入图像进行高质量的图像到图像转换,支持风格迁移、图像修复和创意变换。"
},
"stable-diffusion-v1.5": {
"description": "stable-diffusion-v1.5 是以 stable-diffusion-v1.2 检查点的权重进行初始化,并在 \"laion-aesthetics v2 5+\" 上以 512x512 的分辨率进行了595k步的微调减少了 10% 的文本条件化,以提高无分类器的引导采样。"
},

View file

@ -44,6 +44,9 @@
"cometapi": {
"description": "CometAPI 是一个提供多种前沿大模型接口的服务平台,支持 OpenAI、Anthropic、Google 及更多适合多样化的开发和应用需求。用户可根据自身需求灵活选择最优的模型和价格助力AI体验的提升。"
},
"comfyui": {
"description": "强大的开源图像、视频、音频生成工作流引擎,支持 SD FLUX Qwen Hunyuan WAN 等先进模型,提供节点化工作流编辑和私有化部署能力"
},
"deepseek": {
"description": "DeepSeek 是一家专注于人工智能技术研究和应用的公司,其最新模型 DeepSeek-V3 多项评测成绩超越 Qwen2.5-72B 和 Llama-3.1-405B 等开源模型,性能对齐领军闭源模型 GPT-4o 与 Claude-3.5-Sonnet。"
},

View file

@ -175,6 +175,7 @@
"@opentelemetry/exporter-jaeger": "^2.1.0",
"@opentelemetry/winston-transport": "^0.17.0",
"@react-spring/web": "^9.7.5",
"@saintno/comfyui-sdk": "^0.2.48",
"@serwist/next": "^9.2.1",
"@t3-oss/env-nextjs": "^0.13.8",
"@tanstack/react-query": "^5.90.2",

View file

@ -19,6 +19,7 @@
"./cloudflare": "./src/aiModels/cloudflare.ts",
"./cohere": "./src/aiModels/cohere.ts",
"./cometapi": "./src/aiModels/cometapi.ts",
"./comfyui": "./src/aiModels/comfyui.ts",
"./deepseek": "./src/aiModels/deepseek.ts",
"./fal": "./src/aiModels/fal.ts",
"./fireworksai": "./src/aiModels/fireworksai.ts",

View file

@ -0,0 +1,335 @@
import { ModelParamsSchema, PRESET_ASPECT_RATIOS } from '../standard-parameters';
import { AIImageModelCard } from '../types';
/**
* Aspect ratios supported by FLUX models
* Support wide range ratios from 21:9 to 9:21, including foldable screen devices
*/
const FLUX_ASPECT_RATIOS = [
'21:9', // Ultra-wide screen
'16:9', // Widescreen
'8:7', // Foldable screen (e.g. Galaxy Z Fold, unfolded state ~7.6 inch)
'4:3', // Traditional landscape
'3:2', // Classic landscape
'1:1', // Square
'2:3', // Classic portrait
'3:4', // Traditional portrait
'7:8', // Foldable screen portrait
'9:16', // Portrait
'9:21', // Ultra-tall portrait
];
/**
* Standard aspect ratios supported by SD models
* Based on preset aspect ratios, suitable for traditional SD model use cases
*/
const SD_ASPECT_RATIOS = PRESET_ASPECT_RATIOS;
/**
* Extended aspect ratios supported by SDXL models
* Support more modern display ratios, similar to FLUX but more conservative
*/
const SDXL_ASPECT_RATIOS = [
'16:9', // Modern widescreen
'4:3', // Traditional landscape
'3:2', // Classic landscape
'1:1', // Square
'2:3', // Classic portrait
'3:4', // Traditional portrait
'9:16', // Modern portrait
];
/**
* FLUX.1 Schnell model parameter configuration
* Ultra-fast text-to-image mode, generates in 1-4 steps, Apache 2.0 license
*/
export const fluxSchnellParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: FLUX_ASPECT_RATIOS,
},
cfg: { default: 1, max: 1, min: 1, step: 0 }, // Schnell uses fixed CFG of 1
height: { default: 1024, max: 1536, min: 512, step: 8 },
prompt: { default: '' },
samplerName: { default: 'euler' },
scheduler: { default: 'simple' },
seed: { default: null },
steps: { default: 4, max: 4, min: 1, step: 1 },
width: { default: 1024, max: 1536, min: 512, step: 8 },
};
/**
* FLUX.1 Dev model parameter configuration
* High-quality text-to-image mode, supports guidance scale adjustment, non-commercial license
*/
export const fluxDevParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: FLUX_ASPECT_RATIOS,
},
cfg: { default: 3.5, max: 10, min: 1, step: 0.5 },
height: { default: 1024, max: 2048, min: 512, step: 8 },
prompt: { default: '' },
samplerName: { default: 'euler' },
scheduler: { default: 'simple' },
seed: { default: null },
steps: { default: 20, max: 50, min: 10, step: 1 },
width: { default: 1024, max: 2048, min: 512, step: 8 },
};
/**
* FLUX.1 Krea-dev model parameter configuration
* Enhanced safety text-to-image mode, developed in collaboration with Krea, non-commercial license
*/
export const fluxKreaDevParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: FLUX_ASPECT_RATIOS,
},
cfg: { default: 3.5, max: 10, min: 1, step: 0.5 },
height: { default: 1024, max: 2048, min: 512, step: 8 },
prompt: { default: '' },
samplerName: { default: 'dpmpp_2m_sde' },
scheduler: { default: 'karras' },
seed: { default: null },
steps: { default: 15, max: 50, min: 10, step: 1 },
width: { default: 1024, max: 2048, min: 512, step: 8 },
};
/**
* FLUX.1 Kontext-dev model parameter configuration
* Image editing mode, supports modifying existing images based on text instructions, non-commercial license
*/
export const fluxKontextDevParamsSchema: ModelParamsSchema = {
cfg: { default: 3.5, max: 10, min: 1, step: 0.5 },
imageUrl: { default: '' }, // Input image URL (supports text-to-image and image-to-image)
prompt: { default: '' },
seed: { default: null },
steps: { default: 28, max: 50, min: 10, step: 1 }, // Kontext defaults to 28 steps
strength: { default: 0.85, max: 1, min: 0, step: 0.05 }, // Image editing strength control (frontend parameter)
};
/**
* SD3.5 model parameter configuration
* Stable Diffusion 3.5, supports Large and Medium versions, automatically selects by priority
*/
export const sd35ParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: FLUX_ASPECT_RATIOS, // SD3.5 also supports multiple aspect ratios
},
cfg: { default: 4, max: 20, min: 1, step: 0.5 },
height: { default: 1024, max: 2048, min: 512, step: 8 },
prompt: { default: '' },
samplerName: { default: 'euler' },
scheduler: { default: 'sgm_uniform' },
seed: { default: null },
steps: { default: 20, max: 50, min: 10, step: 1 },
width: { default: 1024, max: 2048, min: 512, step: 8 },
};
/**
* SD1.5 text-to-image model parameter configuration
* Stable Diffusion 1.5 text-to-image generation, suitable for 512x512 resolution
*/
export const sd15T2iParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: SD_ASPECT_RATIOS,
},
cfg: { default: 7, max: 20, min: 1, step: 0.5 },
height: { default: 512, max: 1024, min: 256, step: 8 },
prompt: { default: '' },
samplerName: { default: 'euler' },
scheduler: { default: 'normal' },
seed: { default: null },
steps: { default: 25, max: 50, min: 10, step: 1 },
width: { default: 512, max: 1024, min: 256, step: 8 },
};
/**
* SDXL text-to-image model parameter configuration
* SDXL text-to-image generation, suitable for 1024x1024 resolution
*/
export const sdxlT2iParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: SDXL_ASPECT_RATIOS,
},
cfg: { default: 8, max: 20, min: 1, step: 0.5 },
height: { default: 1024, max: 2048, min: 512, step: 8 },
prompt: { default: '' },
samplerName: { default: 'euler' },
scheduler: { default: 'normal' },
seed: { default: null },
steps: { default: 30, max: 50, min: 10, step: 1 },
width: { default: 1024, max: 2048, min: 512, step: 8 },
};
/**
* SDXL image-to-image model parameter configuration
* SDXL image-to-image generation, supports input image modification
*/
export const sdxlI2iParamsSchema: ModelParamsSchema = {
cfg: { default: 8, max: 20, min: 1, step: 0.5 },
imageUrl: { default: '' }, // Input image URL
prompt: { default: '' },
samplerName: { default: 'euler' },
scheduler: { default: 'normal' },
seed: { default: null },
steps: { default: 30, max: 50, min: 10, step: 1 },
strength: { default: 0.75, max: 1, min: 0, step: 0.05 }, // Image modification strength (frontend parameter)
};
/**
* Custom SD text-to-image model parameter configuration
* Custom Stable Diffusion text-to-image model with flexible parameter settings
*/
export const customSdT2iParamsSchema: ModelParamsSchema = {
aspectRatio: {
default: '1:1',
enum: SDXL_ASPECT_RATIOS, // Use broader aspect ratio support
},
cfg: { default: 7, max: 30, min: 1, step: 0.5 },
height: { default: 768, max: 2048, min: 256, step: 8 },
prompt: { default: '' },
samplerName: { default: 'euler' }, // Use SDXL common parameters
scheduler: { default: 'normal' }, // Use SDXL common parameters
seed: { default: null },
steps: { default: 25, max: 100, min: 5, step: 1 },
width: { default: 768, max: 2048, min: 256, step: 8 },
};
/**
* Custom SD image-to-image model parameter configuration
* Custom Stable Diffusion image-to-image model, supports image editing
*/
export const customSdI2iParamsSchema: ModelParamsSchema = {
cfg: { default: 7, max: 30, min: 1, step: 0.5 },
imageUrl: { default: '' }, // Input image URL
prompt: { default: '' },
samplerName: { default: 'euler' }, // Use SDXL common parameters
scheduler: { default: 'normal' }, // Use SDXL common parameters
seed: { default: null },
steps: { default: 25, max: 100, min: 5, step: 1 },
strength: { default: 0.75, max: 1, min: 0, step: 0.05 }, // Image modification strength (frontend parameter)
};
/**
* List of image generation models supported by ComfyUI
* Supports FLUX series and Stable Diffusion 3.5 models
*/
const comfyuiImageModels: AIImageModelCard[] = [
{
description:
'FLUX.1 Schnell - 超快速文生图模型1-4步即可生成高质量图像适合实时应用和快速原型制作',
displayName: 'FLUX.1 Schnell',
enabled: true,
id: 'comfyui/flux-schnell',
parameters: fluxSchnellParamsSchema,
releasedAt: '2024-08-01',
type: 'image',
},
{
description: 'FLUX.1 Dev - 高质量文生图模型10-50步生成适合高质量创作和艺术作品生成',
displayName: 'FLUX.1 Dev',
enabled: true,
id: 'comfyui/flux-dev',
parameters: fluxDevParamsSchema,
releasedAt: '2024-08-01',
type: 'image',
},
{
description: 'FLUX.1 Krea-dev - 增强安全的文生图模型,与 Krea 合作开发,内置安全过滤',
displayName: 'FLUX.1 Krea-dev',
enabled: false,
id: 'comfyui/flux-krea-dev',
parameters: fluxKreaDevParamsSchema,
releasedAt: '2025-07-31',
type: 'image',
},
{
description:
'FLUX.1 Kontext-dev - 图像编辑模型,支持基于文本指令修改现有图像,支持局部修改和风格迁移',
displayName: 'FLUX.1 Kontext-dev',
enabled: true,
id: 'comfyui/flux-kontext-dev',
parameters: fluxKontextDevParamsSchema,
releasedAt: '2025-05-29', // Aligned with BFL official Kontext series release date
type: 'image',
},
{
description:
'Stable Diffusion 3.5 新一代文生图模型,支持 Large 和 Medium 两个版本,需要外部 CLIP 编码器文件,提供卓越的图像质量和提示词匹配度。',
displayName: 'Stable Diffusion 3.5',
enabled: true,
id: 'comfyui/stable-diffusion-35',
parameters: sd35ParamsSchema,
releasedAt: '2024-10-22',
type: 'image',
},
{
description:
'Stable Diffusion 3.5 内置 CLIP/T5 编码器版本,无需外部编码器文件,适用于 sd3.5_medium_incl_clips 等模型,资源占用更少。',
displayName: 'Stable Diffusion 3.5 (内置编码器)',
enabled: false,
id: 'comfyui/stable-diffusion-35-inclclip',
parameters: sd35ParamsSchema,
releasedAt: '2024-10-22',
type: 'image',
},
{
description:
'Stable Diffusion 1.5 文生图模型经典的512x512分辨率文本到图像生成适合快速原型和创意实验',
displayName: 'SD 1.5',
enabled: false,
id: 'comfyui/stable-diffusion-15',
parameters: sd15T2iParamsSchema,
releasedAt: '2022-08-22',
type: 'image',
},
{
description:
'SDXL 文生图模型支持1024x1024高分辨率文本到图像生成提供更好的图像质量和细节表现',
displayName: 'SDXL 文生图',
enabled: true,
id: 'comfyui/stable-diffusion-xl',
parameters: sdxlT2iParamsSchema,
releasedAt: '2023-07-26',
type: 'image',
},
{
description:
'SDXL 图生图模型,基于输入图像进行高质量的图像到图像转换,支持风格迁移、图像修复和创意变换。',
displayName: 'SDXL Refiner',
enabled: true,
id: 'comfyui/stable-diffusion-refiner',
parameters: sdxlI2iParamsSchema,
releasedAt: '2023-07-26',
type: 'image',
},
{
description:
'自定义 SD 文生图模型,模型文件名请使用 custom_sd_lobe.safetensors如有 VAE 请使用 custom_sd_vae_lobe.safetensors模型文件需要按照 Comfy 的要求放入对应文件夹',
displayName: '自定义 SD 文生图',
enabled: false,
id: 'comfyui/stable-diffusion-custom',
parameters: customSdT2iParamsSchema,
releasedAt: '2023-01-01',
type: 'image',
},
{
description:
'自定义 SDXL 图生图模型,模型文件名请使用 custom_sd_lobe.safetensors如有 VAE 请使用 custom_sd_vae_lobe.safetensors模型文件需要按照 Comfy 的要求放入对应文件夹',
displayName: '自定义 SDXL Refiner',
enabled: false,
id: 'comfyui/stable-diffusion-custom-refiner',
parameters: customSdI2iParamsSchema,
releasedAt: '2023-01-01',
type: 'image',
},
];
export const allModels = [...comfyuiImageModels];
export default allModels;

View file

@ -14,6 +14,7 @@ import { default as cerebras } from './cerebras';
import { default as cloudflare } from './cloudflare';
import { default as cohere } from './cohere';
import { default as cometapi } from './cometapi';
import { default as comfyui } from './comfyui';
import { default as deepseek } from './deepseek';
import { default as fal } from './fal';
import { default as fireworksai } from './fireworksai';
@ -100,6 +101,7 @@ export const LOBE_DEFAULT_MODEL_LIST = buildDefaultModelList({
cloudflare,
cohere,
cometapi,
comfyui,
deepseek,
fal,
fireworksai,
@ -167,6 +169,7 @@ export { default as cerebras } from './cerebras';
export { default as cloudflare } from './cloudflare';
export { default as cohere } from './cohere';
export { default as cometapi } from './cometapi';
export { default as comfyui } from './comfyui';
export { default as deepseek } from './deepseek';
export { default as fal, fluxSchnellParamsSchema } from './fal';
export { default as fireworksai } from './fireworksai';

View file

@ -14,6 +14,7 @@ export enum ModelProvider {
Cloudflare = 'cloudflare',
Cohere = 'cohere',
CometAPI = 'cometapi',
ComfyUI = 'comfyui',
DeepSeek = 'deepseek',
Fal = 'fal',
FireworksAI = 'fireworksai',

View file

@ -96,6 +96,30 @@ export const ModelParamsMetaSchema = z.object({
})
.optional(),
/**
* samplerName is not requires by all i2i providers
*/
samplerName: z
.object({
default: z.string(),
description: z.string().optional(),
enum: z.array(z.string()).optional(),
type: z.literal('string').optional(),
})
.optional(),
/**
* scheduler is not requires by all i2i providers
*/
scheduler: z
.object({
default: z.string(),
description: z.string().optional(),
enum: z.array(z.string()).optional(),
type: z.literal('string').optional(),
})
.optional(),
height: z
.object({
default: z.number(),
@ -136,6 +160,20 @@ export const ModelParamsMetaSchema = z.object({
})
.optional(),
/**
* strength/denoise is optional for t2i but must be used for i2i
*/
strength: z
.object({
default: z.number(),
description: z.string().optional(),
max: z.number().optional().default(1),
min: z.number().optional().default(0),
step: z.number().optional().default(0.05),
type: z.literal('number').optional(),
})
.optional(),
steps: z
.object({
default: z.number(),

View file

@ -16,7 +16,7 @@ import {
TextToImagePayload,
TextToSpeechPayload,
} from '../types';
import { CreateImagePayload } from '../types/image';
import { AuthenticatedImageRuntime, CreateImagePayload } from '../types/image';
import { LobeRuntimeAI } from './BaseAI';
export interface AgentChatOptions {
@ -92,6 +92,13 @@ export class ModelRuntime {
return this._runtime.pullModel?.(params, options);
}
/**
* Get authentication headers if runtime supports it
*/
getAuthHeaders(): Record<string, string> | undefined {
return (this._runtime as AuthenticatedImageRuntime).getAuthHeaders?.();
}
/**
* @description Initialize the runtime with the provider and the options
* @param provider choose a model provider

View file

@ -13,6 +13,7 @@ export { LobeBedrockAI } from './providers/bedrock';
export { LobeBflAI } from './providers/bfl';
export { LobeCerebrasAI } from './providers/cerebras';
export { LobeCometAPIAI } from './providers/cometapi';
export { LobeComfyUI } from './providers/comfyui';
export { LobeDeepSeekAI } from './providers/deepseek';
export { LobeGoogleAI } from './providers/google';
export { LobeGroq } from './providers/groq';
@ -39,3 +40,4 @@ export { AgentRuntimeError } from './utils/createError';
export { getModelPropertyWithFallback } from './utils/getFallbackModelProperty';
export { getModelPricing } from './utils/getModelPricing';
export { parseDataUri } from './utils/uriParser';

View file

@ -0,0 +1,521 @@
// @vitest-environment node
import { createBasicAuthCredentials } from '@lobechat/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComfyUIKeyVault } from '@/types/index';
import type { CreateImagePayload } from '../../../types/image';
import { LobeComfyUI } from '../index';
// Mock debug
vi.mock('debug', () => ({
default: vi.fn(() => vi.fn()),
}));
describe('LobeComfyUI Runtime', () => {
let runtime: LobeComfyUI;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
// Mock global fetch
mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe('constructor', () => {
it('should initialize with default options', () => {
runtime = new LobeComfyUI();
expect(runtime.baseURL).toBe('http://localhost:8188');
});
it('should initialize with custom baseURL', () => {
const options: ComfyUIKeyVault = {
baseURL: 'https://custom.comfyui.com',
};
runtime = new LobeComfyUI(options);
expect(runtime.baseURL).toBe('https://custom.comfyui.com');
});
it('should use environment variable if no baseURL provided', () => {
const originalEnv = process.env.COMFYUI_DEFAULT_URL;
process.env.COMFYUI_DEFAULT_URL = 'https://env.comfyui.com';
runtime = new LobeComfyUI();
expect(runtime.baseURL).toBe('https://env.comfyui.com');
// Restore environment
if (originalEnv === undefined) {
delete process.env.COMFYUI_DEFAULT_URL;
} else {
process.env.COMFYUI_DEFAULT_URL = originalEnv;
}
});
});
describe('getAuthHeaders', () => {
it('should return undefined for no auth', () => {
runtime = new LobeComfyUI({ authType: 'none' });
const headers = runtime.getAuthHeaders();
expect(headers).toBeUndefined();
});
it('should return undefined for default auth type', () => {
runtime = new LobeComfyUI();
const headers = runtime.getAuthHeaders();
expect(headers).toBeUndefined();
});
it('should return Basic auth headers when configured correctly', () => {
const options: ComfyUIKeyVault = {
authType: 'basic',
username: 'testuser',
password: 'testpass',
};
runtime = new LobeComfyUI(options);
const headers = runtime.getAuthHeaders();
expect(headers).toEqual({
Authorization: `Basic ${createBasicAuthCredentials('testuser', 'testpass')}`,
});
});
it('should return undefined for basic auth without credentials', () => {
const options: ComfyUIKeyVault = {
authType: 'basic',
};
runtime = new LobeComfyUI(options);
const headers = runtime.getAuthHeaders();
expect(headers).toBeUndefined();
});
it('should return Bearer auth headers when configured correctly', () => {
const options: ComfyUIKeyVault = {
authType: 'bearer',
apiKey: 'test-api-key',
};
runtime = new LobeComfyUI(options);
const headers = runtime.getAuthHeaders();
expect(headers).toEqual({
Authorization: 'Bearer test-api-key',
});
});
it('should return undefined for bearer auth without apiKey', () => {
const options: ComfyUIKeyVault = {
authType: 'bearer',
};
runtime = new LobeComfyUI(options);
const headers = runtime.getAuthHeaders();
expect(headers).toBeUndefined();
});
it('should return custom headers when configured', () => {
const customHeaders = {
'X-Custom-Auth': 'custom-value',
'Authorization': 'Custom auth-token',
};
const options: ComfyUIKeyVault = {
authType: 'custom',
customHeaders,
};
runtime = new LobeComfyUI(options);
const headers = runtime.getAuthHeaders();
expect(headers).toEqual(customHeaders);
});
it('should return undefined for custom auth without headers', () => {
const options: ComfyUIKeyVault = {
authType: 'custom',
};
runtime = new LobeComfyUI(options);
const headers = runtime.getAuthHeaders();
expect(headers).toBeUndefined();
});
});
describe('createImage', () => {
beforeEach(() => {
runtime = new LobeComfyUI({
baseURL: 'https://test.comfyui.com',
authType: 'bearer',
apiKey: 'test-key',
});
});
it('should call WebAPI endpoint with correct URL and payload', async () => {
const mockPayload: CreateImagePayload = {
model: 'flux1-dev.safetensors',
params: {
prompt: 'a beautiful landscape',
width: 1024,
height: 1024,
steps: 20,
cfg: 7,
},
};
const mockResponse = {
imageUrl: 'https://test.comfyui.com/image/output.png',
width: 1024,
height: 1024,
};
// Mock successful response
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockResponse),
});
// Set APP_URL environment variable
const originalAppUrl = process.env.APP_URL;
process.env.APP_URL = 'http://localhost:3010';
const result = await runtime.createImage(mockPayload);
// Verify fetch was called with correct parameters
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3010/webapi/create-image/comfyui', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-key',
},
body: JSON.stringify({
model: 'flux1-dev.safetensors',
options: {
baseURL: 'https://test.comfyui.com',
authType: 'bearer',
apiKey: 'test-key',
},
params: {
prompt: 'a beautiful landscape',
width: 1024,
height: 1024,
steps: 20,
cfg: 7,
},
}),
});
expect(result).toEqual(mockResponse);
// Restore environment
if (originalAppUrl === undefined) {
delete process.env.APP_URL;
} else {
process.env.APP_URL = originalAppUrl;
}
});
it('should use default APP_URL when environment variable is not set', async () => {
const mockPayload: CreateImagePayload = {
model: 'flux1-dev.safetensors',
params: {
prompt: 'test prompt',
},
};
// Mock successful response
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ imageUrl: 'test.png' }),
});
// Ensure APP_URL is not set
const originalAppUrl = process.env.APP_URL;
const originalPort = process.env.PORT;
delete process.env.APP_URL;
process.env.PORT = '3000';
await runtime.createImage(mockPayload);
// Should use default localhost:3000
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/webapi/create-image/comfyui',
expect.any(Object),
);
// Restore environment
if (originalAppUrl !== undefined) {
process.env.APP_URL = originalAppUrl;
}
if (originalPort === undefined) {
delete process.env.PORT;
} else {
process.env.PORT = originalPort;
}
});
it('should use default port 3010 when PORT is not set', async () => {
const mockPayload: CreateImagePayload = {
model: 'flux1-dev.safetensors',
params: {
prompt: 'test prompt',
},
};
// Mock successful response
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ imageUrl: 'test.png' }),
});
// Ensure both APP_URL and PORT are not set
const originalAppUrl = process.env.APP_URL;
const originalPort = process.env.PORT;
delete process.env.APP_URL;
delete process.env.PORT;
await runtime.createImage(mockPayload);
// Should use default localhost:3010
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3010/webapi/create-image/comfyui',
expect.any(Object),
);
// Restore environment
if (originalAppUrl !== undefined) {
process.env.APP_URL = originalAppUrl;
}
if (originalPort !== undefined) {
process.env.PORT = originalPort;
}
});
it('should include auth headers when configured', async () => {
const mockPayload: CreateImagePayload = {
model: 'test-model.safetensors',
params: {
prompt: 'test prompt',
},
};
// Test with basic auth
runtime = new LobeComfyUI({
baseURL: 'https://test.comfyui.com',
authType: 'basic',
username: 'testuser',
password: 'testpass',
});
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ imageUrl: 'test.png' }),
});
await runtime.createImage(mockPayload);
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
'Authorization': `Basic ${createBasicAuthCredentials('testuser', 'testpass')}`,
}),
}),
);
});
it('should not include auth headers when auth is disabled', async () => {
const mockPayload: CreateImagePayload = {
model: 'test-model.safetensors',
params: {
prompt: 'test prompt',
},
};
// Test with no auth
runtime = new LobeComfyUI({
baseURL: 'https://test.comfyui.com',
authType: 'none',
});
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ imageUrl: 'test.png' }),
});
await runtime.createImage(mockPayload);
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
},
}),
);
});
it('should throw error when fetch response is not ok', async () => {
const mockPayload: CreateImagePayload = {
model: 'flux1-dev.safetensors',
params: {
prompt: 'test prompt',
},
};
// Mock error response
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Internal server error'),
});
await expect(runtime.createImage(mockPayload)).rejects.toMatchObject({
errorType: 'ComfyUIServiceUnavailable',
provider: 'comfyui',
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should throw error when fetch throws', async () => {
const mockPayload: CreateImagePayload = {
model: 'flux1-dev.safetensors',
params: {
prompt: 'test prompt',
},
};
const networkError = new Error('Network connection failed');
mockFetch.mockRejectedValue(networkError);
await expect(runtime.createImage(mockPayload)).rejects.toMatchObject({
errorType: 'ComfyUIBizError',
provider: 'comfyui',
error: {
message: 'Network connection failed',
},
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should handle complex payload with all parameters', async () => {
const complexPayload: CreateImagePayload = {
model: 'sd3.5-large.safetensors',
params: {
prompt: 'complex image generation with multiple parameters',
width: 1152,
height: 896,
steps: 25,
cfg: 8.5,
seed: 12345,
},
};
const mockResponse = {
imageUrl: 'https://test.comfyui.com/complex-image.png',
width: 1152,
height: 896,
};
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockResponse),
});
const result = await runtime.createImage(complexPayload);
expect(result).toEqual(mockResponse);
// Verify that complex payload was passed correctly
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: JSON.stringify({
model: 'sd3.5-large.safetensors',
options: {
baseURL: 'https://test.comfyui.com',
authType: 'bearer',
apiKey: 'test-key',
},
params: {
prompt: 'complex image generation with multiple parameters',
width: 1152,
height: 896,
steps: 25,
cfg: 8.5,
seed: 12345,
},
}),
}),
);
});
it('should handle WebAPI error responses with JSON body', async () => {
const mockPayload: CreateImagePayload = {
model: 'flux1-dev.safetensors',
params: {
prompt: 'test prompt',
},
};
// Mock error response with JSON body
mockFetch.mockResolvedValue({
ok: false,
status: 400,
text: vi
.fn()
.mockResolvedValue('{"message":"Invalid model specified","error":"Model not found"}'),
});
await expect(runtime.createImage(mockPayload)).rejects.toMatchObject({
errorType: 'ComfyUIBizError',
provider: 'comfyui',
error: {
message: 'Invalid model specified',
},
});
});
});
describe('runtime interface compliance', () => {
it('should implement LobeRuntimeAI interface', () => {
runtime = new LobeComfyUI();
expect(runtime).toHaveProperty('baseURL');
expect(typeof runtime.createImage).toBe('function');
});
it('should implement AuthenticatedImageRuntime interface', () => {
runtime = new LobeComfyUI();
expect(typeof runtime.getAuthHeaders).toBe('function');
});
});
});

View file

@ -0,0 +1,116 @@
import { createBasicAuthCredentials } from '@lobechat/utils';
import type { ComfyUIKeyVault } from '@/types/index';
export interface BasicCredentials {
password: string;
type: 'basic';
username: string;
}
export interface BearerTokenCredentials {
apiKey: string;
type: 'bearer';
}
export interface CustomCredentials {
customHeaders: Record<string, string>;
type: 'custom';
}
/**
* ComfyUI Authentication Manager
* Handles authentication headers generation for ComfyUI requests
*/
export class AuthManager {
private credentials: BasicCredentials | BearerTokenCredentials | CustomCredentials | undefined;
private authHeaders: Record<string, string> | undefined;
constructor(options: ComfyUIKeyVault) {
this.validateOptions(options);
this.credentials = this.createCredentials(options);
this.authHeaders = this.createAuthHeaders(options);
}
getAuthHeaders(): Record<string, string> | undefined {
return this.authHeaders;
}
private validateOptions(options: ComfyUIKeyVault): void {
const { authType = 'none', apiKey, username, password, customHeaders } = options;
switch (authType) {
case 'basic': {
if (!username || !password) {
throw new TypeError('Username and password are required for basic authentication');
}
break;
}
case 'bearer': {
if (!apiKey) {
throw new TypeError('API key is required for bearer token authentication');
}
break;
}
case 'custom': {
if (!customHeaders || Object.keys(customHeaders).length === 0) {
throw new TypeError('Custom headers are required for custom authentication');
}
break;
}
case 'none': {
// No validation needed for none authentication
break;
}
default: {
throw new TypeError(`Unsupported authentication type: ${authType}`);
}
}
}
private createCredentials(
options: ComfyUIKeyVault,
): BasicCredentials | BearerTokenCredentials | CustomCredentials | undefined {
const { authType = 'none', apiKey, username, password, customHeaders } = options;
switch (authType) {
case 'basic': {
return { password: password!, type: 'basic', username: username! };
}
case 'bearer': {
return { apiKey: apiKey!, type: 'bearer' };
}
case 'custom': {
return { customHeaders: customHeaders!, type: 'custom' };
}
case 'none': {
return undefined;
}
}
}
private createAuthHeaders(options: ComfyUIKeyVault): Record<string, string> | undefined {
const { authType = 'none', apiKey, username, password, customHeaders } = options;
switch (authType) {
case 'basic': {
if (!username || !password) return undefined;
const credentials = createBasicAuthCredentials(username, password);
return { Authorization: `Basic ${credentials}` };
}
case 'bearer': {
if (!apiKey) return undefined;
return { Authorization: `Bearer ${apiKey}` };
}
case 'custom': {
return customHeaders || undefined;
}
case 'none': {
return undefined;
}
}
}
}

View file

@ -0,0 +1,180 @@
import { createBasicAuthCredentials } from '@lobechat/utils';
import debug from 'debug';
import type { ComfyUIKeyVault } from '@/types/index';
import { LobeRuntimeAI } from '../../core/BaseAI';
import {
AuthenticatedImageRuntime,
CreateImagePayload,
CreateImageResponse,
} from '../../types/image';
import { parseComfyUIErrorMessage } from '../../utils/comfyuiErrorParser';
import { AgentRuntimeError } from '../../utils/createError';
const log = debug('lobe-image:comfyui');
/**
* ComfyUI Runtime implementation
* Supports text-to-image and image editing
*/
export class LobeComfyUI implements LobeRuntimeAI, AuthenticatedImageRuntime {
private options: ComfyUIKeyVault;
baseURL: string;
constructor(options: ComfyUIKeyVault = {}) {
log('🏗️ ComfyUI Runtime initialized');
this.options = options;
this.baseURL = options.baseURL || process.env.COMFYUI_DEFAULT_URL || 'http://localhost:8188';
log('✅ ComfyUI Runtime ready - baseURL: %s', this.baseURL);
}
/**
* Get authentication headers for image download
* Used by framework for authenticated image downloads
*/
getAuthHeaders(): Record<string, string> | undefined {
log('🔐 Providing auth headers for image download');
const { authType = 'none', apiKey, username, password, customHeaders } = this.options;
switch (authType) {
case 'basic': {
if (username && password) {
return { Authorization: `Basic ${createBasicAuthCredentials(username, password)}` };
}
return undefined;
}
case 'bearer': {
if (apiKey) {
return { Authorization: `Bearer ${apiKey}` };
}
return undefined;
}
case 'custom': {
return customHeaders || undefined;
}
case 'none': {
return undefined;
}
}
}
/**
* Create image using internal API endpoint
* Always uses full URL for consistency across environments
*/
async createImage(payload: CreateImagePayload): Promise<CreateImageResponse> {
log('🎨 Creating image with model: %s', payload.model);
try {
// Determine app URL with Vercel support
const isInVercel = process.env.VERCEL === '1';
const vercelUrl = `https://${process.env.VERCEL_URL}`;
const appUrl =
process.env.APP_URL ||
(isInVercel ? vercelUrl : `http://localhost:${process.env.PORT || 3010}`);
// Build headers with authentication
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
};
// In development mode, use debug header to bypass auth
if (process.env.NODE_ENV === 'development') {
headers['lobe-auth-dev-backend-api'] = '1';
}
// If KEY_VAULTS_SECRET is available (server-side), use it for internal service auth
// But only if it's actually set (not empty string)
const keyVaultSecret = process.env.KEY_VAULTS_SECRET;
if (keyVaultSecret && keyVaultSecret.trim() !== '') {
headers['Authorization'] = `Bearer ${keyVaultSecret}`;
}
const response = await fetch(`${appUrl}/webapi/create-image/comfyui`, {
body: JSON.stringify({
model: payload.model,
options: this.options,
params: payload.params,
}),
headers,
method: 'POST',
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any;
try {
errorData = JSON.parse(errorText);
} catch {
// If not JSON, use the text as error message
errorData = { message: errorText, status: response.status };
}
// Check if it's already an AgentRuntimeError from WebAPI
if (
errorData &&
typeof errorData === 'object' &&
'errorType' in errorData &&
'error' in errorData &&
'provider' in errorData
) {
// Already a properly formatted AgentRuntimeError from WebAPI
// Reconstruct the error using the framework's method to ensure proper type
throw AgentRuntimeError.createImage({
error: errorData.error,
errorType: errorData.errorType,
provider: errorData.provider,
});
}
// Otherwise parse and create new error
const { error: parsedError, errorType } = parseComfyUIErrorMessage(errorData);
throw AgentRuntimeError.createImage({
error: parsedError,
errorType,
provider: 'comfyui',
});
}
const result = await response.json();
log('✅ ComfyUI image created successfully');
return result;
} catch (error) {
log('❌ ComfyUI createImage error: %O', error);
// If it looks like an AgentRuntimeError object structure (already processed), reconstruct it
if (
error &&
typeof error === 'object' &&
'errorType' in error &&
'error' in error &&
'provider' in error
) {
throw AgentRuntimeError.createImage({
error: (error as any).error,
errorType: (error as any).errorType,
provider: (error as any).provider,
});
}
// Otherwise parse and format the error
const { error: parsedError, errorType } = parseComfyUIErrorMessage(error);
throw AgentRuntimeError.createImage({
error: parsedError,
errorType,
provider: 'comfyui',
});
}
}
}

View file

@ -13,6 +13,7 @@ import { LobeCerebrasAI } from './providers/cerebras';
import { LobeCloudflareAI } from './providers/cloudflare';
import { LobeCohereAI } from './providers/cohere';
import { LobeCometAPIAI } from './providers/cometapi';
import { LobeComfyUI } from './providers/comfyui';
import { LobeDeepSeekAI } from './providers/deepseek';
import { LobeFalAI } from './providers/fal';
import { LobeFireworksAI } from './providers/fireworksai';
@ -79,6 +80,7 @@ export const providerRuntimeMap = {
cloudflare: LobeCloudflareAI,
cohere: LobeCohereAI,
cometapi: LobeCometAPIAI,
comfyui: LobeComfyUI,
deepseek: LobeDeepSeekAI,
fal: LobeFalAI,
fireworksai: LobeFireworksAI,

View file

@ -19,6 +19,14 @@ export const AgentRuntimeErrorType = {
OllamaBizError: 'OllamaBizError',
OllamaServiceUnavailable: 'OllamaServiceUnavailable',
InvalidComfyUIArgs: 'InvalidComfyUIArgs',
ComfyUIBizError: 'ComfyUIBizError',
ComfyUIServiceUnavailable: 'ComfyUIServiceUnavailable',
ComfyUIEmptyResult: 'ComfyUIEmptyResult',
ComfyUIUploadFailed: 'ComfyUIUploadFailed',
ComfyUIWorkflowError: 'ComfyUIWorkflowError',
ComfyUIModelError: 'ComfyUIModelError',
InvalidBedrockCredentials: 'InvalidBedrockCredentials',
InvalidVertexCredentials: 'InvalidVertexCredentials',
StreamChunkError: 'StreamChunkError',

View file

@ -34,3 +34,12 @@ export type CreateImageResponse = {
*/
modelUsage?: ModelUsage;
};
// 新增:支持认证图片下载的运行时接口
export interface AuthenticatedImageRuntime {
/**
* Get authentication headers for image download
* Used when the image server requires authentication
*/
getAuthHeaders(): Record<string, string> | undefined;
}

View file

@ -0,0 +1,369 @@
import { describe, expect, it } from 'vitest';
import { AgentRuntimeErrorType } from '../types/error';
import { cleanComfyUIErrorMessage, parseComfyUIErrorMessage } from './comfyuiErrorParser';
describe('comfyuiErrorParser', () => {
describe('cleanComfyUIErrorMessage', () => {
it('should remove leading asterisks and spaces', () => {
const message = '* Error message';
expect(cleanComfyUIErrorMessage(message)).toBe('Error message');
// Test multiple asterisks
const multiAsterisk = '* * * Error message';
expect(cleanComfyUIErrorMessage(multiAsterisk)).toBe('* * Error message');
});
it('should convert escaped newlines', () => {
const message = 'Line 1\\nLine 2';
expect(cleanComfyUIErrorMessage(message)).toBe('Line 1 Line 2');
});
it('should replace multiple newlines with single space', () => {
const message = 'Line 1\n\n\nLine 2';
expect(cleanComfyUIErrorMessage(message)).toBe('Line 1 Line 2');
});
it('should trim leading and trailing spaces', () => {
const message = ' Error message ';
expect(cleanComfyUIErrorMessage(message)).toBe('Error message');
});
});
describe('parseComfyUIErrorMessage', () => {
describe('HTTP status code errors', () => {
it('should identify 401 as InvalidProviderAPIKey', () => {
const error = { message: 'Unauthorized', status: 401 };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
});
it('should identify 403 as PermissionDenied', () => {
const error = { message: 'Forbidden', status: 403 };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.PermissionDenied);
});
it('should identify 404 as InvalidProviderAPIKey', () => {
const error = { message: 'Not Found', status: 404 };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
});
it('should identify 500+ as ComfyUIServiceUnavailable', () => {
const error = { message: 'Internal Server Error', status: 500 };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIServiceUnavailable);
});
it('should identify HTTP status in message when status field missing', () => {
const error = { message: 'Request failed with HTTP 401' };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
});
});
describe('Network errors', () => {
it('should return ComfyUIBizError for fetch failed (processed by server)', () => {
const error = new Error('fetch failed');
const result = parseComfyUIErrorMessage(error);
// Network error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('fetch failed');
});
it('should return ComfyUIBizError for ECONNREFUSED (processed by server)', () => {
const error = { message: 'Connection ECONNREFUSED', code: 'ECONNREFUSED' };
const result = parseComfyUIErrorMessage(error);
// Network error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Connection ECONNREFUSED');
});
it('should return ComfyUIBizError for WebSocket errors (processed by server)', () => {
const error = { message: 'WebSocket connection failed', code: 'WS_CONNECTION_FAILED' };
const result = parseComfyUIErrorMessage(error);
// Network error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('WebSocket connection failed');
});
});
describe('Model errors', () => {
it('should return ComfyUIBizError for model not found (processed by server)', () => {
const error = { message: 'Model not found: flux1-dev.safetensors' };
const result = parseComfyUIErrorMessage(error);
// Model error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Model not found: flux1-dev.safetensors');
});
it('should return ComfyUIBizError for checkpoint not found (processed by server)', () => {
const error = { message: 'Checkpoint not found' };
const result = parseComfyUIErrorMessage(error);
// Model error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Checkpoint not found');
});
it('should return ComfyUIBizError for safetensors file errors (processed by server)', () => {
const error = { message: 'Missing file: model.safetensors' };
const result = parseComfyUIErrorMessage(error);
// Model error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Missing file: model.safetensors');
});
it('should preserve server-provided file info but return ComfyUIBizError', () => {
const error = {
message: 'Some error',
missingFileName: 'flux1-dev.safetensors',
missingFileType: 'model',
};
const result = parseComfyUIErrorMessage(error);
// File info is preserved but error type detection is server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.missingFileName).toBe('flux1-dev.safetensors');
expect(result.error.missingFileType).toBe('model');
});
});
describe('Workflow errors', () => {
it('should return ComfyUIBizError for workflow validation errors (processed by server)', () => {
const error = { message: 'Workflow validation failed' };
const result = parseComfyUIErrorMessage(error);
// Workflow error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Workflow validation failed');
});
it('should return ComfyUIBizError for node execution errors (processed by server)', () => {
const error = {
message: 'Node execution failed',
node_id: '5',
node_type: 'KSampler',
};
const result = parseComfyUIErrorMessage(error);
// Workflow error detection moved to server-side, but node info is preserved
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Node execution failed');
expect(result.error.details).toEqual({
node_id: '5',
node_type: 'KSampler',
});
});
it('should return ComfyUIBizError for queue errors (processed by server)', () => {
const error = { message: 'Queue processing error' };
const result = parseComfyUIErrorMessage(error);
// Workflow error detection moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Queue processing error');
});
});
describe('SDK custom errors', () => {
it('should identify SDK error classes', () => {
const error = {
name: 'ExecutionFailedError',
message: 'Execution failed',
};
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
});
it('should identify SDK error messages', () => {
const error = { message: 'SDK Error: Invalid configuration' };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
});
});
describe('JSON parsing errors', () => {
it('should return ComfyUIBizError for SyntaxError (SyntaxError detection moved to server)', () => {
const error = new SyntaxError('Unexpected token < in JSON at position 0');
const result = parseComfyUIErrorMessage(error);
// SyntaxError detection and message enhancement moved to server-side
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('Unexpected token < in JSON at position 0');
expect(result.error.type).toBe('SyntaxError');
});
});
describe('Error information extraction', () => {
it('should extract error info from string', () => {
const error = 'Simple error message';
const result = parseComfyUIErrorMessage(error);
expect(result.error.message).toBe('Simple error message');
});
it('should extract error info from Error object', () => {
const error = new Error('Error message');
(error as any).code = 'ERROR_CODE';
(error as any).status = 500;
const result = parseComfyUIErrorMessage(error);
expect(result.error.message).toBe('Error message');
expect(result.error.code).toBe('ERROR_CODE');
expect(result.error.status).toBe(500);
expect(result.error.type).toBe('Error');
});
it('should extract error info from structured object', () => {
const error = {
message: 'Error message',
code: 'ERROR_CODE',
status: 400,
details: { foo: 'bar' },
node_id: '5',
node_type: 'KSampler',
};
const result = parseComfyUIErrorMessage(error);
expect(result.error.message).toBe('Error message');
expect(result.error.code).toBe('ERROR_CODE');
expect(result.error.status).toBe(400);
expect(result.error.details).toEqual({
foo: 'bar',
node_id: '5',
node_type: 'KSampler',
});
});
it('should preserve server-generated file info and guidance', () => {
const error = {
message: 'Model file missing',
missingFileName: 'flux1-dev.safetensors',
missingFileType: 'model' as const,
userGuidance: 'Please download the model from...',
};
const result = parseComfyUIErrorMessage(error);
expect(result.error.missingFileName).toBe('flux1-dev.safetensors');
expect(result.error.missingFileType).toBe('model');
expect(result.error.userGuidance).toBe('Please download the model from...');
});
it('should extract nested error info from various locations', () => {
const error = {
body: {
error: {
message: 'Nested error',
missingFileName: 'ae.safetensors',
userGuidance: 'Download VAE model',
},
},
};
const result = parseComfyUIErrorMessage(error);
expect(result.error.missingFileName).toBe('ae.safetensors');
expect(result.error.userGuidance).toBe('Download VAE model');
});
it('should handle cause field (SDK pattern)', () => {
const error = new Error('Wrapper error');
(error as any).cause = {
message: 'Actual error',
code: 'ACTUAL_CODE',
};
const result = parseComfyUIErrorMessage(error);
expect(result.error.message).toBe('Actual error');
expect(result.error.code).toBe('ACTUAL_CODE');
// Type comes from the cause object's constructor name (plain object = "Object")
expect(result.error.type).toBe('Object');
});
it('should extract message from various possible sources', () => {
const error = {
exception_message: 'ComfyUI exception',
error: {
message: 'Should not use this',
},
};
const result = parseComfyUIErrorMessage(error);
// exception_message has highest priority
expect(result.error.message).toBe('ComfyUI exception');
});
});
describe('AgentRuntimeError handling', () => {
it('should detect and return AgentRuntimeError as-is', () => {
const agentRuntimeError = {
error: {
message: 'Model not found',
missingFileName: 'flux1-dev.safetensors',
missingFileType: 'model',
userGuidance: 'Please download the model',
},
errorType: AgentRuntimeErrorType.ModelNotFound,
provider: 'comfyui',
};
const result = parseComfyUIErrorMessage(agentRuntimeError);
expect(result.errorType).toBe(AgentRuntimeErrorType.ModelNotFound);
expect(result.error).toEqual(agentRuntimeError.error);
});
it('should handle AgentRuntimeError with InvalidProviderAPIKey', () => {
const agentRuntimeError = {
error: {
message: 'Authentication failed',
status: 401,
},
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
provider: 'comfyui',
};
const result = parseComfyUIErrorMessage(agentRuntimeError);
expect(result.errorType).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
expect(result.error.message).toBe('Authentication failed');
});
});
describe('Default error handling', () => {
it('should handle unknown error types', () => {
const error = { random: 'data' };
const result = parseComfyUIErrorMessage(error);
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toContain('object');
});
it('should handle null/undefined gracefully', () => {
const result = parseComfyUIErrorMessage(null);
expect(result.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(result.error.message).toBe('null');
});
});
});
});

View file

@ -0,0 +1,266 @@
import { AgentRuntimeErrorType, ILobeAgentRuntimeErrorType } from '../types/error';
export interface ComfyUIError {
code?: number | string;
details?: any;
message: string;
missingFileName?: string;
missingFileType?: 'model' | 'component';
status?: number;
type?: string;
userGuidance?: string;
}
export interface ParsedError {
error: ComfyUIError;
errorType: ILobeAgentRuntimeErrorType;
}
/**
* Clean ComfyUI error message by removing formatting characters and extra spaces
* @param message - Original error message
* @returns Cleaned error message
*/
export function cleanComfyUIErrorMessage(message: string): string {
return message
.replaceAll(/^\*\s*/gm, '') // Remove leading asterisks and spaces (multiline)
.replaceAll('\\n', '\n') // Convert escaped newlines
.replaceAll(/\n+/g, ' ') // Replace multiple newlines with single space
.trim(); // Remove leading and trailing spaces
}
/**
* Extract structured information from error object
* Client-side version that preserves server-generated information
* @param error - Original error object
* @returns Structured ComfyUI error information
*/
function extractComfyUIErrorInfo(error: any): ComfyUIError {
// Handle string errors
if (typeof error === 'string') {
const cleanedMessage = cleanComfyUIErrorMessage(error);
return {
message: cleanedMessage,
};
}
// Handle Error objects - prioritize cause field (SDK pattern)
if (error instanceof Error) {
// Check if there's a cause field with actual error details (SDK pattern)
if ((error as any).cause) {
const cause = (error as any).cause;
// Recursively extract error info from cause
const causeInfo = extractComfyUIErrorInfo(cause);
return {
...causeInfo,
// Preserve the original error type if cause doesn't have one
type: causeInfo.type || error.name,
};
}
const cleanedMessage = cleanComfyUIErrorMessage(error.message);
return {
code: (error as any).code,
message: cleanedMessage,
// Preserve server-generated file info and guidance
missingFileName: (error as any).missingFileName,
missingFileType: (error as any).missingFileType,
status: (error as any).status || (error as any).statusCode,
type: error.name,
userGuidance: (error as any).userGuidance,
};
}
// Handle structured objects
if (error && typeof error === 'object') {
// Check for cause field first (SDK pattern)
if (error.cause) {
const causeInfo = extractComfyUIErrorInfo(error.cause);
return {
...causeInfo,
type: causeInfo.type || error.type || error.name || error.constructor?.name,
};
}
// Extract message from various possible sources
const possibleMessage = [
error.exception_message, // ComfyUI specific field (highest priority)
error.error?.exception_message, // Nested ComfyUI exception message
error.error?.error, // Deeply nested error.error.error path
error.message,
error.error?.message,
error.data?.message,
error.body?.message,
error.body?.error?.message,
error.response?.data?.message,
error.response?.data?.error?.message,
error.response?.text,
error.response?.body,
error.statusText,
].find(Boolean);
const message = possibleMessage || String(error);
// Extract status code from various possible locations
const possibleStatus = [
error.status,
error.statusCode,
error.details?.status, // ServicesError puts status in details
error.response?.status,
error.response?.statusCode,
error.error?.status,
error.error?.statusCode,
].find(Number.isInteger);
const code = error.code || error.error?.code || error.response?.data?.code;
// Extract details including ComfyUI specific fields
let details = error.response?.data || error.details || undefined;
// Include ComfyUI specific fields in details
if (error.node_id || error.node_type || error.nodeId || error.nodeType || error.nodeName) {
details = {
...details,
nodeName: error.nodeName,
node_id: error.node_id || error.nodeId,
node_type: error.node_type || error.nodeType,
};
}
const cleanedMessage = cleanComfyUIErrorMessage(message);
// Extract server-provided file info and guidance from various locations
const missingFileName =
error.missingFileName || error.body?.error?.missingFileName || error.error?.missingFileName;
const missingFileType =
error.missingFileType || error.body?.error?.missingFileType || error.error?.missingFileType;
const userGuidance =
error.userGuidance || error.body?.error?.userGuidance || error.error?.userGuidance;
return {
code,
details,
message: cleanedMessage,
missingFileName,
missingFileType,
status: possibleStatus,
type: error.type || error.name || error.constructor?.name,
userGuidance,
};
}
// Fallback handling
const cleanedMessage = cleanComfyUIErrorMessage(String(error));
return {
message: cleanedMessage,
};
}
/**
* Parse ComfyUI error message and return structured error information
* Client-side version that focuses on error type categorization
* File information and userGuidance are expected from server-side error handling
* @param error - Original error object
* @returns Parsed error object and error type
*/
export function parseComfyUIErrorMessage(error: any): ParsedError {
// Check if it's already an AgentRuntimeError from WebAPI
// AgentRuntimeError has structure: { error: object, errorType: string, provider: string }
if (
error &&
typeof error === 'object' &&
'errorType' in error &&
'error' in error &&
'provider' in error
) {
// Already parsed by server, return as-is
return {
error: error.error,
errorType: error.errorType,
};
}
// Check if it's an error from checkAuth middleware
// Format: { body: any, errorType: string }
if (error && typeof error === 'object' && 'errorType' in error && 'body' in error) {
// Extract error message from body
let message = 'Authentication failed';
if (error.body?.error?.message) {
message = error.body.error.message;
} else if (error.body?.error && typeof error.body.error === 'string') {
message = error.body.error;
} else if (error.body?.message) {
message = error.body.message;
}
return {
error: {
message,
status: 401,
},
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
};
}
const errorInfo = extractComfyUIErrorInfo(error);
// Default error type
let errorType: ILobeAgentRuntimeErrorType = AgentRuntimeErrorType.ComfyUIBizError;
// Note: SyntaxError checking moved to server-side errorHandlerService
// Client-side will never receive raw SyntaxError as it's already processed by server
// 1. HTTP status code errors (priority check)
const status = errorInfo.status;
const message = errorInfo.message;
switch (status) {
case 400:
case 401: {
// These trigger ComfyUIAuth component
errorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
break;
}
case 403: {
// Permission denied
errorType = AgentRuntimeErrorType.PermissionDenied;
break;
}
case 404: {
// 404 should trigger ComfyUIAuth for baseURL errors
errorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
break;
}
default: {
if (status && status >= 500) {
// Server errors
errorType = AgentRuntimeErrorType.ComfyUIServiceUnavailable;
}
// 2. Check HTTP status code from error message (when status field doesn't exist)
else if (!status && message) {
if (message.includes('HTTP 401') || message.includes('401')) {
errorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
} else if (message.includes('HTTP 403') || message.includes('403')) {
errorType = AgentRuntimeErrorType.PermissionDenied;
} else if (message.includes('HTTP 404') || message.includes('404')) {
errorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
} else if (message.includes('HTTP 400') || message.includes('400')) {
errorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
}
}
}
}
// Note: Error type determination is done server-side
// Client receives pre-determined errorType from server
return {
error: errorInfo,
errorType,
};
}

View file

@ -145,6 +145,8 @@ describe('modelParse', () => {
expect(detectModelProvider('deepseek-coder')).toBe('deepseek');
expect(detectModelProvider('doubao-pro')).toBe('volcengine');
expect(detectModelProvider('yi-large')).toBe('zeroone');
expect(detectModelProvider('comfyui/flux-dev')).toBe('comfyui');
expect(detectModelProvider('comfyui/sdxl-model')).toBe('comfyui');
});
it('should default to OpenAI when no provider is detected', () => {
@ -362,21 +364,28 @@ describe('modelParse', () => {
{ id: 'claude-3-opus' }, // anthropic
{ id: 'gemini-pro' }, // google
{ id: 'qwen-turbo' }, // qwen
{ id: 'comfyui/flux-dev', parameters: { width: 1024, height: 1024 } }, // comfyui
];
const result = await processMultiProviderModelList(modelList);
expect(result).toHaveLength(4);
expect(result).toHaveLength(5);
const gpt4 = result.find((model) => model.id === 'gpt-4')!;
const claude = result.find((model) => model.id === 'claude-3-opus')!;
const gemini = result.find((model) => model.id === 'gemini-pro')!;
const qwen = result.find((model) => model.id === 'qwen-turbo')!;
const comfyui = result.find((model) => model.id === 'comfyui/flux-dev')!;
// Check abilities based on their respective provider configs and knownModels
expect(gpt4.reasoning).toBe(false); // From knownModel (gpt-4)
expect(claude.functionCall).toBe(true); // From knownModel (claude-3-opus)
expect(gemini.functionCall).toBe(true); // From google keyword 'gemini'
expect(qwen.functionCall).toBe(true); // From knownModel (qwen-turbo)
// ComfyUI models should have no chat capabilities (all false)
expect(comfyui.functionCall).toBe(false); // ComfyUI config has empty arrays
expect(comfyui.reasoning).toBe(false); // ComfyUI config has empty arrays
expect(comfyui.vision).toBe(false); // ComfyUI config has empty arrays
});
it('should recognize model capabilities based on keyword detection across providers', async () => {

View file

@ -23,6 +23,12 @@ export const MODEL_LIST_CONFIGS = {
reasoningKeywords: ['-3-7', '3.7', '-4'],
visionKeywords: ['claude'],
},
comfyui: {
// ComfyUI models are image generation models, no chat capabilities
functionCallKeywords: [],
reasoningKeywords: [],
visionKeywords: [],
},
deepseek: {
functionCallKeywords: ['v3', 'r1', 'deepseek-chat'],
reasoningKeywords: ['r1', 'deepseek-reasoner', 'v3.1', 'v3.2'],
@ -105,6 +111,7 @@ export const MODEL_LIST_CONFIGS = {
// 模型所有者 (提供商) 关键词配置
export const MODEL_OWNER_DETECTION_CONFIG = {
anthropic: ['claude'],
comfyui: ['comfyui/'], // ComfyUI models detection - all ComfyUI models have comfyui/ prefix
deepseek: ['deepseek'],
google: ['gemini', 'imagen'],
inclusionai: ['ling-', 'ming-', 'ring-'],

View file

@ -25,6 +25,7 @@ export const AiProviderSDKEnum = {
AzureAI: 'azureai',
Bedrock: 'bedrock',
Cloudflare: 'cloudflare',
ComfyUI: 'comfyui',
Google: 'google',
Huggingface: 'huggingface',
Ollama: 'ollama',
@ -38,6 +39,7 @@ export type AiProviderSDKType = (typeof AiProviderSDKEnum)[keyof typeof AiProvid
const AiProviderSdkTypes = [
'anthropic',
'comfyui',
'openai',
'ollama',
'azure',
@ -240,7 +242,15 @@ export const UpdateAiProviderConfigSchema = z.object({
})
.optional(),
fetchOnClient: z.boolean().nullable().optional(),
keyVaults: z.record(z.string(), z.string().optional()).optional(),
keyVaults: z
.record(
z.string(),
z.union([
z.string().optional(),
z.record(z.string(), z.string()).optional(), // 支持嵌套对象,如 customHeaders
]),
)
.optional(),
});
export type UpdateAiProviderConfigParams = z.infer<typeof UpdateAiProviderConfigSchema>;

View file

@ -14,6 +14,10 @@ export enum AsyncTaskStatus {
export enum AsyncTaskErrorType {
EmbeddingError = 'EmbeddingError',
InvalidProviderAPIKey = 'InvalidProviderAPIKey',
/**
* Model not found on server
*/
ModelNotFound = 'ModelNotFound',
/**
* the chunk parse result it empty
*/

View file

@ -29,6 +29,14 @@ export interface ClientSecretPayload {
vertexAIRegion?: string;
/**
* ComfyUI specific authentication fields
*/
authType?: string;
username?: string;
password?: string;
customHeaders?: Record<string, string>;
/**
* user id
* in client db mode it's a uuid

View file

@ -34,6 +34,15 @@ export interface CloudflareKeyVault {
baseURLOrAccountID?: string;
}
export interface ComfyUIKeyVault {
apiKey?: string;
authType?: 'none' | 'basic' | 'bearer' | 'custom';
baseURL?: string;
customHeaders?: Record<string, string>;
password?: string;
username?: string;
}
export interface SearchEngineKeyVaults {
searchxng?: {
apiKey?: string;
@ -57,6 +66,7 @@ export interface UserKeyVaults extends SearchEngineKeyVaults {
cloudflare?: CloudflareKeyVault;
cohere?: OpenAICompatibleKeyVault;
cometapi?: OpenAICompatibleKeyVault;
comfyui?: ComfyUIKeyVault;
deepseek?: OpenAICompatibleKeyVault;
fal?: FalKeyVault;
fireworksai?: OpenAICompatibleKeyVault;

View file

@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest';
import { createBasicAuthCredentials, decodeFromBase64, encodeToBase64 } from './base64';
describe('base64 utilities', () => {
describe('encodeToBase64', () => {
it('should encode string to base64 in browser environment', () => {
// Mock browser environment
global.btoa = vi
.fn()
.mockImplementation((input) => Buffer.from(input, 'utf8').toString('base64'));
const result = encodeToBase64('test');
expect(global.btoa).toHaveBeenCalledWith('test');
expect(result).toBe('dGVzdA==');
});
it('should encode string to base64 in Node.js environment', () => {
// Mock Node.js environment by removing btoa
const originalBtoa = global.btoa;
// @ts-ignore
delete global.btoa;
const result = encodeToBase64('test');
expect(result).toBe('dGVzdA==');
// Restore btoa
global.btoa = originalBtoa;
});
it('should handle special characters', () => {
const input = 'test@123:password';
const result = encodeToBase64(input);
// Expected base64 for 'test@123:password' is 'dGVzdEAxMjM6cGFzc3dvcmQ='
expect(result).toBe(Buffer.from(input, 'utf8').toString('base64'));
});
});
describe('decodeFromBase64', () => {
it('should decode base64 string in browser environment', () => {
// Mock browser environment
global.atob = vi
.fn()
.mockImplementation((input) => Buffer.from(input, 'base64').toString('utf8'));
const result = decodeFromBase64('dGVzdA==');
expect(global.atob).toHaveBeenCalledWith('dGVzdA==');
expect(result).toBe('test');
});
it('should decode base64 string in Node.js environment', () => {
// Mock Node.js environment by removing atob
const originalAtob = global.atob;
// @ts-ignore
delete global.atob;
const result = decodeFromBase64('dGVzdA==');
expect(result).toBe('test');
// Restore atob
global.atob = originalAtob;
});
});
describe('createBasicAuthCredentials', () => {
it('should create basic auth credentials', () => {
const username = 'testuser';
const password = 'testpass';
const result = createBasicAuthCredentials(username, password);
// Expected base64 for 'testuser:testpass' is 'dGVzdHVzZXI6dGVzdHBhc3M='
expect(result).toBe('dGVzdHVzZXI6dGVzdHBhc3M=');
});
it('should handle special characters in credentials', () => {
const username = 'user@domain.com';
const password = 'p@ss:w0rd!';
const result = createBasicAuthCredentials(username, password);
const decoded = decodeFromBase64(result);
expect(decoded).toBe('user@domain.com:p@ss:w0rd!');
});
it('should handle empty credentials', () => {
const result = createBasicAuthCredentials('', '');
const decoded = decodeFromBase64(result);
expect(decoded).toBe(':');
});
});
describe('round-trip encoding/decoding', () => {
it('should preserve data through encode/decode cycle', () => {
const testStrings = [
'simple text',
'test@123:password',
'中文测试',
'user:pass',
'special!@#$%^&*()chars',
'',
];
testStrings.forEach((input) => {
const encoded = encodeToBase64(input);
const decoded = decodeFromBase64(encoded);
expect(decoded).toBe(input);
});
});
});
});

View file

@ -0,0 +1,44 @@
/**
* Cross-platform base64 encoding utility
* Works in both browser and Node.js environments
*/
/**
* Encode a string to base64
* @param input - The string to encode
* @returns Base64 encoded string
*/
export const encodeToBase64 = (input: string): string => {
if (typeof btoa === 'function') {
// Browser environment
return btoa(input);
} else {
// Node.js environment
return Buffer.from(input, 'utf8').toString('base64');
}
};
/**
* Decode a base64 string
* @param input - The base64 string to decode
* @returns Decoded string
*/
export const decodeFromBase64 = (input: string): string => {
if (typeof atob === 'function') {
// Browser environment
return atob(input);
} else {
// Node.js environment
return Buffer.from(input, 'base64').toString('utf8');
}
};
/**
* Create Basic Authentication header value
* @param username - Username for authentication
* @param password - Password for authentication
* @returns Base64 encoded credentials for Basic auth
*/
export const createBasicAuthCredentials = (username: string, password: string): string => {
return encodeToBase64(`${username}:${password}`);
};

View file

@ -1,8 +1,10 @@
export * from './base64';
export * from './client/cookie';
export * from './detectChinese';
export * from './format';
export * from './imageToBase64';
export * from './keyboard';
export * from './number';
export * from './object';
export * from './parseModels';
export * from './pricing';

View file

@ -0,0 +1,98 @@
import { NextResponse } from 'next/server';
import { checkAuth } from '@/app/(backend)/middleware/auth';
import { getServerDBConfig } from '@/config/db';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { lambdaRouter } from '@/server/routers/lambda';
export const runtime = 'nodejs';
export const maxDuration = 300;
const serverDBEnv = getServerDBConfig();
// Custom handler that supports both regular auth and internal service auth
const handler = async (req: Request, { jwtPayload }: { jwtPayload?: any }) => {
try {
const body = await req.json();
const { model, params, options } = body;
// Create tRPC caller with authentication context
const createCaller = createCallerFactory(lambdaRouter);
const caller = createCaller({
jwtPayload,
nextAuth: undefined, // WebAPI routes don't have nextAuth session
userId: jwtPayload?.userId, // Required for userAuth middleware
});
// Call ComfyUI service through tRPC
const result = await caller.comfyui.createImage({
model,
options,
params,
});
return NextResponse.json(result);
} catch (error: any) {
console.error('[ComfyUI WebAPI] Error:', error);
// Extract AgentRuntimeError from TRPCError's cause
const agentError = error?.cause;
// If we have an AgentRuntimeError in the cause, return it
if (agentError && typeof agentError === 'object' && 'errorType' in agentError) {
// Convert errorType to HTTP status
let status;
switch (agentError.errorType) {
case 'InvalidProviderAPIKey':
case 401: {
status = 401;
break;
}
case 'PermissionDenied':
case 403: {
status = 403;
break;
}
case 'ModelNotFound':
case 404: {
status = 404;
break;
}
case 'ComfyUIServiceUnavailable':
case 503: {
status = 503;
break;
}
default: {
status = 500;
}
}
// Return the AgentRuntimeError directly for the Provider to handle
return NextResponse.json(agentError, { status });
}
// Fallback for other errors
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
};
export const POST = async (req: Request) => {
// Check for internal service authentication (only if KEY_VAULTS_SECRET is set)
if (serverDBEnv.KEY_VAULTS_SECRET) {
const authorization = req.headers.get('Authorization');
// If request has internal service token, bypass regular auth
if (authorization === `Bearer ${serverDBEnv.KEY_VAULTS_SECRET}`) {
// Internal service call from ComfyUI provider
// Pass a system user ID for internal service calls
return handler(req, { jwtPayload: { userId: 'INTERNAL_SERVICE' } });
}
}
// Otherwise use regular checkAuth
// ComfyUI doesn't have a provider param, but checkAuth requires it
return checkAuth(handler)(req, { params: Promise.resolve({ provider: 'comfyui' }) });
};

View file

@ -115,6 +115,7 @@ export const GenerationBatchItem = memo<GenerationBatchItemProps>(({ batch }) =>
);
if (isInvalidApiKey) {
// Use unified InvalidAPIKey component for all providers (including ComfyUI)
return (
<InvalidAPIKey
bedrockDescription={t('bedrock.unlock.imageGenerationDescription', { ns: 'modelProvider' })}

View file

@ -2,10 +2,12 @@
import { Block, Icon, Text } from '@lobehub/ui';
import { ImageOffIcon } from 'lucide-react';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center } from 'react-layout-kit';
import { AgentRuntimeErrorType } from '@lobechat/model-runtime';
import { ActionButtons } from './ActionButtons';
import { useStyles } from './styles';
import { ErrorStateProps } from './types';
@ -16,12 +18,46 @@ export const ErrorState = memo<ErrorStateProps>(
({ generation, generationBatch, aspectRatio, onDelete, onCopyError }) => {
const { styles, theme } = useStyles();
const { t } = useTranslation('image');
const { t: tError } = useTranslation('error');
const errorMessage = generation.task.error
? typeof generation.task.error.body === 'string'
? generation.task.error.body
: generation.task.error.body?.detail || generation.task.error.name || 'Unknown error'
: '';
const errorMessage = useMemo(() => {
if (!generation.task.error) return '';
const error = generation.task.error;
const errorBody = typeof error.body === 'string' ? error.body : error.body?.detail;
// Try to translate based on error type if it matches known AgentRuntimeErrorType
if (errorBody) {
// Check if the error body is an AgentRuntimeErrorType that needs translation
const knownErrorTypes = Object.values(AgentRuntimeErrorType);
if (
knownErrorTypes.includes(
errorBody as (typeof AgentRuntimeErrorType)[keyof typeof AgentRuntimeErrorType],
)
) {
// Use localized error message - ComfyUI errors are under 'response' namespace
const translationKey = `response.${errorBody}`;
const translated = tError(translationKey as any);
// If translation key is not found, it returns the key itself
// Check if we got back the key (meaning translation failed)
if (translated === translationKey || (translated as string).startsWith('response.')) {
// Try without any prefix (for backwards compatibility)
const directTranslated = tError(errorBody as any);
if (directTranslated !== errorBody) {
return directTranslated as string;
}
// Final fallback to the original error message
return errorBody;
}
return translated as string;
}
}
// Fallback to original error message
return errorBody || error.name || 'Unknown error';
}, [generation.task.error, generationBatch.provider, tError]);
return (
<Block

View file

@ -0,0 +1,138 @@
'use client';
import { Select } from '@lobehub/ui';
import { useTranslation } from 'react-i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import KeyValueEditor from '@/components/KeyValueEditor';
import { ComfyUIProviderCard } from '@/config/modelProviders';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { GlobalLLMProviderKey } from '@/types/user/settings';
import { KeyVaultsConfigKey } from '../../const';
import { SkeletonInput } from '../../features/ProviderConfig';
import { ProviderItem } from '../../type';
import ProviderDetail from '../default';
const providerKey: GlobalLLMProviderKey = 'comfyui';
const useComfyUICard = (): ProviderItem => {
const { t } = useTranslation('modelProvider');
const isLoading = useAiInfraStore(aiProviderSelectors.isAiProviderConfigLoading(providerKey));
// Get current config and watch for auth type changes
const config = useAiInfraStore((s) => s.aiProviderRuntimeConfig?.[providerKey]);
const authType = config?.keyVaults?.authType || 'none';
const authTypeOptions = [
{ label: t('comfyui.authType.options.none'), value: 'none' },
{ label: t('comfyui.authType.options.basic'), value: 'basic' },
{ label: t('comfyui.authType.options.bearer'), value: 'bearer' },
{ label: t('comfyui.authType.options.custom'), value: 'custom' },
];
const apiKeyItems = [
// Base URL - Always shown
{
children: isLoading ? (
<SkeletonInput />
) : (
<FormInput placeholder={t('comfyui.baseURL.placeholder')} />
),
desc: t('comfyui.baseURL.desc'),
label: t('comfyui.baseURL.title'),
name: [KeyVaultsConfigKey, 'baseURL'],
},
// Authentication Type Selector - Always shown
{
children: isLoading ? (
<SkeletonInput />
) : (
<Select
allowClear={false}
options={authTypeOptions}
placeholder={t('comfyui.authType.placeholder')}
/>
),
desc: t('comfyui.authType.desc'),
label: t('comfyui.authType.title'),
name: [KeyVaultsConfigKey, 'authType'],
},
];
// Conditionally add fields based on auth type
if (authType === 'basic') {
apiKeyItems.push(
{
children: isLoading ? (
<SkeletonInput />
) : (
<FormInput autoComplete="username" placeholder={t('comfyui.username.placeholder')} />
),
desc: t('comfyui.username.desc'),
label: t('comfyui.username.title'),
name: [KeyVaultsConfigKey, 'username'],
},
{
children: isLoading ? (
<SkeletonInput />
) : (
<FormPassword
autoComplete="new-password"
placeholder={t('comfyui.password.placeholder')}
/>
),
desc: t('comfyui.password.desc'),
label: t('comfyui.password.title'),
name: [KeyVaultsConfigKey, 'password'],
},
);
}
if (authType === 'bearer') {
apiKeyItems.push({
children: isLoading ? (
<SkeletonInput />
) : (
<FormPassword autoComplete="new-password" placeholder={t('comfyui.apiKey.placeholder')} />
),
desc: t('comfyui.apiKey.desc'),
label: t('comfyui.apiKey.title'),
name: [KeyVaultsConfigKey, 'apiKey'],
});
}
if (authType === 'custom') {
apiKeyItems.push({
children: isLoading ? (
<SkeletonInput />
) : (
<KeyValueEditor
addButtonText={t('comfyui.customHeaders.addButton')}
deleteTooltip={t('comfyui.customHeaders.deleteTooltip')}
duplicateKeyErrorText={t('comfyui.customHeaders.duplicateKeyError')}
keyPlaceholder={t('comfyui.customHeaders.keyPlaceholder')}
valuePlaceholder={t('comfyui.customHeaders.valuePlaceholder')}
/>
),
desc: t('comfyui.customHeaders.desc'),
label: t('comfyui.customHeaders.title'),
name: [KeyVaultsConfigKey, 'customHeaders'],
});
}
return {
...ComfyUIProviderCard,
apiKeyItems,
};
};
const Page = () => {
const card = useComfyUICard();
return <ProviderDetail {...card} />;
};
export default Page;

View file

@ -3,6 +3,7 @@ import Azure from './azure';
import AzureAI from './azureai';
import Bedrock from './bedrock';
import Cloudflare from './cloudflare';
import ComfyUI from './comfyui';
import DefaultPage from './default/ProviderDetialPage';
import GitHub from './github';
import Ollama from './ollama';
@ -28,6 +29,9 @@ const ProviderDetailPage = (props: { id?: string | null }) => {
case 'cloudflare': {
return <Cloudflare />;
}
case 'comfyui': {
return <ComfyUI />;
}
case 'github': {
return <GitHub />;
}

View file

@ -0,0 +1,251 @@
'use client';
import { ComfyUI } from '@lobehub/icons';
import { Button, Icon, Select } from '@lobehub/ui';
import { createStyles, useTheme } from 'antd-style';
import { Loader2Icon, Network } from 'lucide-react';
import { memo, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { FormInput, FormPassword } from '@/components/FormInput';
import KeyValueEditor from '@/components/KeyValueEditor';
import { FormAction } from '@/features/Conversation/Error/style';
import { useAiInfraStore } from '@/store/aiInfra';
import { ComfyUIKeyVault } from '@/types/user/settings';
import { LoadingContext } from './LoadingContext';
interface ComfyUIFormProps {
description: string;
}
const useStyles = createStyles(({ css, token }) => ({
comfyuiFormWide: css`
max-width: 900px !important;
/* Hide the avatar - target the first child which is the Avatar component */
> *:first-child {
display: none !important;
}
`,
container: css`
width: 100%;
max-width: 900px;
border: 1px solid ${token.colorSplit};
border-radius: 8px;
color: ${token.colorText};
background: ${token.colorBgContainer};
`,
}));
const ComfyUIForm = memo<ComfyUIFormProps>(({ description }) => {
const { t } = useTranslation('error');
const { t: s } = useTranslation('modelProvider');
const theme = useTheme();
const { styles } = useStyles();
// Use aiInfraStore for updating config (same as settings page)
const updateAiProviderConfig = useAiInfraStore((s) => s.updateAiProviderConfig);
const useFetchAiProviderRuntimeState = useAiInfraStore((s) => s.useFetchAiProviderRuntimeState);
const { loading, setLoading } = useContext(LoadingContext);
// Fetch the runtime state to ensure config is loaded
// Pass true since this is for auth dialog (not initialization)
const fetchRuntimeState = useFetchAiProviderRuntimeState(true);
// Get ComfyUI config from aiInfraStore (same as settings page)
const comfyUIConfig = useAiInfraStore(
(s) => s.aiProviderRuntimeConfig?.['comfyui']?.keyVaults,
) as ComfyUIKeyVault | undefined;
// State for showing base URL input - initially hidden
const [showBaseURL, setShowBaseURL] = useState(false);
// State management for form values - initialize without config first
const [formValues, setFormValues] = useState({
apiKey: '',
authType: 'none' as string,
baseURL: 'http://127.0.0.1:8000',
customHeaders: {} as Record<string, string>,
password: '',
username: '',
});
// Update form values when comfyUIConfig changes (配置反读)
// Use individual primitive values to avoid infinite re-renders
useEffect(() => {
if (comfyUIConfig) {
const newValues = {
apiKey: comfyUIConfig.apiKey || '',
authType: comfyUIConfig.authType || 'none',
baseURL: comfyUIConfig.baseURL || 'http://127.0.0.1:8000',
customHeaders: comfyUIConfig.customHeaders || {},
password: comfyUIConfig.password || '',
username: comfyUIConfig.username || '',
};
setFormValues(newValues);
}
}, [
comfyUIConfig?.apiKey,
comfyUIConfig?.authType,
comfyUIConfig?.baseURL,
comfyUIConfig?.password,
comfyUIConfig?.username,
JSON.stringify(comfyUIConfig?.customHeaders),
]);
const authTypeOptions = [
{ label: s('comfyui.authType.options.none'), value: 'none' },
{ label: s('comfyui.authType.options.basic'), value: 'basic' },
{ label: s('comfyui.authType.options.bearer'), value: 'bearer' },
{ label: s('comfyui.authType.options.custom'), value: 'custom' },
];
const handleValueChange = async (field: string, value: any) => {
const newValues = {
...formValues,
[field]: value,
};
setFormValues(newValues);
// Skip validation for certain fields that can be empty
const skipValidation = ['customHeaders', 'apiKey', 'username', 'password'];
// Basic validation before saving
if (!skipValidation.includes(field) && field === 'baseURL' && !value) {
return; // Don't save if baseURL is empty
}
// Real-time save like other providers
setLoading(true);
try {
await updateAiProviderConfig('comfyui', {
keyVaults: newValues,
});
// Refetch the runtime state to ensure config is synced
await fetchRuntimeState.mutate();
} catch (error) {
console.error('Failed to update ComfyUI config:', error);
} finally {
setLoading(false);
}
};
return (
<Center className={styles.container} gap={24} padding={24}>
<Center gap={16} paddingBlock={32} style={{ width: '100%' }}>
<ComfyUI.Combine size={64} type={'color'} />
<FormAction
avatar={<div />}
className={styles.comfyuiFormWide}
description={description}
title={t('unlock.comfyui.title', { name: 'ComfyUI' })}
>
<Flexbox gap={16} width="100%">
{/* Base URL */}
{showBaseURL ? (
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.baseURL.title')}</div>
<FormInput
onChange={(value) => handleValueChange('baseURL', value)}
placeholder={s('comfyui.baseURL.placeholder')}
suffix={<div>{loading && <Icon icon={Loader2Icon} spin />}</div>}
value={formValues.baseURL}
/>
</Flexbox>
) : (
<Button
icon={<Icon icon={Network} />}
onClick={() => setShowBaseURL(true)}
type={'text'}
>
{t('unlock.comfyui.modifyBaseUrl')}
</Button>
)}
{/* Auth Type */}
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.authType.title')}</div>
<Select
allowClear={false}
onChange={(value) => handleValueChange('authType', value)}
options={authTypeOptions}
placeholder={s('comfyui.authType.placeholder')}
value={formValues.authType}
/>
</Flexbox>
{/* Basic Auth Fields */}
{formValues.authType === 'basic' && (
<>
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.username.title')}</div>
<FormInput
autoComplete="username"
onChange={(value) => handleValueChange('username', value)}
placeholder={s('comfyui.username.placeholder')}
suffix={<div>{loading && <Icon icon={Loader2Icon} spin />}</div>}
value={formValues.username}
/>
</Flexbox>
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.password.title')}</div>
<FormPassword
autoComplete="new-password"
onChange={(value) => handleValueChange('password', value)}
placeholder={s('comfyui.password.placeholder')}
suffix={<div>{loading && <Icon icon={Loader2Icon} spin />}</div>}
value={formValues.password}
/>
</Flexbox>
</>
)}
{/* Bearer Token Field */}
{formValues.authType === 'bearer' && (
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{s('comfyui.apiKey.title')}</div>
<FormPassword
autoComplete="new-password"
onChange={(value) => handleValueChange('apiKey', value)}
placeholder={s('comfyui.apiKey.placeholder')}
suffix={<div>{loading && <Icon icon={Loader2Icon} spin />}</div>}
value={formValues.apiKey}
/>
</Flexbox>
)}
{/* Custom Headers Field */}
{formValues.authType === 'custom' && (
<Flexbox gap={4}>
<div style={{ fontSize: 14, fontWeight: 500 }}>
{s('comfyui.customHeaders.title')}
</div>
<div style={{ color: theme.colorTextSecondary, fontSize: 12, marginBottom: 4 }}>
{s('comfyui.customHeaders.desc')}
</div>
<KeyValueEditor
addButtonText={s('comfyui.customHeaders.addButton')}
deleteTooltip={s('comfyui.customHeaders.deleteTooltip')}
duplicateKeyErrorText={s('comfyui.customHeaders.duplicateKeyError')}
keyPlaceholder={s('comfyui.customHeaders.keyPlaceholder')}
onChange={(value) => handleValueChange('customHeaders', value)}
value={formValues.customHeaders}
valuePlaceholder={s('comfyui.customHeaders.valuePlaceholder')}
/>
</Flexbox>
)}
</Flexbox>
</FormAction>
</Center>
</Center>
);
});
ComfyUIForm.displayName = 'ComfyUIForm';
export default ComfyUIForm;

View file

@ -0,0 +1,137 @@
import { ModelProvider } from 'model-bank';
import { describe, expect, it, vi } from 'vitest';
import APIKeyForm from '../index';
// Mock the dependencies
vi.mock('@/store/aiInfra', () => ({
useAiInfraStore: vi.fn(() => ({
updateAiProviderConfig: vi.fn(),
useFetchAiProviderRuntimeState: vi.fn(() => ({})),
aiProviderRuntimeConfig: {},
})),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('antd-style', () => ({
useTheme: () => ({
colorTextSecondary: '#999',
}),
createStyles: vi.fn(() => () => ({ styles: {} })),
}));
vi.mock('@/components/FormInput', () => ({
FormInput: vi.fn(({ value, onChange, ...props }) => (
<input
data-testid="form-input"
value={value}
onChange={(e) => onChange?.(e.target.value)}
{...props}
/>
)),
FormPassword: vi.fn(({ value, onChange, ...props }) => (
<input
data-testid="form-password"
type="password"
value={value}
onChange={(e) => onChange?.(e.target.value)}
{...props}
/>
)),
}));
vi.mock('@/components/KeyValueEditor', () => ({
default: vi.fn(() => <div data-testid="key-value-editor">Key-Value Editor</div>),
}));
vi.mock('@lobehub/ui', () => ({
Icon: vi.fn(({ icon, ...props }) => (
<div data-testid="icon" {...props}>
{icon?.name}
</div>
)),
Select: vi.fn(({ value, options, onChange, ...props }) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange?.(e.target.value)}
{...props}
>
{options?.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)),
Button: vi.fn(({ children, onClick, ...props }) => (
<button data-testid="button" onClick={onClick} {...props}>
{children}
</button>
)),
ProviderIcon: vi.fn(() => <div data-testid="provider-icon">Provider Icon</div>),
}));
vi.mock('@lobehub/icons', () => ({
ComfyUI: {
Combine: vi.fn(() => <div data-testid="comfyui-icon">ComfyUI Icon</div>),
},
ProviderIcon: vi.fn(() => <div data-testid="provider-icon">Provider Icon</div>),
}));
vi.mock('react-layout-kit', () => ({
Center: vi.fn(({ children, ...props }) => (
<div data-testid="center" {...props}>
{children}
</div>
)),
Flexbox: vi.fn(({ children, ...props }) => (
<div data-testid="flexbox" {...props}>
{children}
</div>
)),
}));
vi.mock('@/features/Conversation/Error/style', () => ({
FormAction: vi.fn(({ children, title, description, avatar, ...props }) => (
<div data-testid="form-action" {...props}>
<div data-testid="avatar">{avatar}</div>
<div data-testid="title">{title}</div>
<div data-testid="description">{description}</div>
{children}
</div>
)),
ErrorActionContainer: vi.fn(({ children, ...props }) => (
<div data-testid="error-action-container" {...props}>
{children}
</div>
)),
}));
describe('ComfyUIForm Integration', () => {
const mockProps = {
bedrockDescription: 'bedrock.description',
description: 'comfyui.description',
id: 'test-batch-id',
onClose: vi.fn(),
onRecreate: vi.fn(),
provider: ModelProvider.ComfyUI,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should use ComfyUI provider correctly', () => {
expect(ModelProvider.ComfyUI).toBe('comfyui');
});
it('should import APIKeyForm without errors', () => {
expect(APIKeyForm).toBeDefined();
});
});

View file

@ -8,6 +8,7 @@ import { Center, Flexbox } from 'react-layout-kit';
import { GlobalLLMProviderKey } from '@/types/user/settings';
import BedrockForm from './Bedrock';
import ComfyUIForm from './ComfyUIForm';
import { LoadingContext } from './LoadingContext';
import ProviderApiKeyForm from './ProviderApiKeyForm';
@ -67,9 +68,17 @@ const APIKeyForm = memo<APIKeyFormProps>(
return (
<LoadingContext value={{ loading, setLoading }}>
<Center gap={16} style={{ maxWidth: 300 }}>
<Center
gap={16}
style={{
maxWidth: provider === ModelProvider.ComfyUI ? 900 : 300,
width: provider === ModelProvider.ComfyUI ? '80%' : 'auto',
}}
>
{provider === ModelProvider.Bedrock ? (
<BedrockForm description={bedrockDescription} />
) : provider === ModelProvider.ComfyUI ? (
<ComfyUIForm description={description} />
) : (
<ProviderApiKeyForm
apiKeyPlaceholder={apiKeyPlaceholder}

View file

@ -0,0 +1,40 @@
import { ModelProviderCard } from '@/types/llm';
/**
* ComfyUI Provider Configuration
*
* ComfyUI
* FLUX
*
* @see https://www.comfy.org/
*/
const ComfyUI: ModelProviderCard = {
chatModels: [],
description:
'强大的开源图像、视频、音频生成工作流引擎,支持 SD FLUX Qwen Hunyuan WAN 等先进模型,提供节点化工作流编辑和私有化部署能力',
enabled: true,
id: 'comfyui',
name: 'ComfyUI',
settings: {
// 禁用浏览器直接请求,通过服务端代理
disableBrowserRequest: true,
// SDK 类型标识
sdkType: 'comfyui',
// 不显示添加新模型按钮(模型通过配置管理)
showAddNewModel: false,
// 显示 API 密钥配置(用于认证配置)
showApiKey: true,
// 不显示连通性检查(图像生成不支持聊天接口检查)
showChecker: false,
// 不显示模型获取器(使用预定义模型)
showModelFetcher: false,
},
url: 'https://www.comfy.org/',
};
export default ComfyUI;

View file

@ -15,6 +15,7 @@ import CerebrasProvider from './cerebras';
import CloudflareProvider from './cloudflare';
import CohereProvider from './cohere';
import CometAPIProvider from './cometapi';
import ComfyUIProvider from './comfyui';
import DeepSeekProvider from './deepseek';
import FalProvider from './fal';
import FireworksAIProvider from './fireworksai';
@ -129,6 +130,7 @@ export const DEFAULT_MODEL_PROVIDER_LIST = [
OllamaProvider,
OllamaCloudProvider,
VLLMProvider,
ComfyUIProvider,
XinferenceProvider,
AnthropicProvider,
BedrockProvider,
@ -214,6 +216,7 @@ export { default as CerebrasProviderCard } from './cerebras';
export { default as CloudflareProviderCard } from './cloudflare';
export { default as CohereProviderCard } from './cohere';
export { default as CometAPIProviderCard } from './cometapi';
export { default as ComfyUIProviderCard } from './comfyui';
export { default as DeepSeekProviderCard } from './deepseek';
export { default as FalProviderCard } from './fal';
export { default as FireworksAIProviderCard } from './fireworksai';

View file

@ -174,6 +174,14 @@ export const getLLMConfig = () => {
ENABLED_BFL: z.boolean(),
BFL_API_KEY: z.string().optional(),
ENABLED_COMFYUI: z.boolean(),
COMFYUI_BASE_URL: z.string().optional(),
COMFYUI_AUTH_TYPE: z.string().optional(),
COMFYUI_API_KEY: z.string().optional(),
COMFYUI_USERNAME: z.string().optional(),
COMFYUI_PASSWORD: z.string().optional(),
COMFYUI_CUSTOM_HEADERS: z.string().optional(),
ENABLED_MODELSCOPE: z.boolean(),
MODELSCOPE_API_KEY: z.string().optional(),
@ -370,6 +378,14 @@ export const getLLMConfig = () => {
ENABLED_BFL: !!process.env.BFL_API_KEY,
BFL_API_KEY: process.env.BFL_API_KEY,
ENABLED_COMFYUI: process.env.ENABLED_COMFYUI !== '0',
COMFYUI_BASE_URL: process.env.COMFYUI_BASE_URL,
COMFYUI_AUTH_TYPE: process.env.COMFYUI_AUTH_TYPE,
COMFYUI_API_KEY: process.env.COMFYUI_API_KEY,
COMFYUI_USERNAME: process.env.COMFYUI_USERNAME,
COMFYUI_PASSWORD: process.env.COMFYUI_PASSWORD,
COMFYUI_CUSTOM_HEADERS: process.env.COMFYUI_CUSTOM_HEADERS,
ENABLED_MODELSCOPE: !!process.env.MODELSCOPE_API_KEY,
MODELSCOPE_API_KEY: process.env.MODELSCOPE_API_KEY,

View file

@ -55,7 +55,9 @@ const getErrorAlertConfig = (
}
case AgentRuntimeErrorType.OllamaServiceUnavailable:
case AgentRuntimeErrorType.NoOpenAIAPIKey: {
case AgentRuntimeErrorType.NoOpenAIAPIKey:
case AgentRuntimeErrorType.ComfyUIServiceUnavailable:
case AgentRuntimeErrorType.InvalidComfyUIArgs: {
return {
extraDefaultExpand: true,
extraIsolate: true,

View file

@ -147,6 +147,15 @@ export default {
OllamaServiceUnavailable:
'Ollama 服务连接失败,请检查 Ollama 是否运行正常,或是否正确设置 Ollama 的跨域配置',
InvalidComfyUIArgs: 'ComfyUI 配置不正确,请检查 ComfyUI 配置后重试',
ComfyUIBizError: '请求 ComfyUI 服务出错,请根据以下信息排查或重试',
ComfyUIServiceUnavailable:
'ComfyUI 服务连接失败,请检查 ComfyUI 是否运行正常,或检查服务地址配置是否正确',
ComfyUIEmptyResult: 'ComfyUI 未生成任何图像,请检查模型配置或重试',
ComfyUIUploadFailed: 'ComfyUI 图片上传失败,请检查服务器连接或重试',
ComfyUIWorkflowError: 'ComfyUI 工作流执行失败,请检查工作流配置',
ComfyUIModelError: 'ComfyUI 模型加载失败,请检查模型文件是否存在',
AgentRuntimeError: 'Lobe AI Runtime 执行出错,请根据以下信息排查或重试',
// cloud
@ -179,6 +188,11 @@ export default {
title: '使用自定义 {{name}} API Key',
},
closeMessage: '关闭提示',
comfyui: {
description: '请输入正确的 {{name}} 认证信息即可开始生图',
modifyBaseUrl: '修改 Comfy UI 服务地址',
title: '确认你的 {{name}} 认证信息',
},
confirm: '确认并重试',
oauth: {
description: '管理员已开启统一登录认证,点击下方按钮登录,即可解锁应用',

View file

@ -85,6 +85,58 @@ export default {
title: 'Cloudflare 账户 ID / API 地址',
},
},
comfyui: {
apiKey: {
desc: 'Bearer Token 认证所需的 API 密钥',
placeholder: '请输入 API 密钥',
required: '请输入 API 密钥',
title: 'API 密钥',
},
authType: {
desc: '选择与 ComfyUI 服务器的认证方式',
options: {
basic: '账号/密码',
bearer: 'Bearer (API 密钥)',
custom: '自定义请求头',
none: '无需认证',
},
placeholder: '请选择认证类型',
title: '认证类型',
},
baseURL: {
desc: 'ComfyUI 网页访问地址',
placeholder: 'http://127.0.0.1:8000',
required: '请输入 ComfyUI 服务地址',
title: 'ComfyUI 服务地址',
},
checker: {
desc: '测试连接是否正确配置',
title: '连通性检查',
},
customHeaders: {
addButton: '添加请求头',
deleteTooltip: '删除此请求头',
desc: '自定义认证方式所需的请求头,格式为键值对',
duplicateKeyError: '请求头键名不能重复',
keyPlaceholder: '键名',
required: '请输入自定义请求头',
title: '自定义请求头',
valuePlaceholder: '值',
},
password: {
desc: '基本认证所需的密码',
placeholder: '请输入密码',
required: '请输入密码',
title: '密码',
},
title: 'ComfyUI',
username: {
desc: '基本认证所需的用户名',
placeholder: '请输入用户名',
required: '请输入用户名',
title: '用户名',
},
},
createNewAiProvider: {
apiKey: {
placeholder: '请填写你的 API Key',

View file

@ -1,6 +1,6 @@
import { ProviderConfig } from '@lobechat/types';
import { extractEnabledModels, transformToAiModelList } from '@lobechat/utils';
import { ModelProvider , AiFullModelCard } from 'model-bank';
import { AiFullModelCard, ModelProvider } from 'model-bank';
import * as AiModels from 'model-bank';
import { getLLMConfig } from '@/envs/llm';

View file

@ -3,6 +3,7 @@ import {
LobeAnthropicAI,
LobeAzureOpenAI,
LobeBedrockAI,
LobeComfyUI,
LobeDeepSeekAI,
LobeGoogleAI,
LobeGroq,
@ -249,6 +250,49 @@ describe('initModelRuntimeWithUserPayload method', () => {
expect(runtime['_runtime']).toBeInstanceOf(LobeStepfunAI);
});
it('ComfyUI provider: with multiple auth types', async () => {
// Test basic auth
const basicAuthPayload: ClientSecretPayload = {
authType: 'basic',
username: 'test-user',
password: 'test-pass',
baseURL: 'http://localhost:8188',
};
let runtime = await initModelRuntimeWithUserPayload(ModelProvider.ComfyUI, basicAuthPayload);
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeComfyUI);
expect(runtime['_runtime'].baseURL).toBe(basicAuthPayload.baseURL);
// Test bearer auth
const bearerAuthPayload: ClientSecretPayload = {
authType: 'bearer',
apiKey: 'test-token',
baseURL: 'http://localhost:8188',
};
runtime = await initModelRuntimeWithUserPayload(ModelProvider.ComfyUI, bearerAuthPayload);
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeComfyUI);
// Test custom auth
const customAuthPayload: ClientSecretPayload = {
authType: 'custom',
customHeaders: { 'X-API-Key': 'secret123' },
baseURL: 'http://localhost:8188',
};
runtime = await initModelRuntimeWithUserPayload(ModelProvider.ComfyUI, customAuthPayload);
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeComfyUI);
// Test none auth
const noAuthPayload: ClientSecretPayload = {
authType: 'none',
baseURL: 'http://localhost:8188',
};
runtime = await initModelRuntimeWithUserPayload(ModelProvider.ComfyUI, noAuthPayload);
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeComfyUI);
});
it('Unknown Provider: with apikey and endpoint, should initialize to OpenAi', async () => {
const jwtPayload: ClientSecretPayload = {
apiKey: 'user-unknown-key',
@ -420,6 +464,28 @@ describe('initModelRuntimeWithUserPayload method', () => {
expect(runtime['_runtime'].baseURL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1');
});
it('ComfyUI provider: without user payload (using environment variables)', async () => {
const jwtPayload: ClientSecretPayload = {};
const runtime = await initModelRuntimeWithUserPayload(ModelProvider.ComfyUI, jwtPayload);
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeComfyUI);
// Should use environment variable defaults
expect(runtime['_runtime'].baseURL).toBe('http://127.0.0.1:8000');
});
it('ComfyUI provider: partial payload (mixed with env vars)', async () => {
const jwtPayload: ClientSecretPayload = {
baseURL: 'http://custom-comfyui:8188',
// authType, username, password will come from env vars
};
const runtime = await initModelRuntimeWithUserPayload(ModelProvider.ComfyUI, jwtPayload);
expect(runtime).toBeInstanceOf(ModelRuntime);
expect(runtime['_runtime']).toBeInstanceOf(LobeComfyUI);
expect(runtime['_runtime'].baseURL).toBe('http://custom-comfyui:8188');
});
it('Unknown Provider', async () => {
const jwtPayload = {};
const runtime = await initModelRuntimeWithUserPayload('unknown', jwtPayload);

View file

@ -89,6 +89,41 @@ const getParamsFromPayload = (provider: string, payload: ClientSecretPayload) =>
return { apiKey, baseURLOrAccountID };
}
case ModelProvider.ComfyUI: {
const {
COMFYUI_BASE_URL,
COMFYUI_AUTH_TYPE,
COMFYUI_API_KEY,
COMFYUI_USERNAME,
COMFYUI_PASSWORD,
COMFYUI_CUSTOM_HEADERS,
} = llmConfig;
// ComfyUI specific handling with environment variables fallback
const baseURL = payload?.baseURL || COMFYUI_BASE_URL || 'http://127.0.0.1:8000';
// ComfyUI supports multiple auth types: none, basic, bearer, custom
// Extract all relevant auth fields from the payload or environment
const authType = payload?.authType || COMFYUI_AUTH_TYPE || 'none';
const apiKey = payload?.apiKey || COMFYUI_API_KEY;
const username = payload?.username || COMFYUI_USERNAME;
const password = payload?.password || COMFYUI_PASSWORD;
// Parse customHeaders from JSON string (similar to Vertex AI credentials handling)
// Support both payload object and environment variable JSON string
const customHeaders = payload?.customHeaders || safeParseJSON(COMFYUI_CUSTOM_HEADERS);
// Return all authentication parameters
return {
apiKey,
authType,
baseURL,
customHeaders,
password,
username,
};
}
case ModelProvider.GiteeAI: {
const { GITEE_AI_API_KEY } = llmConfig;

View file

@ -59,15 +59,79 @@ const checkAbortSignal = (signal: AbortSignal) => {
/**
* Categorizes errors into appropriate AsyncTaskErrorType
* Returns the original error message if available, otherwise returns the error type as message
* Client should handle localization based on errorType
*/
const categorizeError = (
error: any,
isAborted: boolean,
): { errorMessage: string; errorType: AsyncTaskErrorType } => {
log('🔥🔥🔥 [ASYNC] categorizeError called:', {
errorMessage: error?.message,
errorName: error?.name,
errorStatus: error?.status,
errorType: error?.errorType,
fullError: JSON.stringify(error, null, 2),
isAborted,
});
// Handle Comfy UI errors
if (error.errorType === AgentRuntimeErrorType.ComfyUIServiceUnavailable) {
return {
errorMessage:
error.error?.message || error.message || AgentRuntimeErrorType.ComfyUIServiceUnavailable,
errorType: AsyncTaskErrorType.InvalidProviderAPIKey,
};
}
if (error.errorType === AgentRuntimeErrorType.ComfyUIBizError) {
return {
errorMessage: error.error?.message || error.message || AgentRuntimeErrorType.ComfyUIBizError,
errorType: AsyncTaskErrorType.ServerError,
};
}
if (error.errorType === AgentRuntimeErrorType.ComfyUIWorkflowError) {
return {
errorMessage:
error.error?.message || error.message || AgentRuntimeErrorType.ComfyUIWorkflowError,
errorType: AsyncTaskErrorType.ServerError,
};
}
if (error.errorType === AgentRuntimeErrorType.ComfyUIModelError) {
return {
errorMessage:
error.error?.message || error.message || AgentRuntimeErrorType.ComfyUIModelError,
errorType: AsyncTaskErrorType.ModelNotFound,
};
}
if (error.errorType === AgentRuntimeErrorType.ConnectionCheckFailed) {
return {
errorMessage: error.message || AgentRuntimeErrorType.ConnectionCheckFailed,
errorType: AsyncTaskErrorType.ServerError,
};
}
if (error.errorType === AgentRuntimeErrorType.PermissionDenied) {
return {
errorMessage: error.error?.message || error.message || AgentRuntimeErrorType.PermissionDenied,
errorType: AsyncTaskErrorType.InvalidProviderAPIKey,
};
}
if (error.errorType === AgentRuntimeErrorType.ModelNotFound) {
return {
errorMessage: error.error?.message || error.message || AgentRuntimeErrorType.ModelNotFound,
errorType: AsyncTaskErrorType.ModelNotFound,
};
}
// FIXME: 401 的问题应该放到 agentRuntime 中处理会更好
if (error.errorType === AgentRuntimeErrorType.InvalidProviderAPIKey || error?.status === 401) {
return {
errorMessage: 'Invalid provider API key, please check your API key',
errorMessage:
error.error?.message || error.message || AgentRuntimeErrorType.InvalidProviderAPIKey,
errorType: AsyncTaskErrorType.InvalidProviderAPIKey,
};
}
@ -81,27 +145,27 @@ const categorizeError = (
if (isAborted || error.message?.includes('aborted')) {
return {
errorMessage: 'Image generation task timed out, please try again',
errorMessage: AsyncTaskErrorType.Timeout,
errorType: AsyncTaskErrorType.Timeout,
};
}
if (error.message?.includes('timeout') || error.name === 'TimeoutError') {
return {
errorMessage: 'Image generation task timed out, please try again',
errorMessage: AsyncTaskErrorType.Timeout,
errorType: AsyncTaskErrorType.Timeout,
};
}
if (error.message?.includes('network') || error.name === 'NetworkError') {
return {
errorMessage: error.message || 'Network error occurred during image generation',
errorMessage: error.message || AsyncTaskErrorType.ServerError,
errorType: AsyncTaskErrorType.ServerError,
};
}
return {
errorMessage: error.message || 'Unknown error occurred during image generation',
errorMessage: error.message || AsyncTaskErrorType.ServerError,
errorType: AsyncTaskErrorType.ServerError,
};
};
@ -139,7 +203,6 @@ export const imageRouter = router({
// Check if operation has been cancelled
checkAbortSignal(signal);
log('Agent runtime initialized, calling createImage');
const response = await agentRuntime.createImage!({
model,
@ -171,8 +234,24 @@ export const imageRouter = router({
log('Transforming image for generation');
const { imageUrl, width, height } = response;
const { image, thumbnailImage } =
await ctx.generationService.transformImageForGeneration(imageUrl);
// Extract ComfyUI authentication headers if provider is ComfyUI
let authHeaders: Record<string, string> | undefined;
if (provider === 'comfyui') {
// Use the public interface method to get auth headers
// This avoids accessing private members and exposing credentials
authHeaders = agentRuntime.getAuthHeaders();
if (authHeaders) {
log('Using authentication headers for ComfyUI image download');
} else {
log('No authentication configured for ComfyUI');
}
}
const { image, thumbnailImage } = await ctx.generationService.transformImageForGeneration(
imageUrl,
authHeaders,
);
// Check if operation has been cancelled
checkAbortSignal(signal);

View file

@ -0,0 +1,96 @@
import type { ComfyUIKeyVault } from '@lobechat/types';
import { z } from 'zod';
import { authedProcedure, router } from '@/libs/trpc/lambda';
// Import Framework layer services
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
import { ImageService } from '@/server/services/comfyui/core/imageService';
import { ModelResolverService } from '@/server/services/comfyui/core/modelResolverService';
import { WorkflowBuilderService } from '@/server/services/comfyui/core/workflowBuilderService';
import type { WorkflowContext } from '@/server/services/comfyui/types';
// ComfyUI params validation - only validate required fields
// Other RuntimeImageGenParams fields are passed through automatically
const ComfyUIParamsSchema = z
.object({
prompt: z.string(), // 只验证必需字段
})
.passthrough();
/**
* ComfyUI tRPC Router
* Exposes Framework layer services to Runtime layer
*/
export const comfyuiRouter = router({
/**
* Create image with complete business logic
*/
createImage: authedProcedure
.input(
z.object({
model: z.string(),
options: z.custom<ComfyUIKeyVault>().optional(),
params: ComfyUIParamsSchema,
}),
)
.mutation(async ({ input }) => {
const { model, params, options = {} } = input;
// Initialize Framework layer services
const clientService = new ComfyUIClientService(options);
const modelResolverService = new ModelResolverService(clientService);
// Create workflow context
const context: WorkflowContext = {
clientService,
modelResolverService,
};
const workflowBuilderService = new WorkflowBuilderService(context);
// Initialize image service with all dependencies
const imageService = new ImageService(
clientService,
modelResolverService,
workflowBuilderService,
);
// Execute image creation
return imageService.createImage({
model,
params,
});
}),
/**
* Get authentication headers for image downloads
*/
getAuthHeaders: authedProcedure
.input(
z.object({
options: z.custom<ComfyUIKeyVault>().optional(),
}),
)
.query(async ({ input }) => {
const clientService = new ComfyUIClientService(input.options || {});
return clientService.getAuthHeaders();
}),
/**
* Get available models
*/
getModels: authedProcedure
.input(
z.object({
options: z.custom<ComfyUIKeyVault>().optional(),
}),
)
.query(async ({ input }) => {
const clientService = new ComfyUIClientService(input.options || {});
const modelResolverService = new ModelResolverService(clientService);
return modelResolverService.getAvailableModelFiles();
}),
});
export type ComfyUIRouter = typeof comfyuiRouter;

View file

@ -9,6 +9,7 @@ import { aiModelRouter } from './aiModel';
import { aiProviderRouter } from './aiProvider';
import { apiKeyRouter } from './apiKey';
import { chunkRouter } from './chunk';
import { comfyuiRouter } from './comfyui';
import { configRouter } from './config';
import { documentRouter } from './document';
import { exporterRouter } from './exporter';
@ -38,6 +39,7 @@ export const lambdaRouter = router({
aiProvider: aiProviderRouter,
apiKey: apiKeyRouter,
chunk: chunkRouter,
comfyui: comfyuiRouter,
config: configRouter,
document: documentRouter,
exporter: exporterRouter,

View file

@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
COMFYUI_DEFAULTS,
CUSTOM_SD_CONFIG,
DEFAULT_NEGATIVE_PROMPT,
FLUX_MODEL_CONFIG,
SD_MODEL_CONFIG,
WORKFLOW_DEFAULTS,
} from '@/server/services/comfyui/config/constants';
import { STYLE_KEYWORDS } from '@/server/services/comfyui/config/promptToolConst';
describe('ComfyUI Constants', () => {
describe('COMFYUI_DEFAULTS', () => {
it('should be a valid object', () => {
expect(typeof COMFYUI_DEFAULTS).toBe('object');
expect(COMFYUI_DEFAULTS).toBeDefined();
});
});
describe('FLUX_MODEL_CONFIG', () => {
it('should have correct filename prefixes', () => {
expect(FLUX_MODEL_CONFIG.FILENAME_PREFIXES.SCHNELL).toContain('FLUX_Schnell');
expect(FLUX_MODEL_CONFIG.FILENAME_PREFIXES.DEV).toContain('FLUX_Dev');
expect(FLUX_MODEL_CONFIG.FILENAME_PREFIXES.KONTEXT).toContain('FLUX_Kontext');
expect(FLUX_MODEL_CONFIG.FILENAME_PREFIXES.KREA).toContain('FLUX_Krea');
});
it('should have all required prefixes', () => {
const expectedKeys = ['SCHNELL', 'DEV', 'KONTEXT', 'KREA'];
expect(Object.keys(FLUX_MODEL_CONFIG.FILENAME_PREFIXES)).toEqual(
expect.arrayContaining(expectedKeys),
);
});
it('should be a readonly object (TypeScript as const)', () => {
// `as const` provides readonly types in TypeScript, not runtime freezing
expect(typeof FLUX_MODEL_CONFIG).toBe('object');
});
});
describe('WORKFLOW_DEFAULTS', () => {
it('should have valid workflow parameters', () => {
expect(WORKFLOW_DEFAULTS.IMAGE.BATCH_SIZE).toBeGreaterThan(0);
expect(WORKFLOW_DEFAULTS.SAMPLING.DENOISE).toBeGreaterThanOrEqual(0);
expect(WORKFLOW_DEFAULTS.SAMPLING.DENOISE).toBeLessThanOrEqual(1);
expect(WORKFLOW_DEFAULTS.SAMPLING.MAX_SHIFT).toBeGreaterThan(0);
expect(WORKFLOW_DEFAULTS.SD3.SHIFT).toBeGreaterThan(0);
});
it('should be a readonly object (TypeScript as const)', () => {
// `as const` provides readonly types in TypeScript, not runtime freezing
expect(typeof WORKFLOW_DEFAULTS).toBe('object');
});
});
describe('STYLE_KEYWORDS', () => {
it('should have all required categories', () => {
const expectedCategories = [
'ARTISTS',
'ART_STYLES',
'LIGHTING',
'PHOTOGRAPHY',
'QUALITY',
'RENDERING',
];
expect(Object.keys(STYLE_KEYWORDS)).toEqual(expect.arrayContaining(expectedCategories));
});
it('should have non-empty arrays for each category', () => {
Object.values(STYLE_KEYWORDS).forEach((keywords) => {
expect(Array.isArray(keywords)).toBe(true);
expect(keywords.length).toBeGreaterThan(0);
});
});
it('should contain expected artist keywords', () => {
expect(STYLE_KEYWORDS.ARTISTS).toEqual(
expect.arrayContaining(['by greg rutkowski', 'by artgerm', 'trending on artstation']),
);
});
it('should contain expected art style keywords', () => {
expect(STYLE_KEYWORDS.ART_STYLES).toEqual(
expect.arrayContaining(['photorealistic', 'anime', 'digital art', '3d render']),
);
});
it('should contain expected lighting keywords', () => {
expect(STYLE_KEYWORDS.LIGHTING).toEqual(
expect.arrayContaining(['dramatic lighting', 'studio lighting', 'soft lighting']),
);
});
it('should contain expected photography keywords', () => {
expect(STYLE_KEYWORDS.PHOTOGRAPHY).toEqual(
expect.arrayContaining([
'depth of field',
'bokeh',
'35mm photograph',
'professional photograph',
]),
);
});
it('should contain expected quality keywords', () => {
expect(STYLE_KEYWORDS.QUALITY).toEqual(
expect.arrayContaining([
'masterpiece',
'best quality',
'high quality',
'extremely detailed',
]),
);
});
it('should contain expected rendering keywords', () => {
expect(STYLE_KEYWORDS.RENDERING).toEqual(
expect.arrayContaining(['octane render', 'unreal engine', 'ray tracing', 'cycles render']),
);
});
});
describe('DEFAULT_NEGATIVE_PROMPT', () => {
it('should be defined and non-empty', () => {
expect(DEFAULT_NEGATIVE_PROMPT).toBeDefined();
expect(DEFAULT_NEGATIVE_PROMPT).not.toBe('');
});
});
describe('CUSTOM_SD_CONFIG', () => {
it('should have model and VAE filenames', () => {
expect(CUSTOM_SD_CONFIG.MODEL_FILENAME).toBeDefined();
expect(CUSTOM_SD_CONFIG.VAE_FILENAME).toBeDefined();
});
});
describe('SD_MODEL_CONFIG', () => {
it('should have correct filename prefixes', () => {
expect(SD_MODEL_CONFIG.FILENAME_PREFIXES.SD15).toContain('SD15');
expect(SD_MODEL_CONFIG.FILENAME_PREFIXES.SD35).toContain('SD35');
expect(SD_MODEL_CONFIG.FILENAME_PREFIXES.SDXL).toContain('SDXL');
expect(SD_MODEL_CONFIG.FILENAME_PREFIXES.CUSTOM).toContain('CustomSD');
});
});
});

View file

@ -0,0 +1,277 @@
import { describe, expect, it } from 'vitest';
import { MODEL_REGISTRY } from '@/server/services/comfyui/config/modelRegistry';
import {
getAllModelNames,
getModelConfig,
getModelsByVariant,
} from '@/server/services/comfyui/utils/staticModelLookup';
describe('ModelRegistry', () => {
describe('MODEL_REGISTRY', () => {
it('should be a non-empty object with valid structure', () => {
expect(typeof MODEL_REGISTRY).toBe('object');
expect(Object.keys(MODEL_REGISTRY).length).toBeGreaterThan(0);
// Check that all models have required fields
Object.entries(MODEL_REGISTRY).forEach(([, config]) => {
expect(config).toBeDefined();
expect(config.modelFamily).toBeDefined();
expect(config.priority).toBeTypeOf('number');
if (config.recommendedDtype) {
expect(
['default', 'fp8_e4m3fn', 'fp8_e4m3fn_fast', 'fp8_e5m2'].includes(
config.recommendedDtype,
),
).toBe(true);
}
});
});
it('should contain essential model families', () => {
const modelFamilies = Object.values(MODEL_REGISTRY).map((c) => c.modelFamily);
const uniqueFamilies = [...new Set(modelFamilies)];
// Should have at least one model family and FLUX should be included
expect(uniqueFamilies.length).toBeGreaterThan(0);
expect(uniqueFamilies).toContain('FLUX');
});
it('should have valid priority ranges', () => {
Object.entries(MODEL_REGISTRY).forEach(([, config]) => {
// Priorities should be positive numbers
expect(config.priority).toBeGreaterThan(0);
expect(config.priority).toBeLessThanOrEqual(10);
});
});
});
describe('getModelConfig', () => {
it('should return model config for valid name', () => {
// Get any available FLUX model instead of hardcoding
const allModelNames = getAllModelNames();
const fluxModels = allModelNames.filter((name) => {
const config = getModelConfig(name);
return config?.modelFamily === 'FLUX';
});
expect(fluxModels.length).toBeGreaterThan(0);
const config = getModelConfig(fluxModels[0]);
expect(config).toBeDefined();
expect(config?.modelFamily).toBe('FLUX');
});
it('should return undefined for invalid name', () => {
const config = getModelConfig('nonexistent.safetensors');
expect(config).toBeUndefined();
});
});
describe('getAllModelNames', () => {
it('should return all model names', () => {
const names = getAllModelNames();
expect(names.length).toBeGreaterThan(0);
// Check if at least one FLUX model exists instead of hardcoding
const hasFluxModel = names.some((name) => {
const config = getModelConfig(name);
return config?.modelFamily === 'FLUX';
});
expect(hasFluxModel).toBe(true);
});
it('should return unique names', () => {
const names = getAllModelNames();
const uniqueNames = [...new Set(names)];
expect(uniqueNames.length).toBe(names.length);
});
});
describe('getModelsByVariant', () => {
it('should return model names for valid variant', () => {
const modelNames = getModelsByVariant('dev');
expect(modelNames.length).toBeGreaterThan(0);
expect(Array.isArray(modelNames)).toBe(true);
// Verify all returned names are strings and correspond to dev variant models
modelNames.forEach((name) => {
expect(typeof name).toBe('string');
const config = getModelConfig(name);
expect(config).toBeDefined();
expect(config?.variant).toBe('dev');
});
});
it('should return models sorted by priority', () => {
const modelNames = getModelsByVariant('dev');
expect(modelNames.length).toBeGreaterThan(1);
// Verify priority sorting (lower priority number = higher priority)
for (let i = 0; i < modelNames.length - 1; i++) {
const config1 = getModelConfig(modelNames[i]);
const config2 = getModelConfig(modelNames[i + 1]);
expect(config1?.priority).toBeLessThanOrEqual(config2?.priority || 0);
}
});
it('should return empty array for invalid variant', () => {
const models = getModelsByVariant('nonexistent' as any);
expect(models).toEqual([]);
});
});
describe('getModelConfig with options', () => {
it('should support case-insensitive lookup', () => {
// Get any FLUX dev model for testing case-insensitive lookup
const allModels = getAllModelNames();
const fluxDevModel = allModels.find((name) => {
const config = getModelConfig(name);
return config?.modelFamily === 'FLUX' && config?.variant === 'dev';
});
if (fluxDevModel) {
const config = getModelConfig(fluxDevModel.toUpperCase(), { caseInsensitive: true });
expect(config).toBeDefined();
expect(config?.modelFamily).toBe('FLUX');
expect(config?.variant).toBe('dev');
} else {
// If no dev variant exists, test with any FLUX model
const fluxModel = allModels.find((name) => {
const config = getModelConfig(name);
return config?.modelFamily === 'FLUX';
});
expect(fluxModel).toBeDefined();
const config = getModelConfig(fluxModel!.toUpperCase(), { caseInsensitive: true });
expect(config).toBeDefined();
expect(config?.modelFamily).toBe('FLUX');
}
});
it('should return undefined for non-matching case without caseInsensitive option', () => {
// Find any FLUX model and test uppercase version without case-insensitive flag
const allModels = getAllModelNames();
const fluxModel = allModels.find((name) => {
const config = getModelConfig(name);
return config?.modelFamily === 'FLUX';
});
if (fluxModel) {
const config = getModelConfig(fluxModel.toUpperCase());
expect(config).toBeUndefined();
}
});
it('should filter by variant', () => {
// Find models with different variants for testing
const allModels = getAllModelNames();
const devModel = allModels.find((name) => {
const config = getModelConfig(name);
return config?.variant === 'dev';
});
if (devModel) {
// Test matching variant
const config = getModelConfig(devModel, { variant: 'dev' });
expect(config).toBeDefined();
expect(config?.variant).toBe('dev');
// Test non-matching variant
const nonMatchingConfig = getModelConfig(devModel, { variant: 'schnell' });
expect(nonMatchingConfig).toBeUndefined();
}
});
it('should filter by modelFamily', () => {
// 测试 SD3.5 模型家族
const config = getModelConfig('sd3.5_large.safetensors', { modelFamily: 'SD3' });
expect(config).toBeDefined();
expect(config?.modelFamily).toBe('SD3');
// 测试不匹配的 modelFamily
const nonMatchingConfig = getModelConfig('sd3.5_large.safetensors', { modelFamily: 'FLUX' });
expect(nonMatchingConfig).toBeUndefined();
});
it('should filter by priority', () => {
// Find a model with priority 1 for testing
const allModels = getAllModelNames();
const priority1Model = allModels.find((name) => {
const config = getModelConfig(name);
return config?.priority === 1;
});
if (priority1Model) {
const config = getModelConfig(priority1Model, { priority: 1 });
expect(config).toBeDefined();
// Test non-matching priority
const nonMatchingConfig = getModelConfig(priority1Model, { priority: 999 });
expect(nonMatchingConfig).toBeUndefined();
}
});
it('should filter by recommendedDtype', () => {
// flux_shakker_labs_union_pro-fp8_e4m3fn 有 fp8_e4m3fn
const config = getModelConfig('flux_shakker_labs_union_pro-fp8_e4m3fn.safetensors', {
recommendedDtype: 'fp8_e4m3fn',
});
expect(config).toBeDefined();
expect(config?.recommendedDtype).toBe('fp8_e4m3fn');
// 测试不匹配的 recommendedDtype
const nonMatchingConfig = getModelConfig(
'flux_shakker_labs_union_pro-fp8_e4m3fn.safetensors',
{ recommendedDtype: 'default' },
);
expect(nonMatchingConfig).toBeUndefined();
});
it('should combine multiple filters', () => {
// Find a FLUX dev model with priority 1 for testing
const allModels = getAllModelNames();
const testModel = allModels.find((name) => {
const config = getModelConfig(name);
return (
config?.modelFamily === 'FLUX' && config?.variant === 'dev' && config?.priority === 1
);
});
if (testModel) {
// All filters match
const config = getModelConfig(testModel, {
modelFamily: 'FLUX',
priority: 1,
variant: 'dev',
});
expect(config).toBeDefined();
// One filter doesn't match
const nonMatchingConfig = getModelConfig(testModel, {
modelFamily: 'FLUX',
priority: 999, // Wrong priority
variant: 'dev',
});
expect(nonMatchingConfig).toBeUndefined();
}
});
it('should handle case-insensitive with other filters', () => {
// Find a FLUX dev model for testing
const allModels = getAllModelNames();
const fluxDevModel = allModels.find((name) => {
const config = getModelConfig(name);
return config?.modelFamily === 'FLUX' && config?.variant === 'dev';
});
if (fluxDevModel) {
const config = getModelConfig(fluxDevModel.toUpperCase(), {
caseInsensitive: true,
modelFamily: 'FLUX',
variant: 'dev',
});
expect(config).toBeDefined();
}
});
});
});

View file

@ -0,0 +1,357 @@
import { describe, expect, it } from 'vitest';
import {
COMPOUND_STYLES,
STYLE_ADJECTIVE_PATTERNS,
STYLE_KEYWORDS,
STYLE_SYNONYMS,
extractStyleAdjectives,
getAllStyleKeywords,
getCompoundStyles,
isStyleAdjective,
normalizeStyleTerm,
} from '@/server/services/comfyui/config/promptToolConst';
describe('promptToolConst', () => {
describe('STYLE_KEYWORDS', () => {
it('should have all expected categories', () => {
const expectedCategories = [
'ARTISTS',
'ART_STYLES',
'LIGHTING',
'PHOTOGRAPHY',
'QUALITY',
'RENDERING',
'COLOR_MOOD',
'TEXTURE_MATERIAL',
];
expect(Object.keys(STYLE_KEYWORDS)).toEqual(expectedCategories);
});
it('should have expanded keywords in each category', () => {
// Minimum expectations to allow expansion
expect(STYLE_KEYWORDS.ARTISTS.length).toBeGreaterThanOrEqual(20);
expect(STYLE_KEYWORDS.ART_STYLES.length).toBeGreaterThanOrEqual(52);
expect(STYLE_KEYWORDS.LIGHTING.length).toBeGreaterThanOrEqual(37);
expect(STYLE_KEYWORDS.PHOTOGRAPHY.length).toBeGreaterThanOrEqual(49);
expect(STYLE_KEYWORDS.QUALITY.length).toBeGreaterThanOrEqual(39);
expect(STYLE_KEYWORDS.RENDERING.length).toBeGreaterThanOrEqual(39);
expect(STYLE_KEYWORDS.COLOR_MOOD.length).toBeGreaterThanOrEqual(56);
expect(STYLE_KEYWORDS.TEXTURE_MATERIAL.length).toBeGreaterThanOrEqual(60);
});
it('should not have duplicate keywords within categories', () => {
Object.entries(STYLE_KEYWORDS).forEach(([, keywords]: [string, readonly string[]]) => {
const uniqueKeywords = [...new Set(keywords)];
expect(keywords.length).toBe(uniqueKeywords.length);
});
});
it('should have lowercase keywords', () => {
Object.values(STYLE_KEYWORDS).forEach((keywords: readonly string[]) => {
keywords.forEach((keyword: string) => {
expect(keyword).toBe(keyword.toLowerCase());
});
});
});
});
describe('STYLE_SYNONYMS', () => {
it('should have synonym mappings', () => {
// Minimum number of synonym groups to allow expansion
expect(Object.keys(STYLE_SYNONYMS).length).toBeGreaterThanOrEqual(15);
expect(Object.keys(STYLE_SYNONYMS).length).toBeLessThanOrEqual(50); // Reasonable upper bound
});
it('should map common variations', () => {
expect(STYLE_SYNONYMS['photorealistic']).toContain('photo-realistic');
expect(STYLE_SYNONYMS['photorealistic']).toContain('photo realistic');
expect(STYLE_SYNONYMS['photorealistic']).toContain('lifelike');
expect(STYLE_SYNONYMS['4k']).toContain('4k resolution');
expect(STYLE_SYNONYMS['4k']).toContain('ultra hd');
expect(STYLE_SYNONYMS['cinematic']).toContain('filmic');
expect(STYLE_SYNONYMS['cinematic']).toContain('movie-like');
});
it('should have unique synonyms for each key', () => {
Object.entries(STYLE_SYNONYMS).forEach(([, synonyms]: [string, string[]]) => {
const uniqueSynonyms = [...new Set(synonyms)];
expect(synonyms.length).toBe(uniqueSynonyms.length);
});
});
it('should not have overlapping synonyms between different keys', () => {
const allSynonyms: string[] = [];
const duplicates: string[] = [];
Object.values(STYLE_SYNONYMS).forEach((synonyms: string[]) => {
synonyms.forEach((synonym: string) => {
if (allSynonyms.includes(synonym)) {
duplicates.push(synonym);
}
allSynonyms.push(synonym);
});
});
expect(duplicates).toEqual([]);
});
});
describe('COMPOUND_STYLES', () => {
it('should have compound style definitions', () => {
// Minimum range to allow expansion
expect(COMPOUND_STYLES.length).toBeGreaterThanOrEqual(35);
expect(COMPOUND_STYLES.length).toBeLessThanOrEqual(150); // Reasonable upper bound
});
it('should include expected compound styles', () => {
expect(COMPOUND_STYLES).toContain('studio ghibli style');
expect(COMPOUND_STYLES).toContain('cinematic lighting');
expect(COMPOUND_STYLES).toContain('dramatic lighting');
expect(COMPOUND_STYLES).toContain('depth of field');
expect(COMPOUND_STYLES).toContain('physically based rendering');
expect(COMPOUND_STYLES).toContain('global illumination');
});
it('should have unique compound styles', () => {
const uniqueStyles = [...new Set(COMPOUND_STYLES)];
expect(COMPOUND_STYLES.length).toBe(uniqueStyles.length);
});
it('should have lowercase compound styles', () => {
COMPOUND_STYLES.forEach((style: string) => {
expect(style).toBe(style.toLowerCase());
});
});
});
describe('STYLE_ADJECTIVE_PATTERNS', () => {
it('should have all expected pattern categories', () => {
const expectedPatterns = [
'quality',
'artistic',
'visual',
'mood',
'texture',
'scale',
'detail',
'professional',
];
expect(Object.keys(STYLE_ADJECTIVE_PATTERNS)).toEqual(expectedPatterns);
});
it('should match expected adjectives', () => {
// Quality patterns
expect(STYLE_ADJECTIVE_PATTERNS.quality.test('sharp')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.quality.test('blurry')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.quality.test('crisp')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.quality.test('walking')).toBe(false);
// Artistic patterns
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('abstract')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('surreal')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('minimal')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('minimalist')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('running')).toBe(false);
// Visual patterns
expect(STYLE_ADJECTIVE_PATTERNS.visual.test('bright')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.visual.test('dark')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.visual.test('vibrant')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.visual.test('opened')).toBe(false);
// Mood patterns
expect(STYLE_ADJECTIVE_PATTERNS.mood.test('dramatic')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.mood.test('peaceful')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.mood.test('mysterious')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.mood.test('walking')).toBe(false);
});
it('should be case insensitive', () => {
expect(STYLE_ADJECTIVE_PATTERNS.quality.test('Sharp')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.quality.test('SHARP')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('Abstract')).toBe(true);
expect(STYLE_ADJECTIVE_PATTERNS.artistic.test('ABSTRACT')).toBe(true);
});
});
describe('getAllStyleKeywords', () => {
it('should return flattened array of all keywords', () => {
const allKeywords = getAllStyleKeywords();
expect(Array.isArray(allKeywords)).toBe(true);
// Minimum range for total keywords to allow expansion
expect(allKeywords.length).toBeGreaterThanOrEqual(350);
expect(allKeywords.length).toBeLessThanOrEqual(750);
// Check that it contains keywords from different categories
expect(allKeywords).toContain('by greg rutkowski');
expect(allKeywords).toContain('photorealistic');
expect(allKeywords).toContain('dramatic lighting');
expect(allKeywords).toContain('bokeh');
expect(allKeywords).toContain('masterpiece');
});
it('should return readonly array', () => {
const keywords = getAllStyleKeywords();
// TypeScript will enforce readonly at compile time
expect(Object.isFrozen(keywords) || Array.isArray(keywords)).toBe(true);
});
});
describe('getCompoundStyles', () => {
it('should return compound styles array', () => {
const compounds = getCompoundStyles();
expect(Array.isArray(compounds)).toBe(true);
// Minimum range to allow expansion
expect(compounds.length).toBeGreaterThanOrEqual(35);
expect(compounds.length).toBeLessThanOrEqual(150);
expect(compounds).toContain('studio ghibli style');
expect(compounds).toContain('cinematic lighting');
});
it('should return the same array as COMPOUND_STYLES', () => {
const compounds = getCompoundStyles();
expect(compounds).toEqual(COMPOUND_STYLES);
});
});
describe('normalizeStyleTerm', () => {
it('should normalize known synonyms', () => {
expect(normalizeStyleTerm('photo-realistic')).toBe('photorealistic');
expect(normalizeStyleTerm('photo realistic')).toBe('photorealistic');
expect(normalizeStyleTerm('lifelike')).toBe('photorealistic');
expect(normalizeStyleTerm('4k resolution')).toBe('4k');
expect(normalizeStyleTerm('ultra hd')).toBe('4k');
expect(normalizeStyleTerm('filmic')).toBe('cinematic');
expect(normalizeStyleTerm('movie-like')).toBe('cinematic');
});
it('should return original term if not a synonym', () => {
expect(normalizeStyleTerm('unknown-term')).toBe('unknown-term');
expect(normalizeStyleTerm('random')).toBe('random');
expect(normalizeStyleTerm('test')).toBe('test');
});
it('should handle case insensitive matching', () => {
expect(normalizeStyleTerm('Photo-Realistic')).toBe('photorealistic');
expect(normalizeStyleTerm('PHOTO REALISTIC')).toBe('photorealistic');
expect(normalizeStyleTerm('Filmic')).toBe('cinematic');
});
it('should handle empty or invalid input', () => {
expect(normalizeStyleTerm('')).toBe('');
expect(normalizeStyleTerm(' ')).toBe(' ');
});
});
describe('isStyleAdjective', () => {
it('should identify style adjectives', () => {
// Quality adjectives
expect(isStyleAdjective('sharp')).toBe(true);
expect(isStyleAdjective('blurry')).toBe(true);
expect(isStyleAdjective('crisp')).toBe(true);
// Artistic adjectives
expect(isStyleAdjective('abstract')).toBe(true);
expect(isStyleAdjective('surreal')).toBe(true);
expect(isStyleAdjective('minimal')).toBe(true);
// Visual adjectives
expect(isStyleAdjective('bright')).toBe(true);
expect(isStyleAdjective('dark')).toBe(true);
expect(isStyleAdjective('vibrant')).toBe(true);
// Mood adjectives
expect(isStyleAdjective('dramatic')).toBe(true);
expect(isStyleAdjective('peaceful')).toBe(true);
expect(isStyleAdjective('mysterious')).toBe(true);
});
it('should reject non-style adjectives', () => {
expect(isStyleAdjective('walking')).toBe(false);
expect(isStyleAdjective('running')).toBe(false);
expect(isStyleAdjective('opened')).toBe(false);
expect(isStyleAdjective('closed')).toBe(false);
expect(isStyleAdjective('basic')).toBe(false);
expect(isStyleAdjective('normal')).toBe(false);
});
it('should handle case insensitive matching', () => {
expect(isStyleAdjective('Sharp')).toBe(true);
expect(isStyleAdjective('SHARP')).toBe(true);
expect(isStyleAdjective('Abstract')).toBe(true);
expect(isStyleAdjective('ABSTRACT')).toBe(true);
});
});
describe('extractStyleAdjectives', () => {
it('should extract style adjectives from word array', () => {
const words = ['a', 'sharp', 'walking', 'robot', 'with', 'dramatic', 'lighting'];
const adjectives = extractStyleAdjectives(words);
expect(adjectives).toEqual(['sharp', 'dramatic']);
});
it('should handle empty array', () => {
expect(extractStyleAdjectives([])).toEqual([]);
});
it('should handle array with no style adjectives', () => {
const words = ['walking', 'running', 'jumping', 'swimming'];
expect(extractStyleAdjectives(words)).toEqual([]);
});
it('should handle array with all style adjectives', () => {
const words = ['sharp', 'bright', 'dramatic', 'mysterious'];
expect(extractStyleAdjectives(words)).toEqual(words);
});
it('should preserve original case', () => {
const words = ['Sharp', 'BRIGHT', 'Dramatic'];
const adjectives = extractStyleAdjectives(words);
expect(adjectives).toEqual(['Sharp', 'BRIGHT', 'Dramatic']);
});
});
describe('Integration tests', () => {
it('should have consistent data across all exports', () => {
const allKeywords = getAllStyleKeywords();
const totalInCategories = Object.values(STYLE_KEYWORDS).reduce(
(sum: number, keywords: readonly string[]) => sum + keywords.length,
0,
);
expect(allKeywords.length).toBe(totalInCategories);
});
it('should not have keywords that are also synonyms', () => {
const allKeywords = getAllStyleKeywords();
const allSynonyms = new Set(Object.values(STYLE_SYNONYMS).flat());
const overlap = allKeywords.filter((keyword: string) => allSynonyms.has(keyword));
// Allow reasonable range of overlaps
expect(overlap.length).toBeGreaterThanOrEqual(10);
expect(overlap.length).toBeLessThanOrEqual(20); // Reasonable overlap range
});
it('should have compound styles that contain style keywords', () => {
const compounds = getCompoundStyles();
const keywords = getAllStyleKeywords();
// At least some compound styles should contain individual keywords
const compoundsWithKeywords = compounds.filter((compound: string) => {
return keywords.some((keyword: string) => compound.includes(keyword));
});
expect(compoundsWithKeywords.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import {
SYSTEM_COMPONENTS,
getAllComponentsWithNames,
getOptimalComponent,
} from '@/server/services/comfyui/config/systemComponents';
describe('SystemComponents', () => {
describe('SYSTEM_COMPONENTS', () => {
it('should be a non-empty object with valid structure', () => {
expect(typeof SYSTEM_COMPONENTS).toBe('object');
expect(Object.keys(SYSTEM_COMPONENTS).length).toBeGreaterThan(0);
// Check that all components have required fields
Object.entries(SYSTEM_COMPONENTS).forEach(([, config]) => {
expect(config).toBeDefined();
expect(config.type).toBeDefined();
expect(config.priority).toBeTypeOf('number');
expect(config.modelFamily).toBeDefined();
});
});
it('should contain essential component types', () => {
const types = Object.values(SYSTEM_COMPONENTS).map((c) => c.type);
const uniqueTypes = [...new Set(types)];
expect(uniqueTypes).toContain('vae');
expect(uniqueTypes).toContain('clip');
expect(uniqueTypes).toContain('t5');
});
it('should allow direct access to component config by name', () => {
const config = SYSTEM_COMPONENTS['ae.safetensors'];
expect(config).toBeDefined();
expect(config.type).toBe('vae');
expect(config.modelFamily).toBe('FLUX');
expect(config.priority).toBe(1);
});
it('should return undefined for invalid component name', () => {
const config = SYSTEM_COMPONENTS['nonexistent.safetensors'];
expect(config).toBeUndefined();
});
});
describe('getAllComponentsWithNames', () => {
it('should return components with names for valid type', () => {
const result = getAllComponentsWithNames({ type: 'vae' });
expect(result.length).toBeGreaterThan(0);
result.forEach(({ name, config }) => {
expect(name).toBeTypeOf('string');
expect(config.type).toBe('vae');
});
});
it('should filter by modelFamily when specified', () => {
const result = getAllComponentsWithNames({ modelFamily: 'FLUX', type: 'vae' });
expect(result.length).toBeGreaterThan(0);
result.forEach(({ config }) => {
expect(config.modelFamily).toBe('FLUX');
expect(config.type).toBe('vae');
});
});
it('should filter by priority when specified', () => {
const result = getAllComponentsWithNames({ priority: 1 });
expect(result.length).toBeGreaterThan(0);
result.forEach(({ config }) => {
expect(config.priority).toBe(1);
});
});
it('should filter by multiple criteria', () => {
const result = getAllComponentsWithNames({
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
});
expect(result.length).toBeGreaterThan(0);
result.forEach(({ config }) => {
expect(config.type).toBe('lora');
expect(config.modelFamily).toBe('FLUX');
expect(config.priority).toBe(1);
});
});
it('should filter by compatible variant', () => {
const result = getAllComponentsWithNames({
compatibleVariant: 'dev',
type: 'lora',
});
expect(result.length).toBeGreaterThan(0);
result.forEach(({ config }) => {
expect(config.type).toBe('lora');
expect(config.compatibleVariants).toContain('dev');
});
});
it('should return empty array for invalid filters', () => {
const result = getAllComponentsWithNames({
modelFamily: 'NONEXISTENT' as any,
type: 'vae',
});
expect(result).toEqual([]);
});
});
describe('getOptimalComponent', () => {
it('should return component with highest priority (lowest number) for FLUX VAE', () => {
const component = getOptimalComponent('vae', 'FLUX');
expect(component).toBeDefined();
expect(typeof component).toBe('string');
// Should return ae.safetensors which has priority 1
expect(component).toBe('ae.safetensors');
});
it('should return component with highest priority for SD1 VAE', () => {
const component = getOptimalComponent('vae', 'SD1');
expect(component).toBeDefined();
expect(typeof component).toBe('string');
});
it('should return component with highest priority for FLUX clip', () => {
const component = getOptimalComponent('clip', 'FLUX');
expect(component).toBeDefined();
expect(typeof component).toBe('string');
});
it('should throw ConfigError when no components found', () => {
expect(() => {
getOptimalComponent('vae', 'NONEXISTENT' as any);
}).toThrow('No vae components configured for model family NONEXISTENT');
});
});
});

View file

@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { ComfyUIAuthService } from '@/server/services/comfyui/core/comfyUIAuthService';
import { ServicesError } from '@/server/services/comfyui/errors';
import type { ComfyUIKeyVault } from '@/types/user/settings/keyVaults';
describe('ComfyUIAuthService', () => {
describe('Constructor and initialization', () => {
it('should initialize with none auth type by default', () => {
const service = new ComfyUIAuthService({});
expect(service.getCredentials()).toBeUndefined();
expect(service.getAuthHeaders()).toBeUndefined();
});
it('should initialize with basic auth', () => {
const options: ComfyUIKeyVault = {
authType: 'basic',
username: 'testuser',
password: 'testpass',
};
const service = new ComfyUIAuthService(options);
const credentials = service.getCredentials();
expect(credentials).toEqual({
type: 'basic',
username: 'testuser',
password: 'testpass',
});
const headers = service.getAuthHeaders();
expect(headers).toEqual({
Authorization: `Basic ${btoa('testuser:testpass')}`,
});
});
it('should initialize with bearer auth', () => {
const options: ComfyUIKeyVault = {
authType: 'bearer',
apiKey: 'test-api-key',
};
const service = new ComfyUIAuthService(options);
const credentials = service.getCredentials();
expect(credentials).toEqual({
type: 'bearer_token',
token: 'test-api-key',
});
const headers = service.getAuthHeaders();
expect(headers).toEqual({
Authorization: 'Bearer test-api-key',
});
});
it('should initialize with custom auth', () => {
const customHeaders = { 'X-API-Key': 'custom-key', 'X-Client': 'test' };
const options: ComfyUIKeyVault = {
authType: 'custom',
customHeaders,
};
const service = new ComfyUIAuthService(options);
const credentials = service.getCredentials();
expect(credentials).toEqual({
type: 'custom',
headers: customHeaders,
});
const headers = service.getAuthHeaders();
expect(headers).toEqual(customHeaders);
});
});
describe('Validation', () => {
it('should throw error for basic auth without username', () => {
expect(() => {
new ComfyUIAuthService({
authType: 'basic',
password: 'testpass',
});
}).toThrow(ServicesError);
});
it('should throw error for basic auth without password', () => {
expect(() => {
new ComfyUIAuthService({
authType: 'basic',
username: 'testuser',
});
}).toThrow(ServicesError);
});
it('should throw error for bearer auth without apiKey', () => {
expect(() => {
new ComfyUIAuthService({
authType: 'bearer',
});
}).toThrow(ServicesError);
});
it('should throw error for custom auth without headers', () => {
expect(() => {
new ComfyUIAuthService({
authType: 'custom',
});
}).toThrow(ServicesError);
});
it('should throw error for custom auth with empty headers', () => {
expect(() => {
new ComfyUIAuthService({
authType: 'custom',
customHeaders: {},
});
}).toThrow(ServicesError);
});
});
describe('Edge cases', () => {
it('should handle partial basic auth gracefully in headers', () => {
// This tests the createAuthHeaders method behavior
const options: ComfyUIKeyVault = {
authType: 'basic',
username: 'testuser',
password: 'testpass',
};
const service = new ComfyUIAuthService(options);
expect(service.getAuthHeaders()).toBeDefined();
});
it('should handle partial bearer auth gracefully in headers', () => {
const options: ComfyUIKeyVault = {
authType: 'bearer',
apiKey: 'test-key',
};
const service = new ComfyUIAuthService(options);
expect(service.getAuthHeaders()).toBeDefined();
});
});
});

View file

@ -0,0 +1,287 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ComfyUIConnectionService } from '@/server/services/comfyui/core/comfyUIConnectionService';
import { ServicesError } from '@/server/services/comfyui/errors';
// Mock global fetch
global.fetch = vi.fn();
describe('ComfyUIConnectionService', () => {
let service: ComfyUIConnectionService;
const mockFetch = vi.mocked(fetch);
beforeEach(() => {
service = new ComfyUIConnectionService();
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('Constructor and initialization', () => {
it('should initialize with default state', () => {
const newService = new ComfyUIConnectionService();
expect(newService.isValidated()).toBe(false);
const status = newService.getStatus();
expect(status.isValidated).toBe(false);
expect(status.lastValidationTime).toBe(null);
expect(status.timeUntilExpiry).toBe(null);
});
});
describe('Connection validation', () => {
const baseURL = 'http://localhost:8188';
const authHeaders = { Authorization: 'Bearer test-token' };
it('should validate connection successfully', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ system: 'data' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
const result = await service.validateConnection(baseURL, authHeaders);
expect(result).toBe(true);
expect(service.isValidated()).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(`${baseURL}/system_stats`, {
headers: {
...authHeaders,
'Content-Type': 'application/json',
},
method: 'GET',
mode: 'cors',
});
});
it('should validate connection without auth headers', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ system: 'data' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
const result = await service.validateConnection(baseURL);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(`${baseURL}/system_stats`, {
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
mode: 'cors',
});
});
it('should return cached validation result within TTL', async () => {
// First validation
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ system: 'data' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
await service.validateConnection(baseURL, authHeaders);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second validation within TTL should use cached result
const result = await service.validateConnection(baseURL, authHeaders);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch call
});
it('should re-validate after TTL expiry', async () => {
// First validation
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ system: 'data' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
await service.validateConnection(baseURL, authHeaders);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Fast-forward time beyond TTL (5 minutes + 1 second)
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
// Second validation after TTL should make new request
const result = await service.validateConnection(baseURL, authHeaders);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should handle HTTP error responses', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
};
mockFetch.mockResolvedValue(mockResponse as any);
await expect(service.validateConnection(baseURL, authHeaders)).rejects.toThrow(ServicesError);
expect(service.isValidated()).toBe(false);
});
it('should handle invalid JSON response', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue(null),
};
mockFetch.mockResolvedValue(mockResponse as any);
await expect(service.validateConnection(baseURL, authHeaders)).rejects.toThrow(ServicesError);
expect(service.isValidated()).toBe(false);
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(service.validateConnection(baseURL, authHeaders)).rejects.toThrow(Error);
expect(service.isValidated()).toBe(false);
});
it('should handle JSON parse errors', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockRejectedValue(new Error('JSON parse error')),
};
mockFetch.mockResolvedValue(mockResponse as any);
await expect(service.validateConnection(baseURL, authHeaders)).rejects.toThrow(Error);
expect(service.isValidated()).toBe(false);
});
});
describe('Connection state management', () => {
it('should mark connection as validated', () => {
service.markAsValidated();
expect(service.isValidated()).toBe(true);
const status = service.getStatus();
expect(status.isValidated).toBe(true);
expect(status.lastValidationTime).toBeGreaterThan(0);
expect(status.timeUntilExpiry).toBeGreaterThan(0);
});
it('should invalidate connection', () => {
service.markAsValidated();
expect(service.isValidated()).toBe(true);
service.invalidate();
expect(service.isValidated()).toBe(false);
const status = service.getStatus();
expect(status.isValidated).toBe(false);
expect(status.lastValidationTime).toBe(null);
expect(status.timeUntilExpiry).toBe(null);
});
it('should expire validation after TTL', () => {
service.markAsValidated();
expect(service.isValidated()).toBe(true);
// Fast-forward time beyond TTL
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
expect(service.isValidated()).toBe(false);
});
});
describe('Connection status', () => {
it('should return correct status for unvalidated connection', () => {
const status = service.getStatus();
expect(status.isValidated).toBe(false);
expect(status.lastValidationTime).toBe(null);
expect(status.timeUntilExpiry).toBe(null);
});
it('should return correct status for validated connection', () => {
service.markAsValidated();
const status = service.getStatus();
expect(status.isValidated).toBe(true);
expect(status.lastValidationTime).toBeGreaterThan(0);
expect(status.timeUntilExpiry).toBeGreaterThan(0);
expect(status.timeUntilExpiry).toBeLessThanOrEqual(5 * 60 * 1000); // Should be <= 5 minutes
});
it('should calculate time until expiry correctly', () => {
service.markAsValidated();
// Advance time by 2 minutes
vi.advanceTimersByTime(2 * 60 * 1000);
const status = service.getStatus();
expect(status.timeUntilExpiry).toBeCloseTo(3 * 60 * 1000, -2); // ~3 minutes remaining
});
it('should return zero time until expiry when expired', () => {
service.markAsValidated();
// Advance time beyond TTL
vi.advanceTimersByTime(6 * 60 * 1000);
const status = service.getStatus();
expect(status.timeUntilExpiry).toBe(0);
});
});
describe('Edge cases', () => {
const baseURL = 'http://localhost:8188';
const authHeaders = { Authorization: 'Bearer test-token' };
it('should handle multiple rapid validation calls', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ system: 'data' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
// Make multiple concurrent validation calls
const promises = [
service.validateConnection(baseURL, authHeaders),
service.validateConnection(baseURL, authHeaders),
service.validateConnection(baseURL, authHeaders),
];
const results = await Promise.all(promises);
// All should succeed
expect(results.every((r) => r === true)).toBe(true);
// For concurrent calls, each call checks the cache independently
// Since they start before any completes, they will all make HTTP requests
expect(mockFetch).toHaveBeenCalledTimes(3);
// After all complete, subsequent calls should use cache
await service.validateConnection(baseURL, authHeaders);
expect(mockFetch).toHaveBeenCalledTimes(3); // No additional call
});
it('should handle validation with empty auth headers object', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({ system: 'data' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
const result = await service.validateConnection(baseURL, {});
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(`${baseURL}/system_stats`, {
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
mode: 'cors',
});
});
});
});

View file

@ -0,0 +1,666 @@
import { ComfyApi } from '@saintno/comfyui-sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ComfyUIAuthService } from '@/server/services/comfyui/core/comfyUIAuthService';
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
import { ComfyUIConnectionService } from '@/server/services/comfyui/core/comfyUIConnectionService';
import { ServicesError } from '@/server/services/comfyui/errors';
import { ModelResolverError } from '@/server/services/comfyui/errors/modelResolverError';
import { ComfyUIKeyVault } from '@/types/user/settings/keyVaults';
// Mock the SDK
vi.mock('@saintno/comfyui-sdk', () => ({
CallWrapper: vi.fn(),
ComfyApi: vi.fn(),
}));
// Mock the modular services
vi.mock('@/server/services/comfyui/core/comfyUIAuthService');
vi.mock('@/server/services/comfyui/core/comfyUIConnectionService');
describe('ComfyUIClientService', () => {
let service: ComfyUIClientService;
let mockClient: any;
let mockAuthService: any;
let mockConnectionService: any;
let originalDateNow: () => number;
beforeEach(() => {
vi.clearAllMocks();
originalDateNow = Date.now;
// Create mock client
mockClient = {
fetchApi: vi.fn(),
getCheckpoints: vi.fn(),
getLoras: vi.fn(),
getNodeDefs: vi.fn(),
getPathImage: vi.fn(),
getSamplerInfo: vi.fn(),
init: vi.fn(),
uploadImage: vi.fn(),
};
// Create mock services
mockAuthService = {
getCredentials: vi.fn().mockReturnValue(undefined),
getAuthHeaders: vi.fn().mockReturnValue(undefined),
};
mockConnectionService = {
validateConnection: vi.fn().mockResolvedValue(true),
getStatus: vi.fn().mockReturnValue({
isValidated: false,
lastValidationTime: null,
timeUntilExpiry: null,
}),
};
// Mock constructors
vi.mocked(ComfyApi).mockImplementation(() => mockClient);
vi.mocked(ComfyUIAuthService).mockImplementation(() => mockAuthService);
vi.mocked(ComfyUIConnectionService).mockImplementation(() => mockConnectionService);
});
afterEach(() => {
Date.now = originalDateNow;
});
describe('constructor', () => {
it('should initialize with default settings', () => {
service = new ComfyUIClientService();
expect(ComfyUIAuthService).toHaveBeenCalledWith({});
expect(ComfyUIConnectionService).toHaveBeenCalled();
expect(ComfyApi).toHaveBeenCalledWith(expect.stringContaining('http'), undefined, {
credentials: undefined,
});
expect(mockClient.init).toHaveBeenCalled();
});
it('should initialize with custom options', () => {
const options: ComfyUIKeyVault = {
authType: 'basic',
baseURL: 'http://custom:8188',
password: 'pass',
username: 'user',
};
service = new ComfyUIClientService(options);
expect(ComfyUIAuthService).toHaveBeenCalledWith(options);
expect(ComfyUIConnectionService).toHaveBeenCalled();
expect(ComfyApi).toHaveBeenCalledWith('http://custom:8188', undefined, {
credentials: undefined,
});
expect(mockClient.init).toHaveBeenCalled();
});
it('should handle auth service errors during initialization', () => {
// Mock AuthService constructor to throw
vi.mocked(ComfyUIAuthService).mockImplementation(() => {
throw new ServicesError('Invalid auth config', ServicesError.Reasons.INVALID_ARGS);
});
expect(() => new ComfyUIClientService({ authType: 'basic' })).toThrow();
// Verify it throws an error (ErrorHandlerService wraps it into TRPCError)
try {
new ComfyUIClientService({ authType: 'basic' });
expect.fail('Should have thrown an error');
} catch (error: any) {
expect(error.cause).toHaveProperty('errorType', 'InvalidComfyUIArgs');
expect(error.cause).toHaveProperty('provider', 'comfyui');
}
});
});
describe('uploadImage', () => {
beforeEach(() => {
service = new ComfyUIClientService();
});
it('should successfully upload an image', async () => {
// Setup mock
const mockBuffer = Buffer.from('test image data');
const mockFileName = 'test.png';
const mockResult = {
info: {
filename: 'uploaded_test.png',
},
};
mockClient.uploadImage.mockResolvedValue(mockResult);
// Execute
const result = await service.uploadImage(mockBuffer, mockFileName);
// Verify
expect(result).toBe('uploaded_test.png');
expect(mockClient.uploadImage).toHaveBeenCalledWith(mockBuffer, mockFileName);
});
it('should handle upload failure when result is null', async () => {
// Setup
mockClient.uploadImage.mockResolvedValue(null);
// Execute and verify
await expect(service.uploadImage(Buffer.from('data'), 'file.png')).rejects.toThrow(
'Failed to upload image to ComfyUI server',
);
});
it('should handle network errors during upload', async () => {
// Setup
const networkError = new TypeError('Failed to fetch');
mockClient.uploadImage.mockRejectedValue(networkError);
// Execute and verify - uploadImage just re-throws without transformation
await expect(service.uploadImage(Buffer.from('data'), 'file.png')).rejects.toThrow(
'Failed to fetch',
);
});
it('should handle 401 authentication error', async () => {
// Setup
const authError = new Error('Request failed with status: 401');
mockClient.uploadImage.mockRejectedValue(authError);
// Execute and verify - uploadImage just re-throws without transformation
await expect(service.uploadImage(Buffer.from('data'), 'file.png')).rejects.toThrow(
'Request failed with status: 401',
);
});
it('should handle 403 forbidden error', async () => {
// Setup
const forbiddenError = new Error('Request failed with status: 403');
mockClient.uploadImage.mockRejectedValue(forbiddenError);
// Execute and verify - uploadImage just re-throws without transformation
await expect(service.uploadImage(Buffer.from('data'), 'file.png')).rejects.toThrow(
'Request failed with status: 403',
);
});
it('should handle 500+ server errors', async () => {
// Setup
const serverError = new Error('Request failed with status: 503');
mockClient.uploadImage.mockRejectedValue(serverError);
// Execute and verify - uploadImage just re-throws without transformation
await expect(service.uploadImage(Buffer.from('data'), 'file.png')).rejects.toThrow(
'Request failed with status: 503',
);
});
it('should handle unknown errors', async () => {
// Setup
const unknownError = 'Some unexpected error string';
mockClient.uploadImage.mockRejectedValue(unknownError);
// Execute and verify - uploadImage just re-throws without transformation
await expect(service.uploadImage(Buffer.from('data'), 'file.png')).rejects.toBe(
'Some unexpected error string',
);
});
it('should support Blob upload', async () => {
// Setup
const mockBlob = new Blob(['test data']);
const mockResult = {
info: { filename: 'blob_upload.png' },
};
mockClient.uploadImage.mockResolvedValue(mockResult);
// Execute
const result = await service.uploadImage(mockBlob, 'blob.png');
// Verify
expect(result).toBe('blob_upload.png');
expect(mockClient.uploadImage).toHaveBeenCalledWith(mockBlob, 'blob.png');
});
});
describe('executeWorkflow', () => {
beforeEach(() => {
service = new ComfyUIClientService();
});
it('should execute workflow successfully', async () => {
// Import CallWrapper mock
const { CallWrapper } = await import('@saintno/comfyui-sdk');
// Setup mock workflow
const mockWorkflow = { id: 'test-workflow' };
const mockResult = {
images: {
images: [{ data: 'base64' }],
},
};
// Create CallWrapper mock instance
const mockCallWrapper = {
onFailed: vi.fn().mockReturnThis(),
onFinished: vi.fn().mockReturnThis(),
onProgress: vi.fn().mockReturnThis(),
run: vi.fn(),
};
// Setup CallWrapper mock
vi.mocked(CallWrapper).mockImplementation(() => mockCallWrapper as any);
// Simulate successful execution
mockCallWrapper.run.mockImplementation(() => {
const finishCallback = mockCallWrapper.onFinished.mock.calls[0][0];
finishCallback(mockResult);
});
// Execute
const result = await service.executeWorkflow(mockWorkflow as any);
// Verify
expect(result).toEqual(mockResult);
expect(CallWrapper).toHaveBeenCalledWith(mockClient, mockWorkflow);
});
it('should handle workflow execution failure', async () => {
const { CallWrapper } = await import('@saintno/comfyui-sdk');
const mockWorkflow = { id: 'test' };
const mockError = new Error('Workflow failed');
const mockCallWrapper = {
onFailed: vi.fn().mockReturnThis(),
onFinished: vi.fn().mockReturnThis(),
onProgress: vi.fn().mockReturnThis(),
run: vi.fn(),
};
vi.mocked(CallWrapper).mockImplementation(() => mockCallWrapper as any);
// Simulate failure
mockCallWrapper.run.mockImplementation(() => {
const failCallback = mockCallWrapper.onFailed.mock.calls[0][0];
failCallback(mockError);
});
// Execute and verify - executeWorkflow just passes through the error
await expect(service.executeWorkflow(mockWorkflow as any)).rejects.toThrow('Workflow failed');
});
it('should call progress callback', async () => {
const { CallWrapper } = await import('@saintno/comfyui-sdk');
const mockWorkflow = { id: 'test' };
const mockProgress = { step: 1, total: 10 };
const progressCallback = vi.fn();
const mockCallWrapper = {
onFailed: vi.fn().mockReturnThis(),
onFinished: vi.fn().mockReturnThis(),
onProgress: vi.fn().mockReturnThis(),
run: vi.fn(),
};
vi.mocked(CallWrapper).mockImplementation(() => mockCallWrapper as any);
// Simulate progress and completion
mockCallWrapper.run.mockImplementation(() => {
const progressCb = mockCallWrapper.onProgress.mock.calls[0][0];
progressCb(mockProgress);
const finishCb = mockCallWrapper.onFinished.mock.calls[0][0];
finishCb({ images: { images: [] } });
});
// Execute
await service.executeWorkflow(mockWorkflow as any, progressCallback);
// Verify
expect(progressCallback).toHaveBeenCalledWith(mockProgress);
});
});
describe('validateConnection', () => {
beforeEach(() => {
service = new ComfyUIClientService();
});
it('should delegate to connection service', async () => {
mockConnectionService.validateConnection.mockResolvedValue(true);
const result = await service.validateConnection();
expect(result).toBe(true);
expect(mockConnectionService.validateConnection).toHaveBeenCalledWith(
expect.stringContaining('http'), // baseURL
undefined, // auth headers (undefined for no auth)
);
});
it('should pass auth headers to connection service', async () => {
const authHeaders = { Authorization: 'Bearer test-token' };
mockAuthService.getAuthHeaders.mockReturnValue(authHeaders);
mockConnectionService.validateConnection.mockResolvedValue(true);
const result = await service.validateConnection();
expect(result).toBe(true);
expect(mockConnectionService.validateConnection).toHaveBeenCalledWith(
expect.stringContaining('http'), // baseURL
authHeaders,
);
});
it('should handle connection service errors', async () => {
const connectionError = new ServicesError(
'Connection failed',
ServicesError.Reasons.CONNECTION_FAILED,
);
mockConnectionService.validateConnection.mockRejectedValue(connectionError);
await expect(service.validateConnection()).rejects.toThrow(connectionError);
});
});
// fetchApi and getObjectInfo tests removed
// These methods should not be used directly
// Use SDK methods: getCheckpoints(), getNodeDefs(), getLoras(), getSamplerInfo()
describe('getPathImage', () => {
beforeEach(() => {
service = new ComfyUIClientService();
});
it('should delegate to client getPathImage', () => {
// Setup
const mockImageInfo = { filename: 'test.png' };
const expectedPath = 'https://server/image/test.png';
mockClient.getPathImage.mockReturnValue(expectedPath);
// Execute
const result = service.getPathImage(mockImageInfo);
// Verify
expect(result).toBe(expectedPath);
expect(mockClient.getPathImage).toHaveBeenCalledWith(mockImageInfo);
});
});
describe('getAuthHeaders', () => {
beforeEach(() => {
service = new ComfyUIClientService();
});
it('should delegate to auth service', () => {
const expectedHeaders = { Authorization: 'Bearer test-token' };
mockAuthService.getAuthHeaders.mockReturnValue(expectedHeaders);
const headers = service.getAuthHeaders();
expect(headers).toEqual(expectedHeaders);
expect(mockAuthService.getAuthHeaders).toHaveBeenCalled();
});
it('should return undefined when auth service returns undefined', () => {
mockAuthService.getAuthHeaders.mockReturnValue(undefined);
const headers = service.getAuthHeaders();
expect(headers).toBeUndefined();
expect(mockAuthService.getAuthHeaders).toHaveBeenCalled();
});
});
describe('service access methods', () => {
beforeEach(() => {
service = new ComfyUIClientService();
});
it('should provide access to auth service', () => {
const authService = service.getAuthService();
expect(authService).toBe(mockAuthService);
});
it('should provide access to connection service', () => {
const connectionService = service.getConnectionService();
expect(connectionService).toBe(mockConnectionService);
});
it('should provide connection status', () => {
const expectedStatus = {
isValidated: true,
lastValidationTime: Date.now(),
timeUntilExpiry: 300000,
};
mockConnectionService.getStatus.mockReturnValue(expectedStatus);
const status = service.getConnectionStatus();
expect(status).toEqual(expectedStatus);
expect(mockConnectionService.getStatus).toHaveBeenCalled();
});
});
describe('getCheckpoints', () => {
beforeEach(() => {
service = new ComfyUIClientService();
mockClient.getCheckpoints = vi.fn();
});
it('should get checkpoints successfully', async () => {
const mockCheckpoints = ['flux1-dev.safetensors', 'sd3.5_large.safetensors'];
mockClient.getCheckpoints.mockResolvedValue(mockCheckpoints);
const result = await service.getCheckpoints();
expect(result).toEqual(mockCheckpoints);
expect(mockClient.getCheckpoints).toHaveBeenCalled();
});
it('should handle error when getting checkpoints', async () => {
mockClient.getCheckpoints.mockRejectedValue(new Error('Failed to fetch'));
await expect(service.getCheckpoints()).rejects.toThrow();
});
it('should cache checkpoints for 1 minute TTL', async () => {
const mockCheckpoints = ['flux1-dev.safetensors', 'sd3.5_large.safetensors'];
mockClient.getCheckpoints.mockResolvedValue(mockCheckpoints);
// First call
const result1 = await service.getCheckpoints();
expect(result1).toEqual(mockCheckpoints);
expect(mockClient.getCheckpoints).toHaveBeenCalledTimes(1);
// Second call within TTL - should use cache
const result2 = await service.getCheckpoints();
expect(result2).toEqual(mockCheckpoints);
expect(mockClient.getCheckpoints).toHaveBeenCalledTimes(1); // Still only 1 call
// Mock time passing (simulate cache expiry)
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 61 * 1000); // 61 seconds later
// Third call after TTL expired - should make new SDK call
const result3 = await service.getCheckpoints();
expect(result3).toEqual(mockCheckpoints);
expect(mockClient.getCheckpoints).toHaveBeenCalledTimes(2); // Now 2 calls
});
});
describe('getLoras', () => {
beforeEach(() => {
service = new ComfyUIClientService();
mockClient.getLoras = vi.fn();
});
it('should get LoRAs successfully', async () => {
const mockLoras = ['lora1.safetensors', 'lora2.safetensors'];
mockClient.getLoras.mockResolvedValue(mockLoras);
const result = await service.getLoras();
expect(result).toEqual(mockLoras);
expect(mockClient.getLoras).toHaveBeenCalled();
});
it('should handle error when getting LoRAs', async () => {
mockClient.getLoras.mockRejectedValue(new Error('Failed to fetch'));
await expect(service.getLoras()).rejects.toThrow();
});
it('should cache LoRAs for 1 minute TTL', async () => {
const mockLoras = ['lora1.safetensors', 'lora2.safetensors'];
mockClient.getLoras.mockResolvedValue(mockLoras);
// First call
const result1 = await service.getLoras();
expect(result1).toEqual(mockLoras);
expect(mockClient.getLoras).toHaveBeenCalledTimes(1);
// Second call within TTL - should use cache
const result2 = await service.getLoras();
expect(result2).toEqual(mockLoras);
expect(mockClient.getLoras).toHaveBeenCalledTimes(1); // Still only 1 call
// Mock time passing (simulate cache expiry)
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 61 * 1000); // 61 seconds later
// Third call after TTL expired - should make new SDK call
const result3 = await service.getLoras();
expect(result3).toEqual(mockLoras);
expect(mockClient.getLoras).toHaveBeenCalledTimes(2); // Now 2 calls
});
});
describe('getNodeDefs', () => {
beforeEach(() => {
service = new ComfyUIClientService();
mockClient.getNodeDefs = vi.fn();
});
it('should get node definitions with caching', async () => {
const mockNodeDefs = {
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [['flux1-dev.safetensors']],
},
},
},
};
mockClient.getNodeDefs.mockResolvedValue(mockNodeDefs);
// First call - should fetch from API
const result1 = await service.getNodeDefs();
expect(result1).toEqual(mockNodeDefs);
expect(mockClient.getNodeDefs).toHaveBeenCalledTimes(1);
// Second call - should use cache
const result2 = await service.getNodeDefs();
expect(result2).toEqual(mockNodeDefs);
expect(mockClient.getNodeDefs).toHaveBeenCalledTimes(1); // Still 1, used cache
// Get specific node - should return full cache since SDK doesn't support filtering
const result3 = await service.getNodeDefs('CheckpointLoaderSimple');
expect(result3).toEqual(mockNodeDefs);
expect(mockClient.getNodeDefs).toHaveBeenCalledTimes(1); // Still 1, used cache
});
it('should refresh cache after TTL expires', async () => {
const mockNodeDefs1 = { node1: {} };
const mockNodeDefs2 = { node2: {} };
mockClient.getNodeDefs
.mockResolvedValueOnce(mockNodeDefs1)
.mockResolvedValueOnce(mockNodeDefs2);
// First call
const result1 = await service.getNodeDefs();
expect(result1).toEqual(mockNodeDefs1);
// Simulate time passing (more than 1 minute)
const originalNow = Date.now;
Date.now = vi.fn(() => originalNow() + 61_000);
// Second call after TTL - should fetch again
const result2 = await service.getNodeDefs();
expect(result2).toEqual(mockNodeDefs2);
expect(mockClient.getNodeDefs).toHaveBeenCalledTimes(2);
// Restore Date.now
Date.now = originalNow;
});
it('should handle error when getting node definitions', async () => {
mockClient.getNodeDefs.mockRejectedValue(new Error('Failed to fetch'));
await expect(service.getNodeDefs()).rejects.toThrow();
});
});
describe('getSamplerInfo', () => {
beforeEach(() => {
service = new ComfyUIClientService();
mockClient.getSamplerInfo = vi.fn();
});
it('should get sampler info successfully', async () => {
const mockSDKResponse = {
sampler: ['euler', 'ddim'],
scheduler: ['normal', 'karras'],
};
mockClient.getSamplerInfo.mockResolvedValue(mockSDKResponse);
const result = await service.getSamplerInfo();
// Service now returns samplerName instead of sampler for consistency
expect(result).toEqual({
samplerName: ['euler', 'ddim'],
scheduler: ['normal', 'karras'],
});
expect(mockClient.getSamplerInfo).toHaveBeenCalled();
});
it('should handle error when getting sampler info', async () => {
mockClient.getSamplerInfo.mockRejectedValue(new Error('Failed to fetch'));
await expect(service.getSamplerInfo()).rejects.toThrow();
});
});
describe('uploadImage', () => {
beforeEach(() => {
service = new ComfyUIClientService();
mockClient.uploadImage = vi.fn();
});
it('should upload image successfully', async () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const mockResponse = {
info: {
filename: 'uploaded.png',
type: 'input',
},
};
mockClient.uploadImage.mockResolvedValue(mockResponse);
const result = await service.uploadImage(mockFile, 'test.png');
expect(result).toEqual('uploaded.png');
expect(mockClient.uploadImage).toHaveBeenCalledWith(mockFile, 'test.png');
});
it('should handle upload error', async () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
mockClient.uploadImage.mockRejectedValue(new Error('Upload failed'));
await expect(service.uploadImage(mockFile, 'test.png')).rejects.toThrow();
});
});
});

View file

@ -0,0 +1,230 @@
import { AgentRuntimeErrorType } from '@lobechat/model-runtime';
import { beforeEach, describe, expect, it } from 'vitest';
import { ErrorHandlerService } from '@/server/services/comfyui/core/errorHandlerService';
import {
ConfigError,
ServicesError,
UtilsError,
WorkflowError,
} from '@/server/services/comfyui/errors';
import { ModelResolverError } from '@/server/services/comfyui/errors/modelResolverError';
describe('ErrorHandlerService', () => {
let service: ErrorHandlerService;
beforeEach(() => {
service = new ErrorHandlerService();
});
describe('handleError', () => {
describe('ComfyUI internal errors', () => {
it('should handle ConfigError correctly', () => {
const error = new ConfigError('Config is invalid', ConfigError.Reasons.INVALID_CONFIG, {
config: 'test',
});
expect(() => service.handleError(error)).toThrow();
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
expect(e.cause.error.message).toBe('Config is invalid');
expect(e.cause.error.details).toEqual({ config: 'test' });
expect(e.cause.provider).toBe('comfyui');
}
});
it('should handle WorkflowError with UNSUPPORTED_MODEL', () => {
const error = new WorkflowError(
'Model not supported',
WorkflowError.Reasons.UNSUPPORTED_MODEL,
{ model: 'flux1-dev.safetensors' },
);
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ModelNotFound);
expect(e.cause.error.message).toBe('Model not supported');
}
});
it('should handle WorkflowError with MISSING_COMPONENT', () => {
const error = new WorkflowError(
'Component missing',
WorkflowError.Reasons.MISSING_COMPONENT,
{ component: 'vae' },
);
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ComfyUIModelError);
}
});
it('should handle UtilsError correctly', () => {
const error = new UtilsError('Connection failed', UtilsError.Reasons.CONNECTION_ERROR);
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ComfyUIServiceUnavailable);
}
});
it('should handle ModelResolverError correctly', () => {
const error = new ModelResolverError('MODEL_NOT_FOUND', 'Model not found');
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ModelNotFound);
}
});
it('should use default error type for unknown reasons', () => {
const error = new ConfigError('Unknown error', 'UNKNOWN_REASON' as any);
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
}
});
it('should handle ServicesError with all mapped reasons', () => {
// Test a mapped reason
const error1 = new ServicesError('Model not found', ServicesError.Reasons.MODEL_NOT_FOUND, {
model: 'test',
});
try {
service.handleError(error1);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ModelNotFound);
}
// Test unmapped reason - should hit line 120 and return default
const error2 = new ServicesError('Unknown error', 'UNMAPPED_REASON' as any, {});
try {
service.handleError(error2);
} catch (e: any) {
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
}
});
});
describe('Pre-formatted framework errors', () => {
it('should pass through pre-formatted errors', () => {
const error = {
errorType: AgentRuntimeErrorType.ComfyUIWorkflowError,
message: 'Already formatted',
provider: 'comfyui',
};
expect(() => service.handleError(error)).toThrowError();
try {
service.handleError(error);
} catch (e: any) {
// Verify the cause contains the same error properties
expect(e.cause.errorType).toBe(error.errorType);
expect(e.cause.message).toBe(error.message);
expect(e.cause.provider).toBe(error.provider);
}
});
});
describe('Other errors', () => {
it('should parse string errors', () => {
const error = 'Some error message';
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.provider).toBe('comfyui');
expect(e.cause.error).toBeDefined();
}
});
it('should parse Error objects', () => {
const error = new Error('Standard error');
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.provider).toBe('comfyui');
expect(e.cause.error).toBeDefined();
}
});
it('should handle null/undefined errors', () => {
expect(() => service.handleError(null)).toThrow();
expect(() => service.handleError(undefined)).toThrow();
});
});
});
describe('Error mapping completeness', () => {
it('should map all ConfigError reasons', () => {
const reasons = Object.values(ConfigError.Reasons);
reasons.forEach((reason) => {
const error = new ConfigError('Test', reason);
expect(() => service.handleError(error)).toThrow();
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBeDefined();
expect(e.cause.errorType).toBe(AgentRuntimeErrorType.ComfyUIBizError);
}
});
});
it('should map all WorkflowError reasons', () => {
const reasons = Object.values(WorkflowError.Reasons);
const expectedMapping: Record<string, string> = {
[WorkflowError.Reasons.INVALID_CONFIG]: AgentRuntimeErrorType.ComfyUIWorkflowError,
[WorkflowError.Reasons.MISSING_COMPONENT]: AgentRuntimeErrorType.ComfyUIModelError,
[WorkflowError.Reasons.MISSING_ENCODER]: AgentRuntimeErrorType.ComfyUIModelError,
[WorkflowError.Reasons.UNSUPPORTED_MODEL]: AgentRuntimeErrorType.ModelNotFound,
[WorkflowError.Reasons.INVALID_PARAMS]: AgentRuntimeErrorType.ComfyUIWorkflowError,
};
reasons.forEach((reason) => {
const error = new WorkflowError('Test', reason);
try {
service.handleError(error);
} catch (e: any) {
const expected = expectedMapping[reason] || AgentRuntimeErrorType.ComfyUIWorkflowError;
expect(e.cause.errorType).toBe(expected);
}
});
});
it('should map all UtilsError reasons', () => {
const reasons = Object.values(UtilsError.Reasons);
reasons.forEach((reason) => {
const error = new UtilsError('Test', reason);
expect(() => service.handleError(error)).toThrow();
try {
service.handleError(error);
} catch (e: any) {
expect(e.cause.errorType).toBeDefined();
// Should not be undefined
expect(e.cause.errorType).not.toBe(undefined);
}
});
});
});
});

View file

@ -0,0 +1,134 @@
// @vitest-environment node
import { CallWrapper } from '@saintno/comfyui-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { parametersFixture } from '@/server/services/comfyui/__tests__/fixtures/parameters.fixture';
import { mockContext } from '@/server/services/comfyui/__tests__/helpers/mockContext';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
// Import services for testing
import { ImageService } from '@/server/services/comfyui/core/imageService';
import { ModelResolverService } from '@/server/services/comfyui/core/modelResolverService';
import { WorkflowBuilderService } from '@/server/services/comfyui/core/workflowBuilderService';
describe('Error Handling - SDK Integration', () => {
let imageService: ImageService;
let inputCalls: Map<string, any>;
beforeEach(() => {
const mocks = setupAllMocks();
inputCalls = mocks.inputCalls;
// Create service instances
const clientService = new ComfyUIClientService();
const modelResolverService = new ModelResolverService(clientService);
const workflowBuilderService = new WorkflowBuilderService({
clientService,
modelResolverService,
});
imageService = new ImageService(clientService, modelResolverService, workflowBuilderService);
});
describe('SDK Error Handling', () => {
it('should catch and transform SDK errors', async () => {
// This test relies on the unified mock system for CallWrapper
const params = {
model: 'flux-dev',
params: {
prompt: 'test prompt',
...parametersFixture.models['flux-dev'].defaults,
},
};
// Should catch and transform errors
await expect(imageService.createImage(params)).rejects.toThrow();
});
it('should handle workflow build errors gracefully', async () => {
const incompleteParams = {
model: 'flux-dev',
params: {
prompt: '', // Empty prompt may cause issues
},
};
// Should not crash, should return meaningful error
await expect(imageService.createImage(incompleteParams)).rejects.toThrow();
});
it('should handle invalid model errors', async () => {
const invalidModelParams = {
model: 'non-existent-model',
params: {
prompt: 'test prompt',
},
};
// 应该优雅地处理无效模型
await expect(imageService.createImage(invalidModelParams)).rejects.toThrow();
});
});
describe('Parameter Validation Errors', () => {
it('should validate required parameters', async () => {
const missingParams = {
model: 'flux-dev',
params: { prompt: '' }, // 缺少必要参数但至少包含必需的prompt
};
// 应该验证并报错
await expect(imageService.createImage(missingParams)).rejects.toThrow();
});
it('should handle parameter boundary violations gracefully', async () => {
const invalidParams = {
model: 'flux-dev',
params: {
prompt: 'test prompt',
cfg: -1, // 无效值
steps: 1000, // 超出范围
},
};
// 应该处理边界违规
await expect(imageService.createImage(invalidParams)).rejects.toThrow();
});
});
describe('Service Error Propagation', () => {
it('should propagate model resolution errors', async () => {
// Mock 模型解析失败
vi.spyOn(ModelResolverService.prototype, 'validateModel').mockRejectedValue(
new Error('Model not found'),
);
const params = {
model: 'unknown-model',
params: {
prompt: 'test prompt',
},
};
await expect(imageService.createImage(params)).rejects.toThrow();
});
it('should handle connection validation failures', async () => {
// Mock 连接验证失败
vi.spyOn(ComfyUIClientService.prototype, 'validateConnection').mockRejectedValue(
new Error('Connection failed'),
);
const params = {
model: 'flux-dev',
params: {
prompt: 'test prompt',
...parametersFixture.models['flux-dev'].defaults,
},
};
await expect(imageService.createImage(params)).rejects.toThrow();
});
});
});

View file

@ -0,0 +1,528 @@
import { AgentRuntimeErrorType } from '@lobechat/model-runtime';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CreateImagePayload } from '@lobechat/model-runtime';
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
import { ErrorHandlerService } from '@/server/services/comfyui/core/errorHandlerService';
import { ImageService } from '@/server/services/comfyui/core/imageService';
import { ModelResolverService } from '@/server/services/comfyui/core/modelResolverService';
import { WorkflowBuilderService } from '@/server/services/comfyui/core/workflowBuilderService';
import { WorkflowDetector } from '@/server/services/comfyui/utils/workflowDetector';
// Mock dependencies
vi.mock('@/server/services/comfyui/core/comfyUIClientService');
vi.mock('@/server/services/comfyui/core/modelResolverService');
vi.mock('@/server/services/comfyui/core/workflowBuilderService');
vi.mock('@/server/services/comfyui/core/errorHandlerService');
vi.mock('@/server/services/comfyui/utils/workflowDetector');
// Mock sharp module for image processing
vi.mock('sharp', () => ({
default: vi.fn((buffer) => ({
metadata: vi.fn().mockResolvedValue({ height: 1024, width: 1024 }),
resize: vi.fn().mockReturnThis(),
toBuffer: vi.fn().mockResolvedValue(Buffer.from(buffer)),
})),
}));
describe('ImageService', () => {
let imageService: ImageService;
let mockClientService: any;
let mockModelResolverService: any;
let mockWorkflowBuilderService: any;
let mockErrorHandler: any;
let mockFetch: any;
beforeEach(() => {
vi.clearAllMocks();
// Create mocks
mockClientService = {
executeWorkflow: vi.fn(),
getPathImage: vi.fn(),
uploadImage: vi.fn(),
validateConnection: vi.fn().mockResolvedValue(true),
};
mockModelResolverService = {
validateModel: vi.fn(),
};
mockWorkflowBuilderService = {
buildWorkflow: vi.fn(),
};
mockErrorHandler = {
handleError: vi.fn(),
};
// Mock global fetch
mockFetch = vi.fn();
global.fetch = mockFetch;
// Setup mocks for constructor
vi.mocked(ComfyUIClientService, true).mockImplementation(() => mockClientService as any);
vi.mocked(ModelResolverService, true).mockImplementation(() => mockModelResolverService as any);
vi.mocked(WorkflowBuilderService, true).mockImplementation(
() => mockWorkflowBuilderService as any,
);
vi.mocked(ErrorHandlerService, true).mockImplementation(() => mockErrorHandler as any);
// Create service instance
imageService = new ImageService(
mockClientService,
mockModelResolverService,
mockWorkflowBuilderService,
);
// Mock workflow detector
vi.mocked(WorkflowDetector, true).detectModelType = vi.fn().mockReturnValue({
architecture: 'flux-schnell',
isSupported: true,
modelType: 'FLUX',
});
});
describe('createImage', () => {
const mockPayload: CreateImagePayload = {
model: 'flux-schnell',
params: {
height: 1024,
prompt: 'test prompt',
width: 1024,
},
};
it('should successfully create image with text2img workflow', async () => {
// Setup mocks
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'flux1-schnell-fp8.safetensors',
exists: true,
});
const mockWorkflow = { id: 'test-workflow' };
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue(mockWorkflow);
const mockResult = {
images: {
images: [
{
data: 'base64data',
height: 1024,
width: 1024,
},
],
},
};
mockClientService.executeWorkflow.mockResolvedValue(mockResult);
mockClientService.getPathImage.mockReturnValue('https://comfyui.test/image.png');
// Execute
const result = await imageService.createImage(mockPayload);
// Verify
expect(result).toEqual({
imageUrl: 'https://comfyui.test/image.png',
});
expect(mockModelResolverService.validateModel).toHaveBeenCalledWith('flux-schnell');
expect(mockWorkflowBuilderService.buildWorkflow).toHaveBeenCalled();
expect(mockClientService.executeWorkflow).toHaveBeenCalledWith(
mockWorkflow,
expect.any(Function),
);
});
it('should handle model not found error', async () => {
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
exists: false,
});
mockErrorHandler.handleError.mockImplementation((error: any) => {
throw {
error: { message: error.message },
errorType: AgentRuntimeErrorType.ModelNotFound,
provider: 'comfyui',
};
});
// Execute and verify
await expect(imageService.createImage(mockPayload)).rejects.toMatchObject({
errorType: AgentRuntimeErrorType.ModelNotFound,
provider: 'comfyui',
});
});
it('should handle empty result from workflow', async () => {
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'flux1-schnell-fp8.safetensors',
exists: true,
});
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue({});
mockClientService.executeWorkflow.mockResolvedValue({
images: { images: [] },
});
mockErrorHandler.handleError.mockImplementation((error: any) => {
throw {
error: { message: error.message },
errorType: AgentRuntimeErrorType.ComfyUIBizError,
provider: 'comfyui',
};
});
// Execute and verify
await expect(imageService.createImage(mockPayload)).rejects.toMatchObject({
errorType: AgentRuntimeErrorType.ComfyUIBizError,
provider: 'comfyui',
});
});
});
describe('processImageFetch', () => {
const mockPayloadWithImage: CreateImagePayload = {
model: 'flux-schnell',
params: {
height: 1024,
imageUrl: 'https://s3.test/bucket/image.png',
prompt: 'test prompt',
width: 1024,
},
};
it('should fetch image from URL and upload to ComfyUI', async () => {
// Setup mocks
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'flux1-schnell-fp8.safetensors',
exists: true,
});
// Fetch mocks
const mockImageData = new Uint8Array([1, 2, 3, 4, 5]);
mockFetch.mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(mockImageData.buffer),
ok: true,
});
// Upload mock
mockClientService.uploadImage.mockResolvedValue('img2img_123456.png');
// Workflow mocks
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue({});
mockClientService.executeWorkflow.mockResolvedValue({
images: { images: [{ height: 1024, width: 1024 }] },
});
mockClientService.getPathImage.mockReturnValue('https://comfyui.test/result.png');
// Execute
await imageService.createImage(mockPayloadWithImage);
// Verify fetch was called with the image URL
expect(mockFetch).toHaveBeenCalledWith('https://s3.test/bucket/image.png');
// Note: uploadImage won't be called in test environment since window exists (jsdom)
// and sharp code is skipped. The actual image processing is tested in integration tests.
// Verify the original params are NOT modified (we clone them now)
expect(mockPayloadWithImage.params.imageUrl).toBe('https://s3.test/bucket/image.png');
});
it('should skip processing if imageUrl is already a ComfyUI filename', async () => {
const payloadWithFilename: CreateImagePayload = {
model: 'flux-schnell',
params: {
imageUrl: 'existing_image.png',
prompt: 'test prompt', // Not a URL
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'flux1-schnell-fp8.safetensors',
exists: true,
});
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue({});
mockClientService.executeWorkflow.mockResolvedValue({
images: { images: [{}] },
});
mockClientService.getPathImage.mockReturnValue('result.png');
// Execute
await imageService.createImage(payloadWithFilename);
// Verify fetch was not called
expect(mockFetch).not.toHaveBeenCalled();
expect(mockClientService.uploadImage).not.toHaveBeenCalled();
});
it('should handle fetch error', async () => {
const payload: CreateImagePayload = {
model: 'flux-schnell',
params: {
imageUrl: 'https://s3.test/missing.png',
prompt: 'test prompt',
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'model.safetensors',
exists: true,
});
// Fetch error
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});
mockErrorHandler.handleError.mockImplementation((error: any) => {
throw error;
});
// Execute and verify
await expect(imageService.createImage(payload)).rejects.toThrow(
/Failed to fetch image: 404 Not Found/,
);
});
it('should not modify original params object', async () => {
const originalImageUrl = 'https://s3.test/original.png';
const payload: CreateImagePayload = {
model: 'flux-schnell',
params: {
imageUrl: originalImageUrl,
prompt: 'test prompt',
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'flux1-schnell-fp8.safetensors',
exists: true,
});
// Mock WorkflowDetector to return proper architecture
vi.mocked(WorkflowDetector, true).detectModelType = vi.fn().mockReturnValue({
architecture: 'FLUX',
isSupported: true,
modelType: 'FLUX',
});
// Mock fetch and upload
mockFetch.mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(100)),
ok: true,
});
mockClientService.uploadImage.mockResolvedValue('uploaded.png');
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue({});
mockClientService.executeWorkflow.mockResolvedValue({
images: { images: [{}] },
});
mockClientService.getPathImage.mockReturnValue('result.png');
// Execute
await imageService.createImage(payload);
// Verify original params are NOT modified
expect(payload.params.imageUrl).toBe(originalImageUrl);
});
it('should handle empty image data', async () => {
const payload: CreateImagePayload = {
model: 'flux-schnell',
params: {
imageUrl: 'https://s3.test/empty.png',
prompt: 'test prompt',
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'model.safetensors',
exists: true,
});
// Empty image data
mockFetch.mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
ok: true,
});
mockErrorHandler.handleError.mockImplementation((error: any) => {
throw error;
});
// Execute and verify
await expect(imageService.createImage(payload)).rejects.toThrow(/Invalid image data/);
});
it('should handle network fetch errors', async () => {
const payload: CreateImagePayload = {
model: 'flux-schnell',
params: {
imageUrl: 'https://s3.test/network-error.png',
prompt: 'test prompt',
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'model.safetensors',
exists: true,
});
// Network error
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
mockErrorHandler.handleError.mockImplementation((error: any) => {
throw error;
});
// Execute and verify
await expect(imageService.createImage(payload)).rejects.toThrow(/Failed to fetch/);
});
it('should handle imageUrls array format', async () => {
const payloadWithArray: CreateImagePayload = {
model: 'flux-schnell',
params: {
imageUrls: ['https://s3.test/image.png'],
prompt: 'test prompt',
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'model.safetensors',
exists: true,
});
// S3 mocks
mockFetch.mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer),
ok: true,
});
mockClientService.uploadImage.mockResolvedValue('uploaded.png');
// Workflow mocks
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue({});
mockClientService.executeWorkflow.mockResolvedValue({
images: { images: [{}] },
});
mockClientService.getPathImage.mockReturnValue('result.png');
// Execute
await imageService.createImage(payloadWithArray);
// Verify original params are NOT modified (we clone them now)
expect(payloadWithArray.params.imageUrl).toBeUndefined();
expect(payloadWithArray.params.imageUrls![0]).toBe('https://s3.test/image.png');
});
});
describe('buildWorkflow', () => {
it('should detect unsupported models', async () => {
const payload: CreateImagePayload = {
model: 'unsupported-model',
params: { prompt: 'test prompt' },
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'unsupported.safetensors',
exists: true,
});
// Mock unsupported detection
vi.mocked(WorkflowDetector).detectModelType = vi.fn().mockReturnValue({
isSupported: false,
});
mockErrorHandler.handleError.mockImplementation((error: any) => {
throw {
error: { message: error.message },
errorType: AgentRuntimeErrorType.ModelNotFound,
provider: 'comfyui',
};
});
// Execute and verify
await expect(imageService.createImage(payload)).rejects.toMatchObject({
errorType: AgentRuntimeErrorType.ModelNotFound,
});
});
it('should pass correct parameters to workflow builder', async () => {
const payload: CreateImagePayload = {
model: 'sd3.5-large',
params: {
height: 768,
prompt: 'test',
width: 1024,
},
};
// Setup
mockModelResolverService.validateModel.mockResolvedValue({
actualFileName: 'sd3.5_large.safetensors',
exists: true,
});
const detectionResult = {
architecture: 'sd35-large',
isSupported: true,
modelType: 'SD35',
};
vi.mocked(WorkflowDetector).detectModelType = vi.fn().mockReturnValue(detectionResult);
mockWorkflowBuilderService.buildWorkflow.mockResolvedValue({});
mockClientService.executeWorkflow.mockResolvedValue({
images: { images: [{}] },
});
mockClientService.getPathImage.mockReturnValue('result.png');
// Execute
await imageService.createImage(payload);
// Verify workflow builder was called correctly
expect(mockWorkflowBuilderService.buildWorkflow).toHaveBeenCalledWith(
'sd3.5-large',
detectionResult,
'sd3.5_large.safetensors',
payload.params,
);
});
});
describe('error handling delegation', () => {
it('should delegate all errors to ErrorHandlerService', async () => {
const payload: CreateImagePayload = {
model: 'test',
params: { prompt: 'test prompt' },
};
// Setup error
const testError = new Error('Test error');
mockModelResolverService.validateModel.mockRejectedValue(testError);
mockErrorHandler.handleError.mockImplementation(() => {
throw { original: testError, transformed: true };
});
// Execute
await expect(imageService.createImage(payload)).rejects.toMatchObject({
original: testError,
transformed: true,
});
// Verify error handler was called
expect(mockErrorHandler.handleError).toHaveBeenCalledWith(testError);
});
});
});

View file

@ -0,0 +1,454 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
TEST_CUSTOM_SD,
TEST_FLUX_MODELS,
TEST_MODEL_SETS,
TEST_SD35_MODELS,
} from '@/server/services/comfyui/__tests__/fixtures/testModels';
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
import { ModelResolverService } from '@/server/services/comfyui/core/modelResolverService';
import { ModelResolverError } from '@/server/services/comfyui/errors/modelResolverError';
// Mock ComfyUI Client Service
vi.mock('@/server/services/comfyui/core/comfyUIClientService', () => ({
ComfyUIClientService: vi.fn(),
}));
// Mock the config module
vi.mock('@/server/services/comfyui/config/modelRegistry', () => {
const configs: Record<string, any> = {
'flux1-dev.safetensors': {
family: 'flux',
modelFamily: 'FLUX',
variant: 'dev',
},
'flux1-schnell.safetensors': {
family: 'flux',
modelFamily: 'FLUX',
variant: 'schnell',
},
'sd3.5_large.safetensors': {
family: 'sd35',
features: { inclclip: false },
modelFamily: 'SD3.5',
variant: 'sd35',
},
'sd3.5_large_inclclip.safetensors': {
family: 'sd35',
features: { inclclip: true },
modelFamily: 'SD3.5',
variant: 'sd35-inclclip',
},
'sdxl_base.safetensors': {
family: 'sdxl',
modelFamily: 'SDXL',
variant: 'sdxl-t2i',
},
};
return {
MODEL_ID_VARIANT_MAP: {
'flux-dev': 'dev',
'flux-schnell': 'schnell', // Fixed to match actual mapping
'stable-diffusion-35': 'sd35',
},
MODEL_REGISTRY: configs,
};
});
// Mock the staticModelLookup module
vi.mock('../utils/staticModelLookup', () => {
const configs: Record<string, any> = {
'flux1-dev.safetensors': {
family: 'flux',
modelFamily: 'FLUX',
variant: 'dev',
},
'flux1-schnell.safetensors': {
family: 'flux',
modelFamily: 'FLUX',
variant: 'schnell',
},
'sd3.5_large.safetensors': {
family: 'sd35',
features: { inclclip: false },
modelFamily: 'SD3.5',
variant: 'sd35',
},
'sd3.5_large_inclclip.safetensors': {
family: 'sd35',
features: { inclclip: true },
modelFamily: 'SD3.5',
variant: 'sd35-inclclip',
},
'sdxl_base.safetensors': {
family: 'sdxl',
modelFamily: 'SDXL',
variant: 'sdxl-t2i',
},
};
return {
getModelConfig: vi.fn((filename: string) => {
return configs[filename] || null;
}),
getModelsByVariant: vi.fn((variant: string) => {
// Return models sorted by priority (mock implementation)
const models = Object.entries(configs)
.filter(([, config]) => config.variant === variant)
.map(([filename]) => filename);
return models;
}),
};
});
vi.mock('@/server/services/comfyui/config/systemComponents', () => ({
SYSTEM_COMPONENTS: {
'clip_g.safetensors': {
modelFamily: 'SD3',
priority: 1,
type: 'clip',
},
'clip_l.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 'clip',
},
't5-v1_1-xxl-encoder.safetensors': {
modelFamily: 'FLUX',
priority: 2,
type: 't5',
},
't5xxl_fp16.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 't5',
},
},
getSystemComponents: vi.fn(() => ({
flux: {
clip: ['t5xxl_fp16.safetensors', 'clip_l.safetensors'],
t5: 't5-v1_1-xxl-encoder',
},
sd35: {
clip: ['clip_g.safetensors', 'clip_l.safetensors', 't5xxl_fp16.safetensors'],
},
})),
}));
describe('ModelResolverService', () => {
let service: ModelResolverService;
let mockClientService: any;
beforeEach(() => {
vi.clearAllMocks();
mockClientService = {
getCheckpoints: vi.fn(),
getNodeDefs: vi.fn(),
};
service = new ModelResolverService(mockClientService as ComfyUIClientService);
});
describe('resolveModelFileName', () => {
it('should return undefined for unregistered model ID', async () => {
// Model not in registry and not on server should return undefined
mockClientService.getCheckpoints.mockResolvedValue([TEST_SD35_MODELS.LARGE]);
const result = await service.resolveModelFileName('nonexistent-model');
expect(result).toBeUndefined();
});
it('should return filename if already a file', async () => {
// Mock getCheckpoints to include the file
mockClientService.getCheckpoints.mockResolvedValue([
TEST_FLUX_MODELS.DEV,
TEST_FLUX_MODELS.SCHNELL,
]);
const result = await service.resolveModelFileName(TEST_FLUX_MODELS.DEV);
expect(result).toBe(TEST_FLUX_MODELS.DEV);
});
it('should use cache on subsequent calls', async () => {
// Use a non-registry model that requires server check
const customModel = 'custom_test_model.safetensors';
mockClientService.getCheckpoints.mockResolvedValue([customModel]);
// First call
await service.resolveModelFileName(customModel);
// Second call should use cache
const result = await service.resolveModelFileName(customModel);
expect(result).toBe(customModel);
// Should only call once due to caching
expect(mockClientService.getCheckpoints).toHaveBeenCalledTimes(1);
});
it('should resolve custom SD model to fixed filename', async () => {
mockClientService.getCheckpoints.mockResolvedValue([TEST_CUSTOM_SD, TEST_SD35_MODELS.LARGE]);
const result = await service.resolveModelFileName('stable-diffusion-custom');
expect(result).toBe(TEST_CUSTOM_SD);
});
it('should resolve custom SD refiner model to same fixed filename', async () => {
mockClientService.getCheckpoints.mockResolvedValue([TEST_CUSTOM_SD, TEST_SD35_MODELS.LARGE]);
const result = await service.resolveModelFileName('stable-diffusion-custom-refiner');
expect(result).toBe(TEST_CUSTOM_SD);
});
it('should throw error if custom SD model file not found', async () => {
mockClientService.getCheckpoints.mockResolvedValue([TEST_SD35_MODELS.LARGE]);
const result = await service.resolveModelFileName('stable-diffusion-custom');
expect(result).toBeUndefined();
});
});
describe('validateModel', () => {
it('should validate existing model file on server', async () => {
mockClientService.getCheckpoints.mockResolvedValue([
TEST_FLUX_MODELS.DEV,
TEST_FLUX_MODELS.SCHNELL,
]);
const result = await service.validateModel(TEST_FLUX_MODELS.DEV);
expect(result.exists).toBe(true);
expect(result.actualFileName).toBe(TEST_FLUX_MODELS.DEV);
});
it('should throw error for non-existent model', async () => {
mockClientService.getCheckpoints.mockResolvedValue([TEST_SD35_MODELS.LARGE]);
await expect(service.validateModel(TEST_MODEL_SETS.NON_EXISTENT[0])).rejects.toThrow(
'Model not found: , please install one first.',
);
});
it('should re-throw ModelResolverError from network errors', async () => {
// Network error in getCheckpoints leads to CONNECTION_ERROR in handleApiError
// But then resolveModelFileName catches it and throws MODEL_NOT_FOUND
mockClientService.getCheckpoints.mockRejectedValue(new TypeError('Failed to fetch'));
try {
await service.validateModel('test-model');
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeInstanceOf(ModelResolverError);
// The error gets re-thrown as MODEL_NOT_FOUND by resolveModelFileName
expect((error as any).reason).toBe('MODEL_NOT_FOUND');
}
});
});
describe('cache management', () => {
it('should use cached VAE data when available', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {
input: {
required: {
vae_name: [['vae1.safetensors', 'vae2.safetensors']],
},
},
},
});
// First call - populates cache
const result1 = await service.getAvailableVAEFiles();
expect(result1).toEqual(['vae1.safetensors', 'vae2.safetensors']);
expect(mockClientService.getNodeDefs).toHaveBeenCalledTimes(1);
// Second call - ModelResolverService doesn't cache, but ClientService does
const result2 = await service.getAvailableVAEFiles();
expect(result2).toEqual(['vae1.safetensors', 'vae2.safetensors']);
expect(mockClientService.getNodeDefs).toHaveBeenCalledTimes(2); // Called again, caching is in ClientService
});
it('should use cached component data when available', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [['model1.safetensors', 'model2.safetensors']],
},
},
},
});
// First call - populates cache
const result1 = await service.getAvailableComponentFiles(
'CheckpointLoaderSimple',
'ckpt_name',
);
expect(result1).toEqual(['model1.safetensors', 'model2.safetensors']);
expect(mockClientService.getNodeDefs).toHaveBeenCalledTimes(1);
// Second call - ModelResolverService doesn't cache, but ClientService does
const result2 = await service.getAvailableComponentFiles(
'CheckpointLoaderSimple',
'ckpt_name',
);
expect(result2).toEqual(['model1.safetensors', 'model2.safetensors']);
expect(mockClientService.getNodeDefs).toHaveBeenCalledTimes(2); // Called again, caching is in ClientService
});
});
describe('getAvailableVAEFiles edge cases', () => {
it('should handle non-array VAE list from getNodeDefs', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {
input: {
required: {
vae_name: [{}], // Object instead of array
},
},
},
});
const result = await service.getAvailableVAEFiles();
expect(result).toEqual([]);
});
it('should handle missing VAELoader node', async () => {
mockClientService.getNodeDefs.mockResolvedValue({});
const result = await service.getAvailableVAEFiles();
expect(result).toEqual([]);
});
it('should handle missing input in VAELoader', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {},
});
const result = await service.getAvailableVAEFiles();
expect(result).toEqual([]);
});
it('should handle missing required in VAELoader input', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {
input: {},
},
});
const result = await service.getAvailableVAEFiles();
expect(result).toEqual([]);
});
it('should handle missing vae_name in required', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {
input: {
required: {},
},
},
});
const result = await service.getAvailableVAEFiles();
expect(result).toEqual([]);
});
});
describe('getAvailableComponentFiles edge cases', () => {
it('should handle non-array component list', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {
input: {
required: {
vae_name: [{}], // Object instead of array
},
},
},
});
const result = await service.getAvailableComponentFiles('VAELoader', 'vae_name');
expect(result).toEqual([]);
});
it('should handle string component list', async () => {
mockClientService.getNodeDefs.mockResolvedValue({
VAELoader: {
input: {
required: {
vae_name: ['not-an-array'], // String instead of array
},
},
},
});
const result = await service.getAvailableComponentFiles('VAELoader', 'vae_name');
expect(result).toEqual([]);
});
});
describe('validateModel edge cases', () => {
it('should re-throw non-ModelResolverError errors', async () => {
// Mock to throw a regular error instead of ModelResolverError
mockClientService.getCheckpoints.mockRejectedValue(new Error('Network error'));
await expect(service.validateModel('test-model.safetensors')).rejects.toThrow(
'Network error',
);
});
it('should re-throw ModelResolverError', async () => {
// Mock to throw ModelResolverError
const modelError = new ModelResolverError('Test error', 'TEST_ERROR');
mockClientService.getCheckpoints.mockRejectedValue(modelError);
await expect(service.validateModel('test-model.safetensors')).rejects.toThrow(
ModelResolverError,
);
});
it('should include expected files in error message when model not found', async () => {
// Mock getCheckpoints to return empty array (no models available)
mockClientService.getCheckpoints.mockResolvedValue([]);
// Validate a known variant should throw with expected files
await expect(service.validateModel('comfyui/flux-schnell')).rejects.toMatchObject({
details: {
// The actual variant from MODEL_ID_VARIANT_MAP
expectedFiles: expect.arrayContaining(['flux1-schnell.safetensors']),
modelId: 'comfyui/flux-schnell',
variant: 'schnell',
},
message: expect.stringContaining(
'Model not found: flux1-schnell.safetensors, please install one first.',
),
});
// Also verify the message contains expected files
await expect(service.validateModel('comfyui/flux-schnell')).rejects.toThrow(
'Model not found: flux1-schnell.safetensors, please install one first.',
);
});
it('should not include expected files for unknown models', async () => {
// Mock getCheckpoints to return empty array
mockClientService.getCheckpoints.mockResolvedValue([]);
// Validate an unknown model should throw without expected files
await expect(service.validateModel('comfyui/unknown-model')).rejects.toMatchObject({
details: {
expectedFiles: [],
modelId: 'comfyui/unknown-model',
variant: undefined,
},
message: 'Model not found: , please install one first.',
});
// Verify the message doesn't contain expected files
await expect(service.validateModel('comfyui/unknown-model')).rejects.toThrow(
'Model not found: , please install one first.',
);
});
});
});

View file

@ -0,0 +1,294 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Import real test data
import {
TEST_COMPONENTS,
TEST_MODELS,
} from '@/server/services/comfyui/__tests__/helpers/realConfigData';
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
import { ModelResolverService } from '@/server/services/comfyui/core/modelResolverService';
import {
WorkflowBuilderService,
WorkflowContext,
} from '@/server/services/comfyui/core/workflowBuilderService';
import { WorkflowError } from '@/server/services/comfyui/errors';
// Mock dependencies (must be before other imports)
vi.mock('@/server/services/comfyui/core/comfyUIClientService');
vi.mock('@/server/services/comfyui/core/modelResolverService');
// No need to mock modelRegistry - we want to use the real implementation
describe('WorkflowBuilderService', () => {
let service: WorkflowBuilderService;
let mockContext: WorkflowContext;
let mockModelResolver: any;
beforeEach(() => {
vi.clearAllMocks();
mockModelResolver = {
getAvailableVAEFiles: vi.fn().mockResolvedValue(['sdxl_vae.safetensors']),
getOptimalComponent: vi.fn(),
};
mockContext = {
clientService: {} as ComfyUIClientService,
modelResolverService: mockModelResolver as ModelResolverService,
};
service = new WorkflowBuilderService(mockContext);
});
describe('buildWorkflow', () => {
it('should build FLUX workflow', async () => {
// Mock component resolution with real component names
mockModelResolver.getOptimalComponent
.mockResolvedValueOnce(TEST_COMPONENTS.flux.t5) // First call for t5
.mockResolvedValueOnce(TEST_COMPONENTS.flux.clip); // Second call for clip
const workflow = await service.buildWorkflow(
'flux-1-dev',
{ architecture: 'FLUX', isSupported: true },
TEST_MODELS.flux,
{
cfg: 3.5,
height: 1024,
prompt: 'A beautiful landscape',
steps: 20,
width: 1024,
},
);
expect(workflow).toBeDefined();
expect(workflow.workflow).toBeDefined();
// Verify component resolution was called
expect(mockModelResolver.getOptimalComponent).toHaveBeenCalled();
});
it('should build SD/SDXL workflow with VAE', async () => {
mockModelResolver.getOptimalComponent.mockResolvedValue(TEST_COMPONENTS.sd.vae);
const workflow = await service.buildWorkflow(
'stable-diffusion-xl',
{ architecture: 'SDXL', isSupported: true },
TEST_MODELS.sdxl,
{
cfg: 7,
height: 1024,
negativePrompt: 'blurry, ugly',
prompt: 'A beautiful landscape',
steps: 20,
width: 1024,
},
);
expect(workflow).toBeDefined();
expect(workflow.workflow).toBeDefined();
// Check if VAE loader was added
const nodes = workflow.workflow as any;
const vaeNode = Object.values(nodes).find((node: any) => node.class_type === 'VAELoader');
expect(vaeNode).toBeDefined();
});
it('should build SD3.5 workflow', async () => {
mockModelResolver.getOptimalComponent
.mockResolvedValueOnce(TEST_COMPONENTS.sd.clip) // clip_g
.mockResolvedValueOnce(TEST_COMPONENTS.flux.clip) // clip_l (reuse from FLUX)
.mockResolvedValueOnce(TEST_COMPONENTS.flux.t5); // t5xxl (reuse from FLUX)
const workflow = await service.buildWorkflow(
'stable-diffusion-35',
{ architecture: 'SD3', isSupported: true, variant: 'sd35' },
TEST_MODELS.sd35,
{
cfg: 4.5,
height: 1024,
prompt: 'A futuristic city',
shift: 3,
steps: 28,
width: 1024,
},
);
expect(workflow).toBeDefined();
expect(workflow.workflow).toBeDefined();
// Check for SD3.5 specific nodes
const nodes = workflow.workflow as any;
const samplingNode = Object.values(nodes).find(
(node: any) => node.class_type === 'ModelSamplingSD3',
);
expect(samplingNode).toBeDefined();
});
it('should throw error for unsupported model type', async () => {
await expect(
service.buildWorkflow(
'unknown-model',
{ architecture: 'UNKNOWN' as any, isSupported: false },
'unknown.safetensors',
{},
),
).rejects.toThrow(WorkflowError);
});
});
describe('FLUX workflow specifics', () => {
it('should throw error when required components not found', async () => {
// Mock component resolution to fail - should throw error (not use defaults)
mockModelResolver.getOptimalComponent.mockRejectedValue(
new Error('Required CLIP component not found'),
);
// Should throw error because required components are missing
await expect(
service.buildWorkflow(
'flux-1-dev',
{ architecture: 'FLUX', isSupported: true },
TEST_MODELS.flux,
{ prompt: 'test' },
),
).rejects.toThrow('Required CLIP component not found');
});
it('should use default parameters when not provided', async () => {
// Mock component resolution with real components
mockModelResolver.getOptimalComponent
.mockResolvedValueOnce(TEST_COMPONENTS.flux.t5)
.mockResolvedValueOnce(TEST_COMPONENTS.flux.clip);
const workflow = await service.buildWorkflow(
'flux-1-dev',
{ architecture: 'FLUX', isSupported: true },
TEST_MODELS.flux,
{ prompt: 'test' },
);
// The workflow should be built with default params
expect(workflow).toBeDefined();
// Verify component resolution was called
expect(mockModelResolver.getOptimalComponent).toHaveBeenCalled();
});
});
describe('SD/SDXL workflow specifics', () => {
it('should build workflow with VAE loader when external VAE specified', async () => {
// For SDXL, the workflow will use sdxl_vae.safetensors from getOptimalVAEForModel
mockModelResolver.getOptimalComponent.mockResolvedValue('sdxl_vae.safetensors');
const workflow = await service.buildWorkflow(
'stable-diffusion-xl',
{ architecture: 'SDXL', isSupported: true },
TEST_MODELS.sdxl,
{ prompt: 'test' },
);
const nodes = workflow.workflow as any;
const vaeLoader = Object.values(nodes).find((node: any) => node.class_type === 'VAELoader');
// Should have VAE loader with the priority 1 SDXL VAE from config
expect(vaeLoader).toBeDefined();
expect((vaeLoader as any).inputs.vae_name).toBe('sdxl_vae.safetensors');
});
it('should support custom sampler and scheduler', async () => {
mockModelResolver.getOptimalComponent.mockResolvedValue(undefined);
const workflow = await service.buildWorkflow(
'stable-diffusion-xl',
{ architecture: 'SDXL', isSupported: true },
TEST_MODELS.sdxl,
{
prompt: 'test',
samplerName: 'dpmpp_2m',
scheduler: 'karras',
},
);
const nodes = workflow.workflow as any;
const samplerNode = Object.values(nodes).find(
(node: any) => node.class_type === 'KSampler',
) as any;
expect(samplerNode.inputs.sampler_name).toBe('dpmpp_2m');
expect(samplerNode.inputs.scheduler).toBe('karras');
});
});
describe('SD3.5 workflow specifics', () => {
it('should use Triple CLIP loader when components available', async () => {
mockModelResolver.getOptimalComponent
.mockResolvedValueOnce(TEST_COMPONENTS.sd.clip) // clip_g
.mockResolvedValueOnce(TEST_COMPONENTS.flux.clip) // clip_l
.mockResolvedValueOnce(TEST_COMPONENTS.flux.t5); // t5xxl
const workflow = await service.buildWorkflow(
'stable-diffusion-35',
{ architecture: 'SD3', isSupported: true, variant: 'sd35' },
TEST_MODELS.sd35,
{ prompt: 'test' },
);
const nodes = workflow.workflow as any;
const tripleClipNode = Object.values(nodes).find(
(node: any) => node.class_type === 'TripleCLIPLoader',
);
expect(tripleClipNode).toBeDefined();
});
it('should throw error when components not available', async () => {
// Mock no components available
mockModelResolver.getOptimalComponent.mockResolvedValue(undefined);
await expect(
service.buildWorkflow(
'stable-diffusion-35',
{ architecture: 'SD3', isSupported: true, variant: 'sd35' },
TEST_MODELS.sd35,
{ prompt: 'test' },
),
).rejects.toThrow(WorkflowError);
await expect(
service.buildWorkflow(
'stable-diffusion-35',
{ architecture: 'SD3', isSupported: true, variant: 'sd35' },
TEST_MODELS.sd35,
{ prompt: 'test' },
),
).rejects.toThrow('SD3.5 models require external CLIP/T5 encoder files');
});
it('should use default shift parameter', async () => {
// Mock components available for this test
let callCount = 0;
mockModelResolver.getOptimalComponent.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve('clip_l.safetensors');
if (callCount === 2) return Promise.resolve('clip_g.safetensors');
return Promise.resolve('t5xxl_fp16.safetensors');
});
const workflow = await service.buildWorkflow(
'stable-diffusion-35',
{ architecture: 'SD3', isSupported: true, variant: 'sd35' },
TEST_MODELS.sd35,
{
cfg: 4.5,
prompt: 'test',
steps: 28,
},
);
const nodes = workflow.workflow as any;
const samplingNode = Object.values(nodes).find(
(node: any) => node.class_type === 'ModelSamplingSD3',
) as any;
expect(samplingNode.inputs.shift).toBe(3); // Uses default from WORKFLOW_DEFAULTS.SD3.SHIFT
});
});
});

View file

@ -0,0 +1,140 @@
import {
fluxDevParamsSchema,
fluxKontextDevParamsSchema,
fluxSchnellParamsSchema,
sd15T2iParamsSchema,
sd35ParamsSchema,
sdxlT2iParamsSchema,
} from 'model-bank/comfyui';
export const parametersFixture = {
models: {
'flux-dev': {
boundaries: {
max: {
cfg: fluxDevParamsSchema.cfg!.max,
steps: fluxDevParamsSchema.steps!.max,
},
min: {
cfg: fluxDevParamsSchema.cfg!.min,
steps: fluxDevParamsSchema.steps!.min,
},
},
defaults: {
cfg: fluxDevParamsSchema.cfg!.default,
samplerName: fluxDevParamsSchema.samplerName!.default,
scheduler: fluxDevParamsSchema.scheduler!.default,
steps: fluxDevParamsSchema.steps!.default,
},
schema: fluxDevParamsSchema,
},
'flux-kontext': {
boundaries: {
max: {
cfg: fluxKontextDevParamsSchema.cfg!.max,
steps: fluxKontextDevParamsSchema.steps!.max,
},
min: {
cfg: fluxKontextDevParamsSchema.cfg!.min,
steps: fluxKontextDevParamsSchema.steps!.min,
},
},
defaults: {
cfg: fluxKontextDevParamsSchema.cfg!.default,
steps: fluxKontextDevParamsSchema.steps!.default,
strength: fluxKontextDevParamsSchema.strength!.default,
},
schema: fluxKontextDevParamsSchema,
},
'flux-schnell': {
boundaries: {
max: {
cfg: 1,
steps: fluxSchnellParamsSchema.steps!.max,
},
min: {
cfg: 1,
steps: fluxSchnellParamsSchema.steps!.min,
},
},
defaults: {
cfg: 1,
samplerName: fluxSchnellParamsSchema.samplerName!.default,
scheduler: fluxSchnellParamsSchema.scheduler!.default,
// Schnell fixed at 1
steps: fluxSchnellParamsSchema.steps!.default,
},
schema: fluxSchnellParamsSchema,
},
'sd15': {
boundaries: {
max: {
cfg: sd15T2iParamsSchema.cfg!.max,
steps: sd15T2iParamsSchema.steps!.max,
},
min: {
cfg: sd15T2iParamsSchema.cfg!.min,
steps: sd15T2iParamsSchema.steps!.min,
},
},
defaults: {
cfg: sd15T2iParamsSchema.cfg!.default,
samplerName: sd15T2iParamsSchema.samplerName!.default,
scheduler: sd15T2iParamsSchema.scheduler!.default,
steps: sd15T2iParamsSchema.steps!.default,
},
schema: sd15T2iParamsSchema,
},
'sd35': {
boundaries: {
max: {
cfg: sd35ParamsSchema.cfg!.max,
steps: sd35ParamsSchema.steps!.max,
},
min: {
cfg: sd35ParamsSchema.cfg!.min,
steps: sd35ParamsSchema.steps!.min,
},
},
defaults: {
cfg: sd35ParamsSchema.cfg!.default,
samplerName: sd35ParamsSchema.samplerName!.default,
scheduler: sd35ParamsSchema.scheduler!.default,
steps: sd35ParamsSchema.steps!.default,
},
schema: sd35ParamsSchema,
},
'sdxl': {
boundaries: {
max: {
cfg: sdxlT2iParamsSchema.cfg!.max,
steps: sdxlT2iParamsSchema.steps!.max,
},
min: {
cfg: sdxlT2iParamsSchema.cfg!.min,
steps: sdxlT2iParamsSchema.steps!.min,
},
},
defaults: {
cfg: sdxlT2iParamsSchema.cfg!.default,
samplerName: sdxlT2iParamsSchema.samplerName!.default,
scheduler: sdxlT2iParamsSchema.scheduler!.default,
steps: sdxlT2iParamsSchema.steps!.default,
},
schema: sdxlT2iParamsSchema,
},
},
transformations: {
aspectRatio: [
{ expected: { height: 576, width: 1024 }, input: '16:9' },
{ expected: { height: 1024, width: 1024 }, input: '1:1' },
{ expected: { height: 1024, width: 576 }, input: '9:16' },
],
imageUrl: [
{ expectedParam: 'imageUrl', input: 'test.png', mode: 'img2img' },
{ expectedParam: undefined, input: undefined, mode: 'txt2img' },
],
},
};

View file

@ -0,0 +1,97 @@
// 可扩展的支持配置接口
export interface SupportedConfig {
extensions?: Record<string, any>;
models: Record<string, string[]>;
workflows: string[];
}
// 基础支持配置
const baseConfig: SupportedConfig = {
extensions: {},
models: {
flux: ['flux-dev', 'flux-schnell', 'flux-kontext', 'flux-krea'],
sd: ['sd15', 'sdxl', 'sd35'],
},
workflows: ['flux-dev', 'flux-schnell', 'flux-kontext', 'flux-krea', 'simple-sd', 'sd35'],
};
// 动态配置合并函数
function mergeConfig(base: SupportedConfig, custom?: Partial<SupportedConfig>): SupportedConfig {
if (!custom) return base;
return {
// 去重
extensions: {
...base.extensions,
...custom.extensions,
},
models: {
...base.models,
...custom.models,
},
workflows: [...base.workflows, ...(custom.workflows || [])].filter(
(workflow, index, array) => array.indexOf(workflow) === index,
),
};
}
// 可扩展的 fixture 对象
export const supportedFixture = {
// 扩展工具函数
addCustomModels: (modelType: string, models: string[]) => {
baseConfig.models[modelType] = [...(baseConfig.models[modelType] || []), ...models].filter(
(model, index, array) => array.indexOf(model) === index,
);
},
addCustomWorkflows: (workflows: string[]) => {
baseConfig.workflows = [...baseConfig.workflows, ...workflows].filter(
(workflow, index, array) => array.indexOf(workflow) === index,
);
},
// 获取当前配置(支持自定义扩展)
getConfig: (customConfig?: Partial<SupportedConfig>): SupportedConfig => {
return mergeConfig(baseConfig, customConfig);
},
// 验证帮助函数
isSupported: (model: string, customConfig?: Partial<SupportedConfig>) => {
const config = mergeConfig(baseConfig, customConfig);
const allModels = Object.values(config.models).flat();
return allModels.includes(model);
},
// 向后兼容的属性(保持现有测试不受影响)
models: baseConfig.models,
// 重置为基础配置(用于测试隔离)
reset: () => {
baseConfig.models = {
flux: ['flux-dev', 'flux-schnell', 'flux-kontext', 'flux-krea'],
sd: ['sd15', 'sdxl', 'sd35'],
};
baseConfig.workflows = [
'flux-dev',
'flux-schnell',
'flux-kontext',
'flux-krea',
'simple-sd',
'sd35',
];
baseConfig.extensions = {};
},
workflows: baseConfig.workflows,
};

View file

@ -0,0 +1,64 @@
/**
* Real model names from registry for testing
* Using actual registered models instead of fake names
*/
// Real FLUX models from registry
export const TEST_FLUX_MODELS = {
DEV: 'flux1-dev.safetensors',
KONTEXT: 'flux1-kontext-dev.safetensors',
KREA: 'flux1-krea-dev.safetensors',
SCHNELL: 'flux1-schnell.safetensors',
} as const;
// Real SD3.5 models from registry
export const TEST_SD35_MODELS = {
LARGE: 'sd3.5_large.safetensors',
LARGE_FP8: 'sd3.5_large_fp8_scaled.safetensors',
LARGE_TURBO: 'sd3.5_large_turbo.safetensors',
MEDIUM: 'sd3.5_medium.safetensors',
} as const;
// Real SDXL models from registry
export const TEST_SDXL_MODELS = {
BASE: 'sd_xl_base_1.0.safetensors',
TURBO: 'sd_xl_turbo_1.0_fp16.safetensors',
} as const;
// Custom SD model
export const TEST_CUSTOM_SD = 'custom_sd_lobe.safetensors';
// Real component names from system components
export const TEST_COMPONENTS = {
FLUX: {
CLIP_L: 'clip_l.safetensors',
T5: 't5xxl_fp16.safetensors',
VAE: 'ae.safetensors',
},
SD: {
CLIP_G: 'clip_g.safetensors',
CLIP_L: 'clip_l.safetensors',
VAE: 'sdxl_vae_fp16fix.safetensors',
},
} as const;
// Common test model sets for different scenarios
export const TEST_MODEL_SETS = {
// Models that don't exist (for error testing)
NON_EXISTENT: [
'nonexistent-model.safetensors',
'unknown-model.safetensors',
'fake-model.safetensors',
],
// Models that should exist in registry
REGISTERED: [
TEST_FLUX_MODELS.DEV,
TEST_FLUX_MODELS.SCHNELL,
TEST_SD35_MODELS.LARGE,
TEST_SDXL_MODELS.BASE,
],
} as const;
// Default test model for general use
export const DEFAULT_TEST_MODEL = TEST_FLUX_MODELS.DEV;

View file

@ -0,0 +1,98 @@
import { vi } from 'vitest';
import {
TEST_COMPONENTS,
TEST_FLUX_MODELS,
} from '@/server/services/comfyui/__tests__/fixtures/testModels';
import type { WorkflowContext } from '@/server/services/comfyui/core/workflowBuilderService';
/**
* Create a mock WorkflowContext for testing
* WorkflowContext
*/
export function createMockContext(): WorkflowContext {
return {
clientService: {
executeWorkflow: vi.fn().mockResolvedValue({ images: { images: [] } }),
// New SDK wrapper methods
getCheckpoints: vi.fn().mockResolvedValue([TEST_FLUX_MODELS.DEV, TEST_FLUX_MODELS.SCHNELL]),
getLoras: vi.fn().mockResolvedValue(['lora1.safetensors', 'lora2.safetensors']),
getNodeDefs: vi.fn().mockResolvedValue({
CLIPLoader: {
input: {
required: {
clip_name: [['clip_l.safetensors', 'clip_g.safetensors']],
},
},
},
DualCLIPLoader: {
input: {
required: {
clip_name1: [['t5-v1_1-xxl-encoder.safetensors', 't5xxl_fp16.safetensors']],
},
},
},
VAELoader: {
input: {
required: {
vae_name: [
[
'ae.safetensors',
'sdxl_vae_fp16fix.safetensors',
'vae-ft-mse-840000-ema-pruned.safetensors',
],
],
},
},
},
}),
getObjectInfo: vi.fn().mockResolvedValue({}),
getPathImage: vi.fn().mockReturnValue('http://example.com/image.png'),
getSamplerInfo: vi.fn().mockResolvedValue({
sampler: ['euler', 'ddim', 'dpm_2'],
scheduler: ['normal', 'karras', 'exponential'],
}),
validateConnection: vi.fn().mockResolvedValue(undefined),
},
modelResolverService: {
// 新的服务层方法
getOptimalComponent: vi.fn().mockImplementation((type: string, modelFamily: string) => {
// 根据不同的组件类型和模型家族返回相应的默认值
if (type === 't5') {
return Promise.resolve(TEST_COMPONENTS.FLUX.T5);
}
if (type === 'vae') {
if (modelFamily === 'FLUX') {
return Promise.resolve(TEST_COMPONENTS.FLUX.VAE);
}
return Promise.resolve(TEST_COMPONENTS.SD.VAE);
}
if (type === 'clip') {
if (modelFamily === 'FLUX') {
return Promise.resolve(TEST_COMPONENTS.FLUX.CLIP_L);
}
return Promise.resolve(TEST_COMPONENTS.SD.CLIP_G);
}
return Promise.resolve(null);
}),
// 保留旧方法以兼容
selectComponents: vi.fn().mockResolvedValue({
clip: [TEST_COMPONENTS.FLUX.T5, TEST_COMPONENTS.FLUX.CLIP_L],
t5: TEST_COMPONENTS.FLUX.T5,
vae: TEST_COMPONENTS.FLUX.VAE,
}),
validateModel: vi.fn().mockResolvedValue({
actualFileName: TEST_FLUX_MODELS.DEV,
exists: true,
}),
} as any,
} as unknown as WorkflowContext;
}
/**
* Default mock context instance
*
*/
export const mockContext = createMockContext();

View file

@ -0,0 +1,80 @@
/**
* Real configuration data helper for tests
* Uses actual data from configuration files instead of mock data
*/
import { MODEL_REGISTRY } from '@/server/services/comfyui/config/modelRegistry';
import { SYSTEM_COMPONENTS } from '@/server/services/comfyui/config/systemComponents';
import { getModelConfig } from '@/server/services/comfyui/utils/staticModelLookup';
// Export real model entries for tests
export const REAL_MODEL_ENTRIES = Object.entries(MODEL_REGISTRY);
// Get real FLUX models
export const REAL_FLUX_MODELS = REAL_MODEL_ENTRIES.filter(
([, config]) => config.modelFamily === 'FLUX',
).map(([fileName]) => fileName);
// Get real SD models
export const REAL_SD_MODELS = REAL_MODEL_ENTRIES.filter(([, config]) =>
['SD1', 'SDXL', 'SD3'].includes(config.modelFamily),
).map(([fileName]) => fileName);
// Get real system components
export const REAL_COMPONENT_ENTRIES = Object.entries(SYSTEM_COMPONENTS);
// Get real FLUX components
export const REAL_FLUX_COMPONENTS = {
clip: REAL_COMPONENT_ENTRIES.filter(
([, config]) => config.type === 'clip' && config.modelFamily === 'FLUX',
).map(([name]) => name),
t5: REAL_COMPONENT_ENTRIES.filter(
([, config]) => config.type === 't5' && config.modelFamily === 'FLUX',
).map(([name]) => name),
vae: REAL_COMPONENT_ENTRIES.filter(
([, config]) => config.type === 'vae' && config.modelFamily === 'FLUX',
).map(([name]) => name),
};
// Get real SD components
export const REAL_SD_COMPONENTS = {
clip: REAL_COMPONENT_ENTRIES.filter(
([, config]) => config.type === 'clip' && ['SD1', 'SDXL', 'SD3'].includes(config.modelFamily),
).map(([name]) => name),
vae: REAL_COMPONENT_ENTRIES.filter(
([, config]) => config.type === 'vae' && ['SD1', 'SDXL', 'SD3'].includes(config.modelFamily),
).map(([name]) => name),
};
// Export real workflow defaults
// Export real component node mappings
// Helper to get real model config
export const getRealModelConfig = getModelConfig;
// Test data selections (using real data)
export const TEST_MODELS = {
flux: REAL_FLUX_MODELS[0] || 'flux1-dev.safetensors', // Use first real FLUX model
sd35:
REAL_SD_MODELS.find((m) => getRealModelConfig(m)?.modelFamily === 'SD3') ||
'sd3.5_large.safetensors',
sdxl:
REAL_SD_MODELS.find((m) => getRealModelConfig(m)?.modelFamily === 'SDXL') ||
'sdxl_base.safetensors',
};
export const TEST_COMPONENTS = {
flux: {
clip: REAL_FLUX_COMPONENTS.clip[0] || 'clip_l.safetensors',
t5: REAL_FLUX_COMPONENTS.t5[0] || 't5xxl_fp16.safetensors',
vae: REAL_FLUX_COMPONENTS.vae[0] || 'ae.safetensors',
},
sd: {
clip: REAL_SD_COMPONENTS.clip[0] || 'clip_g.safetensors',
vae: REAL_SD_COMPONENTS.vae[0] || 'sdxl_vae_fp16fix.safetensors',
},
};
export {
COMPONENT_NODE_MAPPINGS as REAL_COMPONENT_MAPPINGS,
WORKFLOW_DEFAULTS as REAL_WORKFLOW_DEFAULTS,
} from '@/server/services/comfyui/config/constants';

View file

@ -0,0 +1,219 @@
// @vitest-environment node
import { vi } from 'vitest';
// Common mock setup for ComfyUI tests
export function setupComfyUIMocks() {
// Mock the ComfyUI SDK - keep it simple, tests will override
vi.mock('@saintno/comfyui-sdk', () => ({
CallWrapper: vi.fn(),
ComfyApi: vi.fn(),
PromptBuilder: vi.fn(),
}));
// Mock the ModelResolver
vi.mock('../utils/modelResolver', () => ({
ModelResolver: vi.fn(),
getAllModels: vi.fn().mockReturnValue(['flux-schnell.safetensors', 'flux-dev.safetensors']),
isValidModel: vi.fn().mockReturnValue(true),
resolveModel: vi.fn().mockImplementation(() => {
return {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default' as const,
variant: 'dev' as const,
};
}),
resolveModelStrict: vi.fn().mockImplementation(() => {
return {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default' as const,
variant: 'dev' as const,
};
}),
}));
// Mock fetch globally
global.fetch = vi.fn();
// Mock console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock WorkflowDetector
vi.mock('../utils/workflowDetector', () => ({
WorkflowDetector: {
detectModelType: vi.fn(),
},
}));
// Mock processModels utility
vi.mock('../utils/modelParse', () => ({
MODEL_LIST_CONFIGS: {
comfyui: {
id: 'comfyui',
modelList: [],
},
},
detectModelProvider: vi.fn().mockImplementation((modelId: string) => {
if (modelId.includes('claude')) return 'anthropic';
if (modelId.includes('gpt')) return 'openai';
if (modelId.includes('gemini')) return 'google';
return 'unknown';
}),
processModelList: vi.fn(),
}));
}
export function createMockComfyApi() {
return {
fetchApi: vi.fn().mockResolvedValue({
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [['flux-schnell.safetensors', 'flux-dev.safetensors', 'sd15-base.ckpt']],
},
},
},
}),
getPathImage: vi.fn().mockReturnValue('http://localhost:8000/view?filename=test.png'),
init: vi.fn(),
waitForReady: vi.fn().mockResolvedValue(undefined),
};
}
export function createMockCallWrapper() {
return {
onFailed: vi.fn().mockReturnThis(),
onFinished: vi.fn().mockReturnThis(),
onProgress: vi.fn().mockReturnThis(),
run: vi.fn().mockReturnThis(),
};
}
export function createMockPromptBuilder() {
return {
input: vi.fn().mockReturnThis(),
prompt: {},
setInputNode: vi.fn().mockReturnThis(),
setOutputNode: vi.fn().mockReturnThis(),
} as any;
}
export function createMockModelResolver() {
return {
getAvailableModelFiles: vi
.fn()
.mockResolvedValue(['flux-schnell.safetensors', 'flux-dev.safetensors', 'sd15-base.ckpt']),
resolveModelFileName: vi.fn().mockImplementation((modelId: string) => {
if (
modelId.includes('non-existent') ||
modelId.includes('unknown') ||
modelId.includes('non-verified')
) {
return Promise.reject(new Error(`Model not found: ${modelId}`));
}
const fileName = modelId.split('/').pop() || modelId;
return Promise.resolve(fileName + '.safetensors');
}),
transformModelFilesToList: vi.fn().mockReturnValue([]),
validateModel: vi.fn().mockImplementation((modelId: string) => {
if (
modelId.includes('non-existent') ||
modelId.includes('unknown') ||
modelId.includes('non-verified')
) {
return Promise.resolve({ exists: false });
}
const fileName = modelId.split('/').pop() || modelId;
return Promise.resolve({ actualFileName: fileName + '.safetensors', exists: true });
}),
};
}
// Mock workflow builders
export function setupWorkflowMocks() {
const createMockBuilder = () => ({
input: vi.fn().mockReturnThis(),
prompt: {
'1': {
_meta: { title: 'Checkpoint Loader' },
class_type: 'CheckpointLoaderSimple',
inputs: { ckpt_name: 'test.safetensors' },
},
},
setInputNode: vi.fn().mockReturnThis(),
setOutputNode: vi.fn().mockReturnThis(),
});
// Mock the workflows index
vi.mock('../../workflows', () => ({
buildFluxDevWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
buildFluxKontextWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
buildFluxKreaWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
buildFluxSchnellWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
buildSD35NoClipWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
buildSD35Workflow: vi.fn().mockImplementation(() => createMockBuilder()),
}));
// Mock individual workflow builders
vi.mock('../../workflows/flux-schnell', () => ({
buildFluxSchnellWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
}));
vi.mock('../../workflows/flux-dev', () => ({
buildFluxDevWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
}));
vi.mock('../../workflows/flux-kontext', () => ({
buildFluxKontextWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
}));
vi.mock('../../workflows/sd35', () => ({
buildSD35Workflow: vi.fn().mockImplementation(() => createMockBuilder()),
}));
vi.mock('../../workflows/simple-sd', () => ({
buildSimpleSDWorkflow: vi.fn().mockImplementation(() => createMockBuilder()),
}));
// Mock WorkflowRouter
vi.mock('../utils/workflowRouter', () => {
class WorkflowRoutingError extends Error {
constructor(message?: string) {
super(message);
this.name = 'WorkflowRoutingError';
}
}
return {
WorkflowRouter: {
getExactlySupportedModels: () => ['comfyui/flux-dev', 'comfyui/flux-schnell'],
getSupportedFluxVariants: () => ['dev', 'schnell', 'kontext', 'krea'],
routeWorkflow: () => createMockBuilder(),
},
WorkflowRoutingError,
};
});
// Mock systemComponents
vi.mock('../../config/systemComponents', () => ({
getAllComponentsWithNames: vi.fn().mockImplementation((options: any) => {
if (options?.type === 'clip') {
return [
{ config: { priority: 1 }, name: 'clip_l.safetensors' },
{ config: { priority: 2 }, name: 'clip_g.safetensors' },
];
}
if (options?.type === 't5') {
return [{ config: { priority: 1 }, name: 't5xxl_fp16.safetensors' }];
}
return [];
}),
getOptimalComponent: vi.fn().mockImplementation((type: string) => {
if (type === 't5') return 't5xxl_fp16.safetensors';
if (type === 'vae') return 'ae.safetensors';
if (type === 'clip') return 'clip_l.safetensors';
return 'default.safetensors';
}),
}));
}

View file

@ -0,0 +1,138 @@
// @vitest-environment node
import { PromptBuilder } from '@saintno/comfyui-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { parametersFixture } from '@/server/services/comfyui/__tests__/fixtures/parameters.fixture';
import { supportedFixture } from '@/server/services/comfyui/__tests__/fixtures/supported.fixture';
import { mockContext } from '@/server/services/comfyui/__tests__/helpers/mockContext';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
// Import workflow builders
import { buildFluxDevWorkflow } from '@/server/services/comfyui/workflows/flux-dev';
import { buildFluxKontextWorkflow } from '@/server/services/comfyui/workflows/flux-kontext';
import { buildFluxSchnellWorkflow } from '@/server/services/comfyui/workflows/flux-schnell';
import { buildSD35Workflow } from '@/server/services/comfyui/workflows/sd35';
import { buildSimpleSDWorkflow } from '@/server/services/comfyui/workflows/simple-sd';
describe('Parameter Mapping - Core Business Logic', () => {
const { models } = parametersFixture;
let inputCalls: Map<string, any>;
beforeEach(() => {
const mocks = setupAllMocks();
inputCalls = mocks.inputCalls;
});
// Workflow builder mapping
const workflowBuilders = {
'flux-dev': buildFluxDevWorkflow,
'flux-schnell': buildFluxSchnellWorkflow,
'flux-kontext': buildFluxKontextWorkflow,
'sd15': buildSimpleSDWorkflow,
'sdxl': buildSimpleSDWorkflow,
'sd35': buildSD35Workflow,
};
// Parameterized tests for all supported models
describe.each(
Object.entries(models).filter(
([name]) => workflowBuilders[name as keyof typeof workflowBuilders],
),
)('%s parameter mapping', (modelName, modelConfig) => {
const builder = workflowBuilders[modelName as keyof typeof workflowBuilders];
it('should map schema parameters to workflow', async () => {
const params: any = {
prompt: 'test prompt',
...modelConfig.defaults,
};
// Special parameter handling
if (modelName === 'flux-kontext') {
params.imageUrl = 'test.png';
}
if (modelName.startsWith('sd')) {
params.width = 512;
params.height = 512;
} else if (modelName.startsWith('flux') && modelName !== 'flux-kontext') {
params.width = 1024;
params.height = 1024;
}
// Build workflow
const workflow = await builder(`${modelName}.safetensors`, params, mockContext);
// Verify workflow is built successfully
expect(workflow).toBeDefined();
// Verify PromptBuilder was used for workflow construction
expect(workflow).toBeDefined();
expect(typeof workflow).toBe('object');
});
it('should handle boundary values', async () => {
const { min, max } = modelConfig.boundaries!;
const baseParams = {
prompt: 'test prompt',
width: 512,
height: 512,
};
// Minimum values should not error
const minResult = await builder(
`${modelName}.safetensors`,
{ ...baseParams, ...modelConfig.defaults, ...min },
mockContext,
);
expect(minResult).toBeDefined();
// Maximum values should not error
const maxResult = await builder(
`${modelName}.safetensors`,
{ ...baseParams, ...modelConfig.defaults, ...max },
mockContext,
);
expect(maxResult).toBeDefined();
});
});
// Parameter transformation tests
describe('Parameter Transformations', () => {
it.each(parametersFixture.transformations.aspectRatio)(
'should transform aspectRatio $input to width/height',
async ({ input, expected }) => {
const params = {
prompt: 'test prompt',
...models['flux-dev'].defaults,
aspectRatio: input,
};
const workflow = await buildFluxDevWorkflow('flux-dev.safetensors', params, mockContext);
// Verify workflow builds successfully
expect(workflow).toBeDefined();
// aspectRatio should be processed (verified through successful workflow build)
const workflowStr = JSON.stringify(workflow.workflow || workflow);
expect(workflowStr).not.toContain('aspectRatio');
},
);
it('should handle imageUrl for img2img mode', async () => {
const params = {
prompt: 'test prompt',
imageUrl: 'test-image.png',
strength: 0.8,
};
const workflow = await buildFluxKontextWorkflow(
'flux-kontext.safetensors',
params,
mockContext,
);
// Verify workflow builds successfully (img2img parameters processed)
expect(workflow).toBeDefined();
});
});
});

View file

@ -0,0 +1,88 @@
// @vitest-environment node
import { beforeEach, describe, expect, it } from 'vitest';
import { parametersFixture } from '@/server/services/comfyui/__tests__/fixtures/parameters.fixture';
import { mockContext } from '@/server/services/comfyui/__tests__/helpers/mockContext';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
// Import transformation utilities
import { buildFluxDevWorkflow } from '@/server/services/comfyui/workflows/flux-dev';
import { buildFluxKontextWorkflow } from '@/server/services/comfyui/workflows/flux-kontext';
describe('Parameter Transformation Tests', () => {
let inputCalls: Map<string, any>;
beforeEach(() => {
const mocks = setupAllMocks();
inputCalls = mocks.inputCalls;
});
describe('AspectRatio Transformation', () => {
it.each(parametersFixture.transformations.aspectRatio)(
'should handle aspectRatio $input correctly',
async ({ input }) => {
const params = {
prompt: 'test prompt',
...parametersFixture.models['flux-dev'].defaults,
aspectRatio: input,
};
// Should successfully build workflow
await expect(
buildFluxDevWorkflow('flux-dev.safetensors', params, mockContext),
).resolves.toBeDefined();
},
);
});
describe('Image URL Processing', () => {
it('should process imageUrl for img2img workflows', async () => {
const params = {
prompt: 'test prompt',
imageUrl: 'test-image.png',
strength: 0.8,
};
// Kontext supports img2img
await expect(
buildFluxKontextWorkflow('flux-kontext.safetensors', params, mockContext),
).resolves.toBeDefined();
});
it('should handle missing imageUrl gracefully', async () => {
const params = {
prompt: 'test prompt',
// No imageUrl provided
};
// Should build normally (may fallback to txt2img mode)
await expect(
buildFluxKontextWorkflow('flux-kontext.safetensors', params, mockContext),
).resolves.toBeDefined();
});
});
describe('Parameter Validation', () => {
it('should handle valid parameter ranges', () => {
Object.entries(parametersFixture.models).forEach(([modelName, config]) => {
const { min, max } = (config as any).boundaries!;
// Minimum values within range
Object.entries(min).forEach(([key, value]) => {
expect(typeof value).toBe('number');
expect(value).toBeGreaterThanOrEqual(0);
});
// Maximum values within reasonable range
Object.entries(max).forEach(([key, value]) => {
expect(typeof value).toBe('number');
if (key === 'cfg') {
expect(value).toBeLessThanOrEqual(20);
}
if (key === 'steps') {
expect(value).toBeLessThanOrEqual(150);
}
});
});
});
});
});

View file

@ -0,0 +1,160 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { parametersFixture } from '@/server/services/comfyui/__tests__/fixtures/parameters.fixture';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
import { ComfyUIClientService } from '@/server/services/comfyui/core/comfyUIClientService';
// Import services for testing
import { ImageService } from '@/server/services/comfyui/core/imageService';
import { ModelResolverService } from '@/server/services/comfyui/core/modelResolverService';
import { WorkflowBuilderService } from '@/server/services/comfyui/core/workflowBuilderService';
describe('Service Integration - Module Level', () => {
let imageService: ImageService;
let clientService: ComfyUIClientService;
let modelResolverService: ModelResolverService;
let workflowBuilderService: WorkflowBuilderService;
let inputCalls: Map<string, any>;
beforeEach(() => {
const mocks = setupAllMocks();
inputCalls = mocks.inputCalls;
// 创建服务实例
clientService = new ComfyUIClientService();
modelResolverService = new ModelResolverService(clientService);
workflowBuilderService = new WorkflowBuilderService({
clientService,
modelResolverService,
});
imageService = new ImageService(clientService, modelResolverService, workflowBuilderService);
});
describe('Service Coordination', () => {
it('should coordinate model resolution and workflow building', async () => {
const modelResolverSpy = vi.spyOn(modelResolverService, 'validateModel');
const validateConnectionSpy = vi.spyOn(clientService, 'validateConnection');
// Mock successful connection validation
validateConnectionSpy.mockResolvedValue(true);
// Mock successful model validation
modelResolverSpy.mockResolvedValue({
exists: true,
actualFileName: 'flux-dev.safetensors',
});
const params = {
model: 'flux-dev',
params: {
prompt: 'test prompt',
...parametersFixture.models['flux-dev'].defaults,
width: 1024,
height: 1024,
},
};
try {
await imageService.createImage(params);
} catch (error) {
// 预期在 mock 环境中可能有错误
console.log('Expected error in mock environment:', error);
}
// 验证服务调用顺序
expect(validateConnectionSpy).toHaveBeenCalled();
expect(modelResolverSpy).toHaveBeenCalledWith('flux-dev');
});
});
describe('Context Passing', () => {
it('should pass context between services correctly', async () => {
const context = {
clientService,
modelResolverService,
};
// 验证 WorkflowBuilderService 接收正确的 context
expect(workflowBuilderService).toBeDefined();
// 测试 context 中的服务是否可用
expect(clientService).toBeDefined();
expect(modelResolverService).toBeDefined();
});
});
describe('Error Propagation Between Services', () => {
it('should propagate errors from model resolver to image service', async () => {
const modelResolverSpy = vi.spyOn(modelResolverService, 'validateModel');
modelResolverSpy.mockRejectedValue(new Error('Model validation failed'));
const params = {
model: 'invalid-model',
params: {
prompt: 'test prompt',
},
};
await expect(imageService.createImage(params)).rejects.toBeDefined();
});
it('should handle workflow builder errors', async () => {
const workflowBuilderSpy = vi.spyOn(workflowBuilderService, 'buildWorkflow');
workflowBuilderSpy.mockRejectedValue(new Error('Workflow build failed'));
const params = {
model: 'flux-dev',
params: {
prompt: 'test prompt',
...parametersFixture.models['flux-dev'].defaults,
},
};
await expect(imageService.createImage(params)).rejects.toBeDefined();
});
});
describe('Service Dependencies', () => {
it('should maintain proper service dependencies', () => {
// ImageService 依赖其他三个服务
expect(imageService).toBeDefined();
// ModelResolverService 依赖 ClientService
expect(modelResolverService).toBeDefined();
// WorkflowBuilderService 依赖 context
expect(workflowBuilderService).toBeDefined();
// ClientService 是基础服务
expect(clientService).toBeDefined();
});
});
describe('Mock Integration', () => {
it('should work with unified mocks', async () => {
// 验证统一 mock 正常工作
expect(inputCalls).toBeDefined();
expect(inputCalls).toBeInstanceOf(Map);
// 测试 mock 是否被正确设置
const params = {
model: 'flux-dev',
params: {
prompt: 'test prompt',
...parametersFixture.models['flux-dev'].defaults,
},
};
// 这应该使用统一的 mocks
try {
await imageService.createImage(params);
} catch (error) {
// 预期在 mock 环境中可能有错误
}
// 验证基本功能正常
expect(true).toBe(true);
});
});
});

View file

@ -0,0 +1,48 @@
// @vitest-environment node
import { vi } from 'vitest';
// Create mock PromptBuilder class first
const MockPromptBuilder = vi.fn().mockImplementation((workflow: any) => ({
input: vi.fn().mockReturnThis(),
setInputNode: vi.fn().mockReturnThis(),
setOutputNode: vi.fn().mockReturnThis(),
workflow, // Expose the workflow for testing
}));
// Module-level mock for @saintno/comfyui-sdk
vi.mock('@saintno/comfyui-sdk', () => ({
CallWrapper: vi.fn().mockImplementation(() => ({
call: vi.fn(),
execute: vi.fn(),
})),
ComfyApi: vi.fn().mockImplementation((baseURL: string, clientId?: string, options?: any) => ({
baseURL,
clientId,
connect: vi.fn(),
disconnect: vi.fn(),
getObjectInfo: vi.fn().mockResolvedValue({}),
init: vi.fn(),
options,
})),
PromptBuilder: MockPromptBuilder,
seed: vi.fn(() => 42),
}));
export const setupAllMocks = () => {
// Mock other utility functions
vi.mock('../utils/promptSplitter', () => ({
splitPromptForDualCLIP: vi.fn((prompt: string) => ({
clipLPrompt: prompt,
t5xxlPrompt: prompt,
})),
}));
vi.mock('../utils/weightDType', () => ({
selectOptimalWeightDtype: vi.fn(() => 'default'),
}));
// Enhanced PromptBuilder mock to record parameters
const inputCalls = new Map<string, any>();
return { inputCalls };
};

View file

@ -0,0 +1,571 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TTLCacheManager } from '@/server/services/comfyui/utils/cacheManager';
// Mock debug module
vi.mock('debug', () => ({
default: vi.fn(() => vi.fn()),
}));
describe('cacheManager.ts', () => {
let cacheManager: TTLCacheManager;
let mockFetcher: any;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
cacheManager = new TTLCacheManager(60000); // 60 second TTL
mockFetcher = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
});
describe('TTLCacheManager constructor', () => {
it('should create instance with default TTL', () => {
const cache = new TTLCacheManager();
expect(cache).toBeInstanceOf(TTLCacheManager);
});
it('should create instance with custom TTL', () => {
const cache = new TTLCacheManager(30000);
expect(cache).toBeInstanceOf(TTLCacheManager);
});
it('should handle zero TTL', () => {
const cache = new TTLCacheManager(0);
expect(cache).toBeInstanceOf(TTLCacheManager);
});
it('should handle negative TTL', () => {
const cache = new TTLCacheManager(-1000);
expect(cache).toBeInstanceOf(TTLCacheManager);
});
});
describe('get method', () => {
it('should fetch and cache value on first call', async () => {
const testValue = 'test-value';
mockFetcher.mockResolvedValue(testValue);
const result = await cacheManager.get('test-key', mockFetcher);
expect(result).toBe(testValue);
expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(cacheManager.size()).toBe(1);
});
it('should return cached value on subsequent calls within TTL', async () => {
const testValue = 'cached-value';
mockFetcher.mockResolvedValue(testValue);
// First call
const result1 = await cacheManager.get('test-key', mockFetcher);
// Advance time by 30 seconds (within TTL)
vi.advanceTimersByTime(30000);
// Second call
const result2 = await cacheManager.get('test-key', mockFetcher);
expect(result1).toBe(testValue);
expect(result2).toBe(testValue);
expect(mockFetcher).toHaveBeenCalledTimes(1); // Fetcher called only once
});
it('should re-fetch value after TTL expires', async () => {
const firstValue = 'first-value';
const secondValue = 'second-value';
mockFetcher.mockResolvedValueOnce(firstValue).mockResolvedValueOnce(secondValue);
// First call
const result1 = await cacheManager.get('test-key', mockFetcher);
// Advance time beyond TTL
vi.advanceTimersByTime(70000);
// Second call after TTL expiration
const result2 = await cacheManager.get('test-key', mockFetcher);
expect(result1).toBe(firstValue);
expect(result2).toBe(secondValue);
expect(mockFetcher).toHaveBeenCalledTimes(2);
});
it('should handle multiple different keys', async () => {
const value1 = 'value-1';
const value2 = 'value-2';
const fetcher1 = vi.fn().mockResolvedValue(value1);
const fetcher2 = vi.fn().mockResolvedValue(value2);
const result1 = await cacheManager.get('key-1', fetcher1);
const result2 = await cacheManager.get('key-2', fetcher2);
expect(result1).toBe(value1);
expect(result2).toBe(value2);
expect(cacheManager.size()).toBe(2);
expect(fetcher1).toHaveBeenCalledTimes(1);
expect(fetcher2).toHaveBeenCalledTimes(1);
});
it('should handle fetcher that throws error', async () => {
const error = new Error('Fetcher failed');
mockFetcher.mockRejectedValue(error);
await expect(cacheManager.get('test-key', mockFetcher)).rejects.toThrow('Fetcher failed');
expect(cacheManager.size()).toBe(0); // Should not cache failed results
});
it('should handle async fetcher correctly', async () => {
const asyncFetcher = vi
.fn()
.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve('async-value'), 100)),
);
const resultPromise = cacheManager.get('async-key', asyncFetcher);
// Advance time to resolve the async fetcher
vi.advanceTimersByTime(100);
const result = await resultPromise;
expect(result).toBe('async-value');
expect(cacheManager.size()).toBe(1);
});
it('should handle concurrent requests for same key', async () => {
let callCount = 0;
const concurrentFetcher = vi.fn().mockImplementation(() => {
callCount++;
return Promise.resolve(`value-${callCount}`);
});
// Start multiple concurrent requests for the same key
const promises = [
cacheManager.get('concurrent-key', concurrentFetcher),
cacheManager.get('concurrent-key', concurrentFetcher),
cacheManager.get('concurrent-key', concurrentFetcher),
];
const results = await Promise.all(promises);
// Note: In the current implementation, concurrent calls may each trigger the fetcher
// since there's no deduplication. This test verifies that the cache works correctly.
expect(results.length).toBe(3);
expect(cacheManager.size()).toBe(1);
// At least some of the results should be the same if caching worked
const uniqueResults = [...new Set(results)];
expect(uniqueResults.length).toBeGreaterThanOrEqual(1);
});
it('should handle different data types', async () => {
const objectValue = { foo: 'bar', num: 42 };
const arrayValue = [1, 2, 3, 'test'];
const numberValue = 123.45;
const booleanValue = true;
const nullValue = null;
const undefinedValue = undefined;
const results = await Promise.all([
cacheManager.get('object', () => Promise.resolve(objectValue)),
cacheManager.get('array', () => Promise.resolve(arrayValue)),
cacheManager.get('number', () => Promise.resolve(numberValue)),
cacheManager.get('boolean', () => Promise.resolve(booleanValue)),
cacheManager.get('null', () => Promise.resolve(nullValue)),
cacheManager.get('undefined', () => Promise.resolve(undefinedValue)),
]);
expect(results[0]).toEqual(objectValue);
expect(results[1]).toEqual(arrayValue);
expect(results[2]).toBe(numberValue);
expect(results[3]).toBe(booleanValue);
expect(results[4]).toBe(nullValue);
expect(results[5]).toBe(undefinedValue);
expect(cacheManager.size()).toBe(6);
});
it('should handle empty string key', async () => {
const testValue = 'empty-key-value';
mockFetcher.mockResolvedValue(testValue);
const result = await cacheManager.get('', mockFetcher);
expect(result).toBe(testValue);
expect(cacheManager.has('')).toBe(true);
});
it('should handle special characters in key', async () => {
const specialKey = 'key-with-special-chars!@#$%^&*()[]{}|;:,.<>?';
const testValue = 'special-value';
mockFetcher.mockResolvedValue(testValue);
const result = await cacheManager.get(specialKey, mockFetcher);
expect(result).toBe(testValue);
expect(cacheManager.has(specialKey)).toBe(true);
});
});
describe('invalidate method', () => {
it('should remove specific cache entry', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.size()).toBe(1);
expect(cacheManager.has('test-key')).toBe(true);
cacheManager.invalidate('test-key');
expect(cacheManager.size()).toBe(0);
expect(cacheManager.has('test-key')).toBe(false);
});
it('should not affect other cache entries', async () => {
const fetcher1 = vi.fn().mockResolvedValue('value-1');
const fetcher2 = vi.fn().mockResolvedValue('value-2');
await cacheManager.get('key-1', fetcher1);
await cacheManager.get('key-2', fetcher2);
expect(cacheManager.size()).toBe(2);
cacheManager.invalidate('key-1');
expect(cacheManager.size()).toBe(1);
expect(cacheManager.has('key-1')).toBe(false);
expect(cacheManager.has('key-2')).toBe(true);
});
it('should handle invalidating non-existent key gracefully', () => {
expect(() => cacheManager.invalidate('non-existent')).not.toThrow();
expect(cacheManager.size()).toBe(0);
});
it('should cause re-fetch after invalidation', async () => {
const firstValue = 'first-value';
const secondValue = 'second-value';
mockFetcher.mockResolvedValueOnce(firstValue).mockResolvedValueOnce(secondValue);
// First call
const result1 = await cacheManager.get('test-key', mockFetcher);
expect(result1).toBe(firstValue);
expect(mockFetcher).toHaveBeenCalledTimes(1);
// Invalidate
cacheManager.invalidate('test-key');
// Second call should re-fetch
const result2 = await cacheManager.get('test-key', mockFetcher);
expect(result2).toBe(secondValue);
expect(mockFetcher).toHaveBeenCalledTimes(2);
});
});
describe('invalidateAll method', () => {
it('should clear all cache entries', async () => {
const fetcher1 = vi.fn().mockResolvedValue('value-1');
const fetcher2 = vi.fn().mockResolvedValue('value-2');
const fetcher3 = vi.fn().mockResolvedValue('value-3');
await cacheManager.get('key-1', fetcher1);
await cacheManager.get('key-2', fetcher2);
await cacheManager.get('key-3', fetcher3);
expect(cacheManager.size()).toBe(3);
cacheManager.invalidateAll();
expect(cacheManager.size()).toBe(0);
expect(cacheManager.has('key-1')).toBe(false);
expect(cacheManager.has('key-2')).toBe(false);
expect(cacheManager.has('key-3')).toBe(false);
});
it('should handle clearing empty cache', () => {
expect(() => cacheManager.invalidateAll()).not.toThrow();
expect(cacheManager.size()).toBe(0);
});
it('should cause re-fetch for all keys after clear', async () => {
const fetcher1 = vi.fn().mockResolvedValue('value-1').mockResolvedValue('new-value-1');
const fetcher2 = vi.fn().mockResolvedValue('value-2').mockResolvedValue('new-value-2');
// Initial calls
await cacheManager.get('key-1', fetcher1);
await cacheManager.get('key-2', fetcher2);
expect(fetcher1).toHaveBeenCalledTimes(1);
expect(fetcher2).toHaveBeenCalledTimes(1);
// Clear all
cacheManager.invalidateAll();
// Subsequent calls should re-fetch
await cacheManager.get('key-1', fetcher1);
await cacheManager.get('key-2', fetcher2);
expect(fetcher1).toHaveBeenCalledTimes(2);
expect(fetcher2).toHaveBeenCalledTimes(2);
});
});
describe('size method', () => {
it('should return zero for empty cache', () => {
expect(cacheManager.size()).toBe(0);
});
it('should return correct count after adding entries', async () => {
const fetcher1 = vi.fn().mockResolvedValue('value-1');
const fetcher2 = vi.fn().mockResolvedValue('value-2');
const fetcher3 = vi.fn().mockResolvedValue('value-3');
expect(cacheManager.size()).toBe(0);
await cacheManager.get('key-1', fetcher1);
expect(cacheManager.size()).toBe(1);
await cacheManager.get('key-2', fetcher2);
expect(cacheManager.size()).toBe(2);
await cacheManager.get('key-3', fetcher3);
expect(cacheManager.size()).toBe(3);
});
it('should return correct count after removing entries', async () => {
const fetcher1 = vi.fn().mockResolvedValue('value-1');
const fetcher2 = vi.fn().mockResolvedValue('value-2');
await cacheManager.get('key-1', fetcher1);
await cacheManager.get('key-2', fetcher2);
expect(cacheManager.size()).toBe(2);
cacheManager.invalidate('key-1');
expect(cacheManager.size()).toBe(1);
cacheManager.invalidateAll();
expect(cacheManager.size()).toBe(0);
});
});
describe('has method', () => {
it('should return false for non-existent key', () => {
expect(cacheManager.has('non-existent')).toBe(false);
});
it('should return true for existing key', async () => {
mockFetcher.mockResolvedValue('test-value');
expect(cacheManager.has('test-key')).toBe(false);
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.has('test-key')).toBe(true);
});
it('should return false after invalidation', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.has('test-key')).toBe(true);
cacheManager.invalidate('test-key');
expect(cacheManager.has('test-key')).toBe(false);
});
it('should return true for expired entries (key exists but may be expired)', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.has('test-key')).toBe(true);
// Advance time beyond TTL
vi.advanceTimersByTime(70000);
// has() checks existence regardless of expiration
expect(cacheManager.has('test-key')).toBe(true);
});
});
describe('isValid method', () => {
it('should return false for non-existent key', () => {
expect(cacheManager.isValid('non-existent')).toBe(false);
});
it('should return true for valid (not expired) entry', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.isValid('test-key')).toBe(true);
});
it('should return false for expired entry', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.isValid('test-key')).toBe(true);
// Advance time beyond TTL
vi.advanceTimersByTime(70000);
expect(cacheManager.isValid('test-key')).toBe(false);
});
it('should return true just before expiration', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.isValid('test-key')).toBe(true);
// Advance time to just before TTL expiration
vi.advanceTimersByTime(59999);
expect(cacheManager.isValid('test-key')).toBe(true);
});
it('should return false at exact expiration time', async () => {
mockFetcher.mockResolvedValue('test-value');
await cacheManager.get('test-key', mockFetcher);
expect(cacheManager.isValid('test-key')).toBe(true);
// Advance time to exact TTL expiration
vi.advanceTimersByTime(60000);
expect(cacheManager.isValid('test-key')).toBe(false);
});
});
describe('TTL behavior', () => {
it('should handle zero TTL correctly', async () => {
const zeroTTLCache = new TTLCacheManager(0);
mockFetcher.mockResolvedValue('test-value');
await zeroTTLCache.get('test-key', mockFetcher);
expect(zeroTTLCache.isValid('test-key')).toBe(false); // Should be immediately invalid
});
it('should handle negative TTL correctly', async () => {
const negativeTTLCache = new TTLCacheManager(-1000);
mockFetcher.mockResolvedValue('test-value');
await negativeTTLCache.get('test-key', mockFetcher);
expect(negativeTTLCache.isValid('test-key')).toBe(false); // Should be immediately invalid
});
it('should handle very short TTL', async () => {
const shortTTLCache = new TTLCacheManager(100); // 100ms TTL
mockFetcher.mockResolvedValue('test-value');
await shortTTLCache.get('test-key', mockFetcher);
expect(shortTTLCache.isValid('test-key')).toBe(true);
vi.advanceTimersByTime(150);
expect(shortTTLCache.isValid('test-key')).toBe(false);
});
it('should handle very long TTL', async () => {
const longTTLCache = new TTLCacheManager(1000000000); // Very long TTL
mockFetcher.mockResolvedValue('test-value');
await longTTLCache.get('test-key', mockFetcher);
expect(longTTLCache.isValid('test-key')).toBe(true);
vi.advanceTimersByTime(999999999);
expect(longTTLCache.isValid('test-key')).toBe(true);
});
});
describe('edge cases and error scenarios', () => {
it('should handle fetcher returning Promise.reject', async () => {
const rejectedFetcher = vi.fn().mockRejectedValue(new Error('Async rejection'));
await expect(cacheManager.get('reject-key', rejectedFetcher)).rejects.toThrow(
'Async rejection',
);
expect(cacheManager.size()).toBe(0);
});
it('should handle fetcher throwing synchronous error', async () => {
const throwingFetcher = vi.fn().mockImplementation(() => {
throw new Error('Synchronous error');
});
await expect(cacheManager.get('throw-key', throwingFetcher)).rejects.toThrow(
'Synchronous error',
);
expect(cacheManager.size()).toBe(0);
});
it('should handle very long keys', async () => {
const longKey = 'a'.repeat(10000);
mockFetcher.mockResolvedValue('long-key-value');
const result = await cacheManager.get(longKey, mockFetcher);
expect(result).toBe('long-key-value');
expect(cacheManager.has(longKey)).toBe(true);
});
it('should handle unicode keys', async () => {
const unicodeKey = '测试-键-🔑-نام';
mockFetcher.mockResolvedValue('unicode-value');
const result = await cacheManager.get(unicodeKey, mockFetcher);
expect(result).toBe('unicode-value');
expect(cacheManager.has(unicodeKey)).toBe(true);
});
it('should handle null and undefined values from fetcher', async () => {
const nullFetcher = vi.fn().mockResolvedValue(null);
const undefinedFetcher = vi.fn().mockResolvedValue(undefined);
const nullResult = await cacheManager.get('null-key', nullFetcher);
const undefinedResult = await cacheManager.get('undefined-key', undefinedFetcher);
expect(nullResult).toBe(null);
expect(undefinedResult).toBe(undefined);
expect(cacheManager.size()).toBe(2);
});
it('should handle rapid sequential operations', async () => {
const operations = [];
for (let i = 0; i < 100; i++) {
const fetcher = vi.fn().mockResolvedValue(`value-${i}`);
operations.push(cacheManager.get(`key-${i}`, fetcher));
}
const results = await Promise.all(operations);
expect(results).toHaveLength(100);
expect(cacheManager.size()).toBe(100);
});
});
describe('memory management', () => {
it('should not grow indefinitely with different keys', async () => {
// Add many entries
for (let i = 0; i < 1000; i++) {
const fetcher = vi.fn().mockResolvedValue(`value-${i}`);
await cacheManager.get(`key-${i}`, fetcher);
}
expect(cacheManager.size()).toBe(1000);
// Clear all
cacheManager.invalidateAll();
expect(cacheManager.size()).toBe(0);
});
it('should handle invalidation of many entries efficiently', async () => {
// Add many entries
for (let i = 0; i < 100; i++) {
const fetcher = vi.fn().mockResolvedValue(`value-${i}`);
await cacheManager.get(`key-${i}`, fetcher);
}
expect(cacheManager.size()).toBe(100);
// Invalidate half
for (let i = 0; i < 50; i++) {
cacheManager.invalidate(`key-${i}`);
}
expect(cacheManager.size()).toBe(50);
});
});
});

View file

@ -0,0 +1,329 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { COMPONENT_NODE_MAPPINGS } from '@/server/services/comfyui/config/constants';
import { SYSTEM_COMPONENTS } from '@/server/services/comfyui/config/systemComponents';
import {
type ComponentInfo,
getComponentDisplayName,
getComponentFolderPath,
getComponentInfo,
isSystemComponent,
} from '@/server/services/comfyui/utils/componentInfo';
// Mock the config modules to have full control over test data
vi.mock('@/server/services/comfyui/config/constants', () => ({
COMPONENT_NODE_MAPPINGS: {
clip: { node: 'CLIPTextEncode' },
controlnet: { node: 'ControlNetApply' },
lora: { node: 'LoraLoader' },
t5: { node: 'T5TextEncode' },
vae: { node: 'VAEDecode' },
},
}));
vi.mock('@/server/services/comfyui/config/systemComponents', () => ({
SYSTEM_COMPONENTS: {
'clip-l.safetensors': { type: 'clip', modelFamily: 'FLUX', priority: 1 },
'flux-dev.safetensors': { type: 'unet', modelFamily: 'FLUX', priority: 1 },
'invalid-component.bin': { type: 'unknown', modelFamily: 'TEST', priority: 3 },
't5-xxl.safetensors': { type: 't5', modelFamily: 'FLUX', priority: 1 },
'vae.safetensors': { type: 'vae', modelFamily: 'FLUX', priority: 2 },
},
}));
describe('componentInfo.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getComponentDisplayName', () => {
it('should return correct display name for t5 type', () => {
const result = getComponentDisplayName('t5');
expect(result).toBe('T5 text encoder');
});
it('should return correct display name for clip type', () => {
const result = getComponentDisplayName('clip');
expect(result).toBe('CLIP text encoder');
});
it('should return correct display name for vae type', () => {
const result = getComponentDisplayName('vae');
expect(result).toBe('VAE model');
});
it('should return generic display name for unknown type', () => {
const result = getComponentDisplayName('unknown');
expect(result).toBe('UNKNOWN component');
});
it('should return uppercase display name for custom type', () => {
const result = getComponentDisplayName('lora');
expect(result).toBe('LORA component');
});
it('should handle empty string', () => {
const result = getComponentDisplayName('');
expect(result).toBe(' component');
});
it('should handle null and undefined gracefully', () => {
// The function expects string input and will handle null/undefined by converting to string
expect(() => getComponentDisplayName(null as any)).toThrow();
expect(() => getComponentDisplayName(undefined as any)).toThrow();
});
it('should handle numeric input', () => {
const result = getComponentDisplayName('123' as any);
expect(result).toBe('123 component');
});
});
describe('getComponentFolderPath', () => {
it('should return models/clip for clip type', () => {
const result = getComponentFolderPath('clip');
expect(result).toBe('models/clip');
});
it('should return models/clip for t5 type', () => {
const result = getComponentFolderPath('t5');
expect(result).toBe('models/clip');
});
it('should return models/vae for vae type', () => {
const result = getComponentFolderPath('vae');
expect(result).toBe('models/vae');
});
it('should return generic models path for unknown type', () => {
const result = getComponentFolderPath('unknown');
expect(result).toBe('models/unknown');
});
it('should return generic models path for custom type', () => {
const result = getComponentFolderPath('lora');
expect(result).toBe('models/lora');
});
it('should handle empty string', () => {
const result = getComponentFolderPath('');
expect(result).toBe('models/');
});
it('should handle null and undefined', () => {
const resultNull = getComponentFolderPath(null as any);
expect(resultNull).toBe('models/null');
const resultUndefined = getComponentFolderPath(undefined as any);
expect(resultUndefined).toBe('models/undefined');
});
it('should handle special characters in type', () => {
const result = getComponentFolderPath('my-custom_type.v2');
expect(result).toBe('models/my-custom_type.v2');
});
});
describe('getComponentInfo', () => {
it('should return complete component info for valid clip component', () => {
const result = getComponentInfo('clip-l.safetensors');
expect(result).toEqual({
displayName: 'CLIP text encoder',
folderPath: 'models/clip',
nodeType: 'CLIPTextEncode',
type: 'clip',
});
});
it('should return complete component info for valid t5 component', () => {
const result = getComponentInfo('t5-xxl.safetensors');
expect(result).toEqual({
displayName: 'T5 text encoder',
folderPath: 'models/clip',
nodeType: 'T5TextEncode',
type: 't5',
});
});
it('should return complete component info for valid vae component', () => {
const result = getComponentInfo('vae.safetensors');
expect(result).toEqual({
displayName: 'VAE model',
folderPath: 'models/vae',
nodeType: 'VAEDecode',
type: 'vae',
});
});
it('should return undefined for non-existent component', () => {
const result = getComponentInfo('non-existent.safetensors');
expect(result).toBeUndefined();
});
it('should return undefined for component with unknown type', () => {
const result = getComponentInfo('invalid-component.bin');
expect(result).toBeUndefined();
});
it('should handle empty string filename', () => {
const result = getComponentInfo('');
expect(result).toBeUndefined();
});
it('should handle null and undefined filename', () => {
const resultNull = getComponentInfo(null as any);
expect(resultNull).toBeUndefined();
const resultUndefined = getComponentInfo(undefined as any);
expect(resultUndefined).toBeUndefined();
});
it('should work with different file extensions', () => {
// Since we're mocking the entire module, we need to test the existing mock data
// This test validates that the function works with the current mock setup
const result = getComponentInfo('clip-l.safetensors');
expect(result).toEqual({
displayName: 'CLIP text encoder',
folderPath: 'models/clip',
nodeType: 'CLIPTextEncode',
type: 'clip',
});
});
});
describe('isSystemComponent', () => {
it('should return true for known system components', () => {
expect(isSystemComponent('clip-l.safetensors')).toBe(true);
expect(isSystemComponent('t5-xxl.safetensors')).toBe(true);
expect(isSystemComponent('vae.safetensors')).toBe(true);
expect(isSystemComponent('flux-dev.safetensors')).toBe(true);
});
it('should return false for unknown components', () => {
expect(isSystemComponent('unknown.safetensors')).toBe(false);
expect(isSystemComponent('random-file.txt')).toBe(false);
});
it('should handle edge cases', () => {
expect(isSystemComponent('')).toBe(false);
expect(isSystemComponent(null as any)).toBe(false);
expect(isSystemComponent(undefined as any)).toBe(false);
});
it('should be case sensitive', () => {
expect(isSystemComponent('CLIP-L.SAFETENSORS')).toBe(false);
expect(isSystemComponent('clip-l.safetensors')).toBe(true);
});
it('should handle special characters', () => {
// Test with existing mock data that contains standard characters
expect(isSystemComponent('clip-l.safetensors')).toBe(true);
expect(isSystemComponent('t5-xxl.safetensors')).toBe(true);
// Test with non-existent special character filename
expect(isSystemComponent('special-file@2.0_beta.safetensors')).toBe(false);
});
});
describe('integration tests', () => {
it('should provide consistent results across all functions for same component', () => {
const fileName = 'clip-l.safetensors';
const componentInfo = getComponentInfo(fileName);
const isSystem = isSystemComponent(fileName);
expect(isSystem).toBe(true);
expect(componentInfo).toBeDefined();
expect(componentInfo!.type).toBe('clip');
const displayName = getComponentDisplayName(componentInfo!.type);
const folderPath = getComponentFolderPath(componentInfo!.type);
expect(displayName).toBe(componentInfo!.displayName);
expect(folderPath).toBe(componentInfo!.folderPath);
});
it('should handle workflow where component exists but node mapping missing', () => {
// Test with existing mock data - flux-dev.safetensors has type 'unet' which is not in node mappings
const result = getComponentInfo('flux-dev.safetensors');
expect(result).toBeUndefined();
});
it('should handle concurrent access safely', async () => {
// Test concurrent access to functions
const promises = Array.from({ length: 100 }, (_, i) =>
Promise.all([
Promise.resolve(getComponentInfo('clip-l.safetensors')),
Promise.resolve(isSystemComponent('t5-xxl.safetensors')),
Promise.resolve(getComponentDisplayName('vae')),
Promise.resolve(getComponentFolderPath('clip')),
]),
);
const results = await Promise.all(promises);
// All results should be consistent
results.forEach(([info, isSystem, displayName, folderPath]) => {
expect(info).toBeDefined();
expect(isSystem).toBe(true);
expect(displayName).toBe('VAE model');
expect(folderPath).toBe('models/clip');
});
});
it('should maintain type safety with ComponentInfo interface', () => {
const info = getComponentInfo('clip-l.safetensors');
if (info) {
// These should not cause TypeScript errors
const displayName: string = info.displayName;
const folderPath: string = info.folderPath;
const nodeType: string = info.nodeType;
const type: string = info.type;
expect(typeof displayName).toBe('string');
expect(typeof folderPath).toBe('string');
expect(typeof nodeType).toBe('string');
expect(typeof type).toBe('string');
}
});
});
describe('error handling and robustness', () => {
it('should handle corrupted SYSTEM_COMPONENTS gracefully', () => {
// Since we can't easily mock return values, test with invalid input
expect(() => getComponentInfo('any-file.safetensors')).not.toThrow();
expect(() => isSystemComponent('any-file.safetensors')).not.toThrow();
});
it('should handle corrupted COMPONENT_NODE_MAPPINGS gracefully', () => {
// Test with a component that exists in SYSTEM_COMPONENTS but has invalid type
const result = getComponentInfo('invalid-component.bin');
expect(result).toBeUndefined();
});
it('should handle missing properties in config', () => {
// Test with a component that has invalid type in our mock
const result = getComponentInfo('invalid-component.bin');
expect(result).toBeUndefined();
});
it('should handle very long filenames', () => {
const longFilename = 'a'.repeat(1000) + '.safetensors';
expect(() => {
getComponentInfo(longFilename);
isSystemComponent(longFilename);
}).not.toThrow();
});
it('should handle unicode characters in filenames', () => {
const unicodeFilename = '测试-文件-🤖.safetensors';
expect(() => {
getComponentInfo(unicodeFilename);
isSystemComponent(unicodeFilename);
}).not.toThrow();
});
});
});

View file

@ -0,0 +1,424 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type Architecture,
ImageResizer,
imageResizer,
} from '@/server/services/comfyui/utils/imageResizer';
// Mock debug module
vi.mock('debug', () => ({
default: vi.fn(() => vi.fn()),
}));
describe('imageResizer.ts', () => {
let resizer: ImageResizer;
beforeEach(() => {
vi.clearAllMocks();
resizer = new ImageResizer();
});
describe('ImageResizer class', () => {
describe('calculateTargetDimensions', () => {
describe('FLUX architecture', () => {
it('should not resize when dimensions are within limits', () => {
const result = resizer.calculateTargetDimensions(1024, 768, 'FLUX');
expect(result).toEqual({
width: 1024,
height: 768,
needsResize: false,
});
});
it('should resize when width exceeds maximum', () => {
const result = resizer.calculateTargetDimensions(2000, 800, 'FLUX');
expect(result.needsResize).toBe(true);
expect(result.width).toBeLessThanOrEqual(1440);
expect(result.height).toBeLessThanOrEqual(1440);
expect(result.width % 32).toBe(0); // Should be rounded to step
expect(result.height % 32).toBe(0);
});
it('should resize when height exceeds maximum', () => {
const result = resizer.calculateTargetDimensions(800, 2000, 'FLUX');
expect(result.needsResize).toBe(true);
expect(result.width).toBeLessThanOrEqual(1440);
expect(result.height).toBeLessThanOrEqual(1440);
expect(result.width % 32).toBe(0);
expect(result.height % 32).toBe(0);
});
it('should resize when dimensions are too small', () => {
const result = resizer.calculateTargetDimensions(100, 200, 'FLUX');
expect(result.needsResize).toBe(true);
expect(result.width).toBeGreaterThanOrEqual(256);
expect(result.height).toBeGreaterThanOrEqual(256);
expect(result.width % 32).toBe(0);
expect(result.height % 32).toBe(0);
});
it('should handle exact limit dimensions', () => {
const result = resizer.calculateTargetDimensions(1440, 256, 'FLUX');
expect(result).toEqual({
width: 1440,
height: 256,
needsResize: false,
});
});
it('should round to step size (32) correctly', () => {
const result = resizer.calculateTargetDimensions(1000, 600, 'FLUX');
if (result.needsResize) {
expect(result.width % 32).toBe(0);
expect(result.height % 32).toBe(0);
} else {
// Original dimensions should be accepted
expect(result.width).toBe(1000);
expect(result.height).toBe(600);
}
});
it('should handle aspect ratio constraints', () => {
// Test extreme aspect ratios
const wideResult = resizer.calculateTargetDimensions(2100, 900, 'FLUX'); // 2.33 ratio
const tallResult = resizer.calculateTargetDimensions(900, 2100, 'FLUX'); // 0.43 ratio
expect(wideResult.needsResize).toBe(true);
expect(tallResult.needsResize).toBe(true);
// Should still produce valid dimensions within model limits
expect(wideResult.width).toBeLessThanOrEqual(1440);
expect(wideResult.height).toBeLessThanOrEqual(1440);
expect(tallResult.width).toBeLessThanOrEqual(1440);
expect(tallResult.height).toBeLessThanOrEqual(1440);
});
});
describe('SD1 architecture', () => {
it('should not resize for optimal dimensions', () => {
const result = resizer.calculateTargetDimensions(512, 512, 'SD1');
expect(result).toEqual({
width: 512,
height: 512,
needsResize: false,
});
});
it('should resize when exceeding maximum', () => {
const result = resizer.calculateTargetDimensions(1000, 800, 'SD1');
expect(result.needsResize).toBe(true);
expect(result.width).toBeLessThanOrEqual(768);
expect(result.height).toBeLessThanOrEqual(768);
});
it('should resize when below minimum', () => {
const result = resizer.calculateTargetDimensions(100, 200, 'SD1');
expect(result.needsResize).toBe(true);
expect(result.width).toBeGreaterThanOrEqual(256);
expect(result.height).toBeGreaterThanOrEqual(256);
});
it('should handle edge case at exact limits', () => {
const maxResult = resizer.calculateTargetDimensions(768, 768, 'SD1');
const minResult = resizer.calculateTargetDimensions(256, 256, 'SD1');
expect(maxResult.needsResize).toBe(false);
expect(minResult.needsResize).toBe(false);
});
});
describe('SD3 architecture', () => {
it('should not resize for optimal dimensions', () => {
const result = resizer.calculateTargetDimensions(1024, 1024, 'SD3');
expect(result).toEqual({
width: 1024,
height: 1024,
needsResize: false,
});
});
it('should resize when exceeding maximum', () => {
const result = resizer.calculateTargetDimensions(3000, 2000, 'SD3');
expect(result.needsResize).toBe(true);
expect(result.width).toBeLessThanOrEqual(2048);
expect(result.height).toBeLessThanOrEqual(2048);
});
it('should resize when below minimum', () => {
const result = resizer.calculateTargetDimensions(300, 400, 'SD3');
expect(result.needsResize).toBe(true);
expect(result.width).toBeGreaterThanOrEqual(512);
expect(result.height).toBeGreaterThanOrEqual(512);
});
});
describe('SDXL architecture', () => {
it('should not resize for optimal dimensions', () => {
const result = resizer.calculateTargetDimensions(1024, 1024, 'SDXL');
expect(result).toEqual({
width: 1024,
height: 1024,
needsResize: false,
});
});
it('should resize when exceeding maximum', () => {
const result = resizer.calculateTargetDimensions(2500, 2500, 'SDXL');
expect(result.needsResize).toBe(true);
expect(result.width).toBeLessThanOrEqual(2048);
expect(result.height).toBeLessThanOrEqual(2048);
});
it('should resize when below minimum', () => {
const result = resizer.calculateTargetDimensions(400, 300, 'SDXL');
expect(result.needsResize).toBe(true);
expect(result.width).toBeGreaterThanOrEqual(512);
expect(result.height).toBeGreaterThanOrEqual(512);
});
});
describe('edge cases and error handling', () => {
it('should handle zero dimensions', () => {
expect(() => resizer.calculateTargetDimensions(0, 100, 'FLUX')).not.toThrow();
expect(() => resizer.calculateTargetDimensions(100, 0, 'FLUX')).not.toThrow();
expect(() => resizer.calculateTargetDimensions(0, 0, 'FLUX')).not.toThrow();
});
it('should handle negative dimensions', () => {
expect(() => resizer.calculateTargetDimensions(-100, 100, 'FLUX')).not.toThrow();
expect(() => resizer.calculateTargetDimensions(100, -100, 'FLUX')).not.toThrow();
});
it('should handle very large dimensions', () => {
const result = resizer.calculateTargetDimensions(10000, 10000, 'FLUX');
expect(result.needsResize).toBe(true);
expect(result.width).toBeLessThanOrEqual(1440);
expect(result.height).toBeLessThanOrEqual(1440);
});
it('should handle very small dimensions', () => {
const result = resizer.calculateTargetDimensions(1, 1, 'FLUX');
expect(result.needsResize).toBe(true);
expect(result.width).toBeGreaterThanOrEqual(256);
expect(result.height).toBeGreaterThanOrEqual(256);
});
it('should handle unknown architecture gracefully', () => {
expect(() => {
resizer.calculateTargetDimensions(1024, 768, 'UNKNOWN' as Architecture);
}).toThrow();
});
it('should handle floating point dimensions', () => {
const result = resizer.calculateTargetDimensions(1024.5, 768.7, 'FLUX');
// Results should be numbers (may be integers or floats depending on calculation)
expect(typeof result.width).toBe('number');
expect(typeof result.height).toBe('number');
expect(Number.isFinite(result.width)).toBe(true);
expect(Number.isFinite(result.height)).toBe(true);
});
it('should maintain aspect ratio during scaling', () => {
const originalAspectRatio = 16 / 9;
const originalWidth = 1920;
const originalHeight = 1080;
const result = resizer.calculateTargetDimensions(originalWidth, originalHeight, 'FLUX');
if (result.needsResize) {
const newAspectRatio = result.width / result.height;
expect(Math.abs(newAspectRatio - originalAspectRatio)).toBeLessThan(0.1);
}
});
});
describe('performance and consistency', () => {
it('should be deterministic for same inputs', () => {
const inputs = [
[1024, 768, 'FLUX'],
[512, 512, 'SD1'],
[2048, 1024, 'SDXL'],
] as const;
inputs.forEach(([width, height, arch]) => {
const result1 = resizer.calculateTargetDimensions(width, height, arch);
const result2 = resizer.calculateTargetDimensions(width, height, arch);
expect(result1).toEqual(result2);
});
});
it('should handle rapid successive calls', () => {
const results = [];
for (let i = 0; i < 1000; i++) {
const result = resizer.calculateTargetDimensions(1024 + i, 768 + i, 'FLUX');
results.push(result);
}
expect(results).toHaveLength(1000);
results.forEach((result) => {
expect(typeof result.width).toBe('number');
expect(typeof result.height).toBe('number');
expect(typeof result.needsResize).toBe('boolean');
});
});
});
});
});
describe('private method testing through public interface', () => {
describe('calculateRatio method (through public interface)', () => {
it('should calculate ratios correctly in various scenarios', () => {
// Test through calculateTargetDimensions which uses calculateRatioFromDimensions
const wideResult = resizer.calculateTargetDimensions(1600, 900, 'FLUX'); // 16:9
const squareResult = resizer.calculateTargetDimensions(1000, 1000, 'FLUX'); // 1:1
const tallResult = resizer.calculateTargetDimensions(900, 1600, 'FLUX'); // 9:16
expect(typeof wideResult).toBe('object');
expect(typeof squareResult).toBe('object');
expect(typeof tallResult).toBe('object');
});
});
describe('isWithinRatioRange method (through public interface)', () => {
it('should handle ratio range validation through FLUX constraints', () => {
// Test extreme ratios that should trigger ratio range checks
const extremeWide = resizer.calculateTargetDimensions(3000, 1000, 'FLUX'); // 3:1 (exceeds 21:9)
const extremeTall = resizer.calculateTargetDimensions(1000, 3000, 'FLUX'); // 1:3 (exceeds 9:21)
expect(extremeWide.needsResize).toBe(true);
expect(extremeTall.needsResize).toBe(true);
});
});
describe('getModelLimits method (through public interface)', () => {
it('should use correct limits for each architecture', () => {
// Test by checking if results respect known limits
const fluxResult = resizer.calculateTargetDimensions(2000, 2000, 'FLUX');
const sd1Result = resizer.calculateTargetDimensions(1000, 1000, 'SD1');
const sd3Result = resizer.calculateTargetDimensions(3000, 3000, 'SD3');
const sdxlResult = resizer.calculateTargetDimensions(3000, 3000, 'SDXL');
expect(fluxResult.width).toBeLessThanOrEqual(1440); // FLUX max
expect(sd1Result.width).toBeLessThanOrEqual(768); // SD1 max
expect(sd3Result.width).toBeLessThanOrEqual(2048); // SD3 max
expect(sdxlResult.width).toBeLessThanOrEqual(2048); // SDXL max
});
});
});
describe('singleton instance', () => {
it('should export a singleton instance', () => {
expect(imageResizer).toBeInstanceOf(ImageResizer);
});
it('should maintain state across calls', () => {
const result1 = imageResizer.calculateTargetDimensions(1024, 768, 'FLUX');
const result2 = imageResizer.calculateTargetDimensions(1024, 768, 'FLUX');
expect(result1).toEqual(result2);
});
it('should be the same instance when imported multiple times', () => {
// This tests that the singleton pattern is working correctly
expect(imageResizer).toBeDefined();
expect(typeof imageResizer.calculateTargetDimensions).toBe('function');
});
});
describe('integration scenarios', () => {
it('should handle common use cases correctly', () => {
const commonSizes = [
{ width: 512, height: 512, arch: 'SD1' as Architecture },
{ width: 1024, height: 1024, arch: 'SDXL' as Architecture },
{ width: 1440, height: 1024, arch: 'FLUX' as Architecture },
{ width: 768, height: 512, arch: 'SD1' as Architecture },
];
commonSizes.forEach(({ width, height, arch }) => {
const result = resizer.calculateTargetDimensions(width, height, arch);
expect(result).toHaveProperty('width');
expect(result).toHaveProperty('height');
expect(result).toHaveProperty('needsResize');
expect(typeof result.needsResize).toBe('boolean');
});
});
it('should handle img2img workflow dimensions', () => {
// Common img2img input sizes
const img2imgSizes = [
[1920, 1080], // Full HD
[1280, 720], // HD
[800, 600], // SVGA
[640, 480], // VGA
];
img2imgSizes.forEach(([width, height]) => {
['FLUX', 'SDXL', 'SD3', 'SD1'].forEach((arch) => {
const result = resizer.calculateTargetDimensions(width, height, arch as Architecture);
// All results should be valid
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
expect(Number.isInteger(result.width)).toBe(true);
expect(Number.isInteger(result.height)).toBe(true);
});
});
});
});
describe('boundary testing', () => {
it('should handle boundary conditions for each architecture', () => {
const architectures: Architecture[] = ['FLUX', 'SD1', 'SD3', 'SDXL'];
architectures.forEach((arch) => {
// Test minimum boundary
const minResult = resizer.calculateTargetDimensions(1, 1, arch);
expect(minResult.needsResize).toBe(true);
// Test maximum boundary
const maxResult = resizer.calculateTargetDimensions(10000, 10000, arch);
expect(maxResult.needsResize).toBe(true);
});
});
it('should handle step size boundaries for FLUX', () => {
// Test dimensions that are close to but not exactly divisible by 32
const testCases = [
[1023, 767], // Just below step boundary
[1025, 769], // Just above step boundary
[1056, 800], // Exactly on step boundary
];
testCases.forEach(([width, height]) => {
const result = resizer.calculateTargetDimensions(width, height, 'FLUX');
if (result.needsResize) {
expect(result.width % 32).toBe(0);
expect(result.height % 32).toBe(0);
}
});
});
});
});

View file

@ -0,0 +1,191 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { splitPromptForDualCLIP } from '@/server/services/comfyui/utils/promptSplitter';
describe('splitPromptForDualCLIP', () => {
it('should handle empty or null prompt', () => {
expect(splitPromptForDualCLIP('')).toEqual({
clipLPrompt: '',
t5xxlPrompt: '',
});
expect(splitPromptForDualCLIP(null as any)).toEqual({
clipLPrompt: '',
t5xxlPrompt: '',
});
expect(splitPromptForDualCLIP(undefined as any)).toEqual({
clipLPrompt: '',
t5xxlPrompt: '',
});
});
it('should split prompt with style keywords', () => {
const prompt = 'a beautiful landscape, photorealistic, high quality, cinematic lighting';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('photorealistic');
expect(result.clipLPrompt).toContain('high quality');
expect(result.clipLPrompt).toContain('cinematic');
});
it('should extract single-word style keywords', () => {
const prompt = 'a cat sitting, realistic, detailed, masterpiece';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('realistic');
expect(result.clipLPrompt).toContain('detailed');
expect(result.clipLPrompt).toContain('masterpiece');
expect(result.clipLPrompt).not.toContain('cat');
expect(result.clipLPrompt).not.toContain('sitting');
});
it('should extract multi-word style keywords', () => {
const prompt = 'beautiful girl portrait, digital art, depth of field, trending on artstation';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('digital art');
expect(result.clipLPrompt).toContain('depth of field');
expect(result.clipLPrompt).toContain('trending on artstation');
});
it('should handle lighting keywords', () => {
const prompt = 'sunset over ocean, dramatic lighting, golden hour, soft lighting';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('dramatic lighting');
expect(result.clipLPrompt).toContain('golden hour');
expect(result.clipLPrompt).toContain('soft lighting');
});
it('should handle quality keywords', () => {
const prompt = 'mountain view, 4k, ultra detailed, best quality, highly detailed';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('4k');
expect(result.clipLPrompt).toContain('ultra detailed');
expect(result.clipLPrompt).toContain('best quality');
expect(result.clipLPrompt).toContain('highly detailed');
});
it('should handle photography terms', () => {
const prompt = 'city street, bokeh, motion blur, wide angle, macro shot';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('bokeh');
expect(result.clipLPrompt).toContain('motion blur');
expect(result.clipLPrompt).toContain('wide angle');
expect(result.clipLPrompt).toContain('macro');
});
it('should handle artist and platform keywords', () => {
const prompt = 'fantasy landscape, by greg rutkowski, concept art, octane render';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('by greg rutkowski');
expect(result.clipLPrompt).toContain('concept art');
expect(result.clipLPrompt).toContain('octane render');
});
it('should fallback to adjectives when no style keywords found', () => {
const prompt = 'a beautiful sunny day with colorful flowers blooming magnificently';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
// Should contain adjective-like words that match the regex pattern
expect(result.clipLPrompt).toMatch(/blooming|magnificently|colorful|beautiful|sunny/);
expect(result.clipLPrompt.length).toBeGreaterThan(0);
});
it('should use same prompt for both when no style words or adjectives', () => {
const prompt = 'cat dog house tree';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toBe(prompt);
});
it('should preserve original case in style words', () => {
const prompt = 'Portrait of girl, Photorealistic, High Quality, Digital Art';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('Photorealistic');
expect(result.clipLPrompt).toContain('High Quality');
expect(result.clipLPrompt).toContain('Digital Art');
});
it('should handle comma-separated prompts', () => {
const prompt = 'forest path, cinematic, dramatic lighting, 8k, masterpiece';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('cinematic');
expect(result.clipLPrompt).toContain('dramatic lighting');
expect(result.clipLPrompt).toContain('8k');
expect(result.clipLPrompt).toContain('masterpiece');
});
it('should handle partial keyword matches correctly', () => {
const prompt = 'realistic portrait, photo-realistic style, realism art';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
// Should match "realistic" but exact behavior depends on implementation
expect(result.clipLPrompt.length).toBeGreaterThan(0);
});
it('should handle overlapping multi-word keywords', () => {
const prompt = 'art gallery, digital art work, concept art design';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('digital art');
expect(result.clipLPrompt).toContain('concept art');
});
it('should work with very long prompts', () => {
const prompt =
'An incredibly detailed and photorealistic portrait of a young woman with flowing hair, sitting in a beautiful garden during golden hour, with soft lighting and dramatic shadows, rendered in 8k ultra high quality with perfect focus and depth of field, trending on artstation, masterpiece';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('photorealistic');
expect(result.clipLPrompt).toContain('golden hour');
expect(result.clipLPrompt).toContain('soft lighting');
expect(result.clipLPrompt).toContain('8k');
expect(result.clipLPrompt).toContain('depth of field');
expect(result.clipLPrompt).toContain('trending on artstation');
expect(result.clipLPrompt).toContain('masterpiece');
});
it('should handle mixed content with various separators', () => {
const prompt = 'sunset landscape; cinematic mood, soft lighting. 4k resolution!';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
expect(result.clipLPrompt).toContain('cinematic');
// soft lighting might be treated as two separate words due to separator handling
expect(result.clipLPrompt).toMatch(/soft|lighting|cinematic|4k/);
});
it('should prioritize style keywords over content words', () => {
const prompt = 'beautiful mountain landscape, photorealistic, detailed';
const result = splitPromptForDualCLIP(prompt);
expect(result.t5xxlPrompt).toBe(prompt);
// Should contain style keywords
expect(result.clipLPrompt).toContain('photorealistic');
expect(result.clipLPrompt).toContain('detailed');
// The algorithm extracts style keywords first, so may not contain content words
expect(result.clipLPrompt.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,192 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { selectOptimalWeightDtype } from '@/server/services/comfyui/utils/weightDType';
// Mock the modelRegistry module
vi.mock('@/server/services/comfyui/config/modelRegistry', () => {
const models = {
'flux1-dev-fp8-e4m3fn.safetensors': {
family: 'flux',
recommendedDtype: 'fp8_e4m3fn',
variant: 'flux1-dev-fp8-e4m3fn',
},
'flux1-dev.safetensors': {
family: 'flux',
recommendedDtype: 'default',
variant: 'flux1-dev',
},
'flux1-kontext-dev.safetensors': {
family: 'flux',
recommendedDtype: 'default',
variant: 'flux1-kontext-dev',
},
'flux1-schnell-fp8-e4m3fn.safetensors': {
family: 'flux',
recommendedDtype: 'fp8_e4m3fn',
variant: 'flux1-schnell-fp8-e4m3fn',
},
'flux1-schnell.safetensors': {
family: 'flux',
recommendedDtype: 'default',
variant: 'flux1-schnell',
},
'vision_realistic_flux_dev_fp8_no_clip_v2.safetensors': {
family: 'flux',
recommendedDtype: 'fp8_e4m3fn',
variant: 'vision_realistic_flux_dev_fp8_no_clip_v2',
},
};
return {
MODEL_ID_VARIANT_MAP: {
'flux-dev': 'flux1-dev',
'flux-schnell': 'flux1-schnell',
},
MODEL_REGISTRY: models,
};
});
// Mock the staticModelLookup module
vi.mock('../utils/staticModelLookup', () => {
const models = {
'flux1-dev-fp8-e4m3fn.safetensors': {
family: 'flux',
recommendedDtype: 'fp8_e4m3fn',
variant: 'flux1-dev-fp8-e4m3fn',
},
'flux1-dev.safetensors': {
family: 'flux',
recommendedDtype: 'default',
variant: 'flux1-dev',
},
'flux1-kontext-dev.safetensors': {
family: 'flux',
recommendedDtype: 'default',
variant: 'flux1-kontext-dev',
},
'flux1-schnell-fp8-e4m3fn.safetensors': {
family: 'flux',
recommendedDtype: 'fp8_e4m3fn',
variant: 'flux1-schnell-fp8-e4m3fn',
},
'flux1-schnell.safetensors': {
family: 'flux',
recommendedDtype: 'default',
variant: 'flux1-schnell',
},
'vision_realistic_flux_dev_fp8_no_clip_v2.safetensors': {
family: 'flux',
recommendedDtype: 'fp8_e4m3fn',
variant: 'vision_realistic_flux_dev_fp8_no_clip_v2',
},
};
return {
resolveModel: vi.fn((modelName: string) => {
const cleanName = modelName.replace(/^comfyui\//, '');
// Case-insensitive lookup
const lowerModelName = cleanName.toLowerCase();
for (const [key, config] of Object.entries(models)) {
if (key.toLowerCase() === lowerModelName) {
return config;
}
}
return null;
}),
};
});
describe('selectOptimalWeightDtype', () => {
it('should return model recommendedDtype for known FLUX models', () => {
// FLUX Dev models should use default for quality
expect(selectOptimalWeightDtype('flux1-dev.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_dev.safetensors')).toBe('default');
// FLUX Schnell models use default in current registry (fps8 variants have separate entries)
expect(selectOptimalWeightDtype('flux1-schnell.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_schnell.safetensors')).toBe('default'); // Not in registry
// FLUX Kontext models should use default
expect(selectOptimalWeightDtype('flux1-kontext-dev.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_kontext.safetensors')).toBe('default');
// FLUX Krea models should use default
expect(selectOptimalWeightDtype('flux_krea.safetensors')).toBe('default');
});
it('should return default for GGUF models', () => {
expect(selectOptimalWeightDtype('flux1-dev-Q4_K_S.gguf')).toBe('default');
expect(selectOptimalWeightDtype('flux1-schnell-Q6_K.gguf')).toBe('default');
});
it('should return correct dtype for quantized models that exist in registry', () => {
// FP8 quantized models that exist in the registry with exact names
expect(selectOptimalWeightDtype('flux1-dev-fp8-e4m3fn.safetensors')).toBe('fp8_e4m3fn');
expect(selectOptimalWeightDtype('flux1-schnell-fp8-e4m3fn.safetensors')).toBe('fp8_e4m3fn');
// Models with approximate names that don't exactly match registry return default
expect(selectOptimalWeightDtype('flux1-dev-fp8.safetensors')).toBe('default'); // Not exact match
});
it('should return default for enterprise lite models', () => {
expect(selectOptimalWeightDtype('flux.1-lite-8B.safetensors')).toBe('default');
});
it('should return default fallback for unknown models', () => {
expect(selectOptimalWeightDtype('unknown_model.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('custom_flux.bin')).toBe('default');
expect(selectOptimalWeightDtype('not_a_flux_model.ckpt')).toBe('default');
expect(selectOptimalWeightDtype('model.pt')).toBe('default');
expect(selectOptimalWeightDtype('weird@model&name.safetensors')).toBe('default');
// Models with precision in filename but not in registry fall back to default
expect(selectOptimalWeightDtype('flux_model_fp32.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_model_fp16.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_model_int8.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_model_int4.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_model_nf4.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux_model_bnb.safetensors')).toBe('default');
});
it('should be case-insensitive for model detection', () => {
expect(selectOptimalWeightDtype('FLUX1-DEV.SAFETENSORS')).toBe('default');
expect(selectOptimalWeightDtype('FLUX1-SCHNELL.SAFETENSORS')).toBe('default');
expect(selectOptimalWeightDtype('FLUX1-DEV-FP8-E4M3FN.SAFETENSORS')).toBe('fp8_e4m3fn');
});
it('should handle community models correctly', () => {
// Most community models will fall back to default unless specifically in registry
expect(selectOptimalWeightDtype('Jib_mix_Flux_V11_Krea_b_00001_.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('RealFlux_1.0b_Dev_Transformer.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('RealFlux_1.0b_Schnell_Transformer.safetensors')).toBe(
'default',
);
expect(selectOptimalWeightDtype('Jib_Mix_Flux_Krea_b_fp8_00001_.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('vision_realistic_flux_dev_fp8_no_clip_v2.safetensors')).toBe(
'fp8_e4m3fn', // This model is actually in the registry
);
});
it('should handle edge cases', () => {
expect(selectOptimalWeightDtype('flux_model')).toBe('default');
expect(selectOptimalWeightDtype('flux_model.')).toBe('default');
expect(selectOptimalWeightDtype('.gguf')).toBe('default');
});
describe('simplified logic without user parameters', () => {
it('should only accept modelName parameter', () => {
// Function now only takes modelName parameter
// JavaScript allows extra parameters, so this won't throw, but TypeScript will catch it
expect(selectOptimalWeightDtype('flux1-dev.safetensors')).toBe('default');
});
it('should always use model-based selection', () => {
// No user choice - always use model configuration or default fallback
expect(selectOptimalWeightDtype('flux1-dev.safetensors')).toBe('default');
expect(selectOptimalWeightDtype('flux1-schnell.safetensors')).toBe('default'); // Base model uses default
expect(selectOptimalWeightDtype('unknown_model.safetensors')).toBe('default');
});
});
});

View file

@ -0,0 +1,507 @@
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelConfig } from '@/server/services/comfyui/config/modelRegistry';
import { resolveModel } from '@/server/services/comfyui/utils/staticModelLookup';
import {
type SD3Variant,
WorkflowDetector,
} from '@/server/services/comfyui/utils/workflowDetector';
// Mock static model lookup functions
vi.mock('../../utils/staticModelLookup', () => ({
resolveModel: vi.fn(),
getModelConfig: vi.fn(),
}));
describe('WorkflowDetector', () => {
const mockedResolveModel = resolveModel as Mock;
beforeEach(() => {
vi.clearAllMocks();
});
describe('detectModelType', () => {
describe('Input Processing', () => {
it('should remove "comfyui/" prefix from modelId', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('comfyui/flux-dev');
expect(mockedResolveModel).toHaveBeenCalledWith('flux-dev');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'dev',
});
});
it('should handle modelId without comfyui prefix', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'schnell',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('flux-schnell');
expect(mockedResolveModel).toHaveBeenCalledWith('flux-schnell');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'schnell',
});
});
it('should handle multiple comfyui prefixes correctly', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SD3',
priority: 1,
recommendedDtype: 'default',
variant: 'sd35',
};
mockedResolveModel.mockReturnValue(mockConfig);
// Only the first "comfyui/" should be removed
const result = WorkflowDetector.detectModelType('comfyui/comfyui/model');
expect(mockedResolveModel).toHaveBeenCalledWith('comfyui/model');
expect(result).toEqual({
architecture: 'SD3',
isSupported: true,
variant: 'sd35',
});
});
});
describe('FLUX Model Detection', () => {
it('should detect FLUX dev variant', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('flux-dev');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'dev',
});
});
it('should detect FLUX schnell variant', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 2,
recommendedDtype: 'fp8_e4m3fn',
variant: 'schnell',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('flux-schnell-fp8');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'schnell',
});
});
it('should detect FLUX kontext variant', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'kontext',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('flux-kontext-dev');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'kontext',
});
});
it('should detect FLUX krea model with dev variant', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('flux-krea-dev');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'dev',
});
});
it('should handle FLUX model with comfyui prefix', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 2,
recommendedDtype: 'fp8_e5m2',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('comfyui/custom-flux-model');
expect(mockedResolveModel).toHaveBeenCalledWith('custom-flux-model');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'dev',
});
});
});
describe('Custom SD Model Detection', () => {
it('should detect custom SD model', () => {
const result = WorkflowDetector.detectModelType('stable-diffusion-custom');
// Custom SD models are hardcoded and don't use resolveModel
expect(mockedResolveModel).not.toHaveBeenCalled();
expect(result).toEqual({
architecture: 'SDXL', // Uses SDXL for img2img support
isSupported: true,
variant: 'custom-sd',
});
});
it('should detect custom SD refiner model', () => {
const result = WorkflowDetector.detectModelType('stable-diffusion-custom-refiner');
// Custom SD models are hardcoded and don't use resolveModel
expect(mockedResolveModel).not.toHaveBeenCalled();
expect(result).toEqual({
architecture: 'SDXL', // Uses SDXL for img2img support
isSupported: true,
variant: 'custom-sd',
});
});
it('should handle custom SD with comfyui prefix', () => {
const result = WorkflowDetector.detectModelType('comfyui/stable-diffusion-custom');
// Custom SD models are hardcoded and don't use resolveModel
expect(mockedResolveModel).not.toHaveBeenCalled();
expect(result).toEqual({
architecture: 'SDXL', // Uses SDXL for img2img support
isSupported: true,
variant: 'custom-sd',
});
});
});
describe('SD3 Model Detection', () => {
it('should detect SD3 sd35 variant', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SD3',
priority: 1,
recommendedDtype: 'default',
variant: 'sd35',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('sd3.5_large');
expect(result).toEqual({
architecture: 'SD3',
isSupported: true,
variant: 'sd35',
});
});
it('should handle SD3 model with comfyui prefix', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SD3',
priority: 2,
recommendedDtype: 'default',
variant: 'sd35',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('comfyui/sd3.5_medium');
expect(mockedResolveModel).toHaveBeenCalledWith('sd3.5_medium');
expect(result).toEqual({
architecture: 'SD3',
isSupported: true,
variant: 'sd35',
});
});
});
describe('Unknown/Unsupported Model Detection', () => {
it('should return unknown architecture when model is not found', () => {
mockedResolveModel.mockReturnValue(null);
const result = WorkflowDetector.detectModelType('unknown-model');
expect(result).toEqual({
architecture: 'unknown',
isSupported: false,
});
});
it('should return SDXL architecture for SDXL model family', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SDXL' as any,
priority: 1,
recommendedDtype: 'default',
variant: 'sdxl-t2i',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('sdxl-base');
expect(result).toEqual({
architecture: 'SDXL',
isSupported: true,
variant: 'sdxl-t2i',
});
});
it('should return SD1 architecture for SD1 model family', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SD1' as any,
priority: 3,
recommendedDtype: 'default',
variant: 'sd15-t2i',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('stable-diffusion-v1-5');
expect(result).toEqual({
architecture: 'SD1',
isSupported: true,
variant: 'sd15-t2i',
});
});
it('should handle null modelId by causing runtime error (expected behavior)', () => {
// According to the function signature, modelId is expected to be a string
// Passing null/undefined would cause a runtime error, which is expected behavior
expect(() => {
WorkflowDetector.detectModelType(null as any);
}).toThrow('Cannot read properties of null');
});
it('should handle undefined modelId by causing runtime error (expected behavior)', () => {
// According to the function signature, modelId is expected to be a string
// Passing null/undefined would cause a runtime error, which is expected behavior
expect(() => {
WorkflowDetector.detectModelType(undefined as any);
}).toThrow('Cannot read properties of undefined');
});
it('should handle empty string modelId', () => {
mockedResolveModel.mockReturnValue(null);
const result = WorkflowDetector.detectModelType('');
expect(mockedResolveModel).toHaveBeenCalledWith('');
expect(result).toEqual({
architecture: 'unknown',
isSupported: false,
});
});
it('should handle whitespace-only modelId', () => {
mockedResolveModel.mockReturnValue(null);
const result = WorkflowDetector.detectModelType(' ');
expect(mockedResolveModel).toHaveBeenCalledWith(' ');
expect(result).toEqual({
architecture: 'unknown',
isSupported: false,
});
});
});
describe('Type Casting', () => {
it('should properly cast FLUX variant to FluxVariant type', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('flux-model');
expect(result.variant).toBe('dev');
expect(typeof result.variant).toBe('string');
// Test with dev variant (krea uses dev workflow)
const mockKreaConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockKreaConfig);
const kreaResult = WorkflowDetector.detectModelType('flux-krea-model');
expect(kreaResult.variant).toBe('dev');
});
it('should properly cast SD3 variant to SD3Variant type', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SD3',
priority: 1,
recommendedDtype: 'default',
variant: 'sd35',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('sd3-model');
expect(result.variant).toBe('sd35');
expect(typeof result.variant).toBe('string');
// Verify it matches SD3Variant type expectations
const sd3Variants: SD3Variant[] = ['sd35'];
expect(sd3Variants).toContain(result.variant as SD3Variant);
});
});
describe('Edge Cases', () => {
it('should handle special characters in modelId', () => {
mockedResolveModel.mockReturnValue(null);
const result = WorkflowDetector.detectModelType('model-with-special!@#$%^&*()_+');
expect(mockedResolveModel).toHaveBeenCalledWith('model-with-special!@#$%^&*()_+');
expect(result).toEqual({
architecture: 'unknown',
isSupported: false,
});
});
it('should handle modelId with path separators', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('path/to/model.safetensors');
expect(mockedResolveModel).toHaveBeenCalledWith('path/to/model.safetensors');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'dev',
});
});
it('should handle very long modelId', () => {
const longModelId = 'a'.repeat(1000);
mockedResolveModel.mockReturnValue(null);
const result = WorkflowDetector.detectModelType(longModelId);
expect(mockedResolveModel).toHaveBeenCalledWith(longModelId);
expect(result).toEqual({
architecture: 'unknown',
isSupported: false,
});
});
it('should handle modelId that is only "comfyui/"', () => {
mockedResolveModel.mockReturnValue(null);
const result = WorkflowDetector.detectModelType('comfyui/');
expect(mockedResolveModel).toHaveBeenCalledWith('');
expect(result).toEqual({
architecture: 'unknown',
isSupported: false,
});
});
it('should handle case sensitivity in modelId', () => {
const mockConfig: ModelConfig = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
variant: 'dev',
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('COMFYUI/FLUX-DEV');
// Should not match the prefix replacement since it's case sensitive
expect(mockedResolveModel).toHaveBeenCalledWith('COMFYUI/FLUX-DEV');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: 'dev',
});
});
});
describe('Configuration Edge Cases', () => {
it('should handle config with missing variant property', () => {
const mockConfig: Partial<ModelConfig> = {
modelFamily: 'FLUX',
priority: 1,
recommendedDtype: 'default',
// variant is missing
};
mockedResolveModel.mockReturnValue(mockConfig as ModelConfig);
const result = WorkflowDetector.detectModelType('flux-model');
expect(result).toEqual({
architecture: 'FLUX',
isSupported: true,
variant: undefined, // Will be cast to FluxVariant but is undefined
});
});
it('should handle config with null variant', () => {
const mockConfig: ModelConfig = {
modelFamily: 'SD3',
priority: 1,
recommendedDtype: 'default',
variant: null as any,
};
mockedResolveModel.mockReturnValue(mockConfig);
const result = WorkflowDetector.detectModelType('sd3-model');
expect(result).toEqual({
architecture: 'SD3',
isSupported: true,
variant: null, // Will be cast to SD3Variant but is null
});
});
});
});
});

View file

@ -0,0 +1,381 @@
// @vitest-environment node
import { PromptBuilder } from '@saintno/comfyui-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TEST_FLUX_MODELS } from '@/server/services/comfyui/__tests__/fixtures/testModels';
import { mockContext } from '@/server/services/comfyui/__tests__/helpers/mockContext';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
import { buildFluxKontextWorkflow } from '@/server/services/comfyui/workflows/flux-kontext';
// Setup basic mocks
vi.mock('../utils/promptSplitter', () => ({
splitPromptForDualCLIP: vi.fn((prompt) => ({
clipLPrompt: prompt,
t5xxlPrompt: prompt,
})),
}));
vi.mock('../utils/weightDType', () => ({
selectOptimalWeightDtype: vi.fn(() => 'default'),
}));
vi.mock('@lobechat/utils', () => ({
generateUniqueSeeds: vi.fn(() => ({ seed: 123456, noiseSeed: 654321 })),
}));
vi.mock('../utils/workflowUtils', () => ({
getWorkflowFilenamePrefix: vi.fn(() => 'kontext'),
}));
const { inputCalls } = setupAllMocks();
describe('buildFluxKontextWorkflow - Complex Dual-Mode Architecture', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Dual-Mode Architecture Tests', () => {
it('should build text-to-image workflow when no input image provided', async () => {
const modelName = TEST_FLUX_MODELS.KONTEXT;
const params = {
cfg: 3.5,
height: 1024,
prompt: 'A beautiful landscape',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(modelName, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Verify text-to-image mode: no image loader nodes
expect(mockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledWith(
't5',
'FLUX',
);
expect(mockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledWith(
'vae',
'FLUX',
);
expect(mockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledWith(
'clip',
'FLUX',
);
});
it('should build image-to-image workflow when input image provided', async () => {
const modelName = TEST_FLUX_MODELS.KONTEXT;
const params = {
cfg: 3.5,
height: 1024,
imageUrl: 'https://example.com/input.jpg',
prompt: 'Transform this image',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(modelName, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Verify image-to-image mode configuration
expect(mockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledTimes(3);
});
it('should handle imageUrls array parameter', async () => {
const modelName = TEST_FLUX_MODELS.KONTEXT;
const params = {
cfg: 3.5,
height: 1024,
imageUrls: ['https://example.com/input1.jpg', 'https://example.com/input2.jpg'],
prompt: 'Process first image',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(modelName, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Should use first image from array for i2i mode
});
});
describe('Dynamic Node Management Tests', () => {
it('should create workflow with all required nodes for t2i mode', async () => {
const modelName = TEST_FLUX_MODELS.KONTEXT;
const params = {
cfg: 4.0,
height: 768,
prompt: 'Dynamic node test',
steps: 28,
width: 768,
};
const result = await buildFluxKontextWorkflow(modelName, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Verify essential nodes are properly connected
expect(mockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledWith(
't5',
'FLUX',
);
});
it('should handle different CFG values for guidance', async () => {
const testCases = [
{ cfg: 1.0, expected: 'minimal guidance' },
{ cfg: 3.5, expected: 'default guidance' },
{ cfg: 7.0, expected: 'high guidance' },
];
for (const testCase of testCases) {
const params = {
cfg: testCase.cfg,
height: 1024,
prompt: `Test with ${testCase.expected}`,
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(
TEST_FLUX_MODELS.KONTEXT,
params,
mockContext,
);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
}
});
});
describe('Component Integration Tests', () => {
it('should integrate with GetImageSize for dynamic dimensions', async () => {
const modelName = TEST_FLUX_MODELS.KONTEXT;
const params = {
cfg: 3.5,
imageUrl: 'https://example.com/variable-size.jpg',
prompt: 'Resize based on input',
steps: 28,
// Note: height/width should be dynamically determined by GetImageSize
};
const result = await buildFluxKontextWorkflow(modelName, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Verify GetImageSize integration would be handled
});
it('should handle component resolution failures gracefully', async () => {
// Mock component resolution failure
const failingContext = {
...mockContext,
modelResolverService: {
...mockContext.modelResolverService,
getOptimalComponent: vi.fn().mockRejectedValue(new Error('Component not found')),
} as any,
};
const params = {
cfg: 3.5,
height: 1024,
prompt: 'Test component failure',
steps: 28,
width: 1024,
};
await expect(
buildFluxKontextWorkflow(TEST_FLUX_MODELS.KONTEXT, params, failingContext),
).rejects.toThrow('Component not found');
});
});
describe('Parameter Validation and Edge Cases', () => {
it('should handle empty prompt', async () => {
const params = {
cfg: 3.5,
height: 1024,
prompt: '',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(TEST_FLUX_MODELS.KONTEXT, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
it('should handle custom dimensions', async () => {
const customDimensions = [
{ width: 512, height: 768 }, // Portrait
{ width: 1152, height: 896 }, // Landscape
{ width: 896, height: 896 }, // Square
];
for (const dims of customDimensions) {
const params = {
cfg: 3.5,
height: dims.height,
prompt: `Test ${dims.width}x${dims.height}`,
steps: 28,
width: dims.width,
};
const result = await buildFluxKontextWorkflow(
TEST_FLUX_MODELS.KONTEXT,
params,
mockContext,
);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
}
});
it('should handle different step counts', async () => {
const stepCounts = [20, 28, 35, 50];
for (const steps of stepCounts) {
const params = {
cfg: 3.5,
height: 1024,
prompt: `Test with ${steps} steps`,
steps,
width: 1024,
};
const result = await buildFluxKontextWorkflow(
TEST_FLUX_MODELS.KONTEXT,
params,
mockContext,
);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
}
});
});
describe('Advanced Feature Tests', () => {
it('should support prompt splitting for dual CLIP', async () => {
const params = {
cfg: 3.5,
height: 1024,
prompt: 'Complex prompt requiring dual CLIP processing',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(TEST_FLUX_MODELS.KONTEXT, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Mock function should be called (tested via workflow execution)
});
it('should handle weight dtype optimization', async () => {
const params = {
cfg: 3.5,
height: 1024,
prompt: 'Weight dtype test',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(TEST_FLUX_MODELS.KONTEXT, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Mock function should be called (tested via workflow execution)
});
it('should generate unique seeds for workflow', async () => {
const { generateUniqueSeeds } = await import('@lobechat/utils');
const params = {
cfg: 3.5,
height: 1024,
prompt: 'Seed generation test',
steps: 28,
width: 1024,
};
await buildFluxKontextWorkflow(TEST_FLUX_MODELS.KONTEXT, params, mockContext);
expect(generateUniqueSeeds).toHaveBeenCalled();
});
});
describe('Complex Workflow Architecture', () => {
it('should handle 28-step workflow complexity', async () => {
const params = {
cfg: 3.5,
height: 1024,
prompt: 'Complex 28-step workflow test',
steps: 28,
width: 1024,
};
const result = await buildFluxKontextWorkflow(TEST_FLUX_MODELS.KONTEXT, params, mockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Verify proper step configuration
});
it('should maintain node connection integrity across modes', async () => {
// Test both modes to ensure consistent node connections
const baseParams = {
cfg: 3.5,
height: 1024,
prompt: 'Connection integrity test',
steps: 28,
width: 1024,
};
// Test t2i mode
const t2iResult = await buildFluxKontextWorkflow(
TEST_FLUX_MODELS.KONTEXT,
baseParams,
mockContext,
);
expect(t2iResult).toHaveProperty('input');
expect(t2iResult).toHaveProperty('setInputNode');
expect(t2iResult).toHaveProperty('setOutputNode');
expect(t2iResult).toHaveProperty('workflow');
// Test i2i mode
const i2iParams = { ...baseParams, imageUrl: 'https://example.com/test.jpg' };
const i2iResult = await buildFluxKontextWorkflow(
TEST_FLUX_MODELS.KONTEXT,
i2iParams,
mockContext,
);
expect(i2iResult).toHaveProperty('input');
expect(i2iResult).toHaveProperty('setInputNode');
expect(i2iResult).toHaveProperty('setOutputNode');
expect(i2iResult).toHaveProperty('workflow');
});
});
});

View file

@ -0,0 +1,558 @@
// @vitest-environment node
import { PromptBuilder } from '@saintno/comfyui-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
TEST_CUSTOM_SD,
TEST_SD35_MODELS,
TEST_SDXL_MODELS,
} from '@/server/services/comfyui/__tests__/fixtures/testModels';
import { mockContext } from '@/server/services/comfyui/__tests__/helpers/mockContext';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
import { buildSimpleSDWorkflow } from '@/server/services/comfyui/workflows/simple-sd';
// Setup basic mocks
vi.mock('@lobechat/utils', () => ({
generateUniqueSeeds: vi.fn(() => ({ seed: 123456, noiseSeed: 654321 })),
}));
vi.mock('../utils/workflowUtils', () => ({
getWorkflowFilenamePrefix: vi.fn(() => 'simple-sd'),
}));
vi.mock('../utils/staticModelLookup', () => ({
getModelConfig: vi.fn((modelName: string) => {
// Mock model configuration mapping
if (modelName.includes('sd3.5') || modelName.includes('sd35')) {
return {
modelFamily: 'SD3',
variant: 'medium',
};
}
if (modelName.includes('sdxl') || modelName.includes('xl')) {
return {
modelFamily: 'SDXL',
variant: 'base',
};
}
if (modelName.includes('sd1') || modelName.includes('v1')) {
return {
modelFamily: 'SD1',
variant: '5',
};
}
if (modelName === TEST_CUSTOM_SD) {
return {
modelFamily: 'SD1',
variant: 'custom',
};
}
return null;
}),
}));
const { inputCalls } = setupAllMocks();
// Extended mock context for SD testing
const createSDMockContext = () => ({
...mockContext,
modelResolverService: {
...mockContext.modelResolverService,
getAvailableVAEFiles: vi
.fn()
.mockResolvedValue([
'vae-ft-mse-840000-ema-pruned.safetensors',
'sdxl_vae_fp16fix.safetensors',
'custom_sd_lobe_vae.safetensors',
]),
getOptimalComponent: vi.fn().mockImplementation((type: string, modelFamily: string) => {
if (type === 'vae') {
if (modelFamily === 'SDXL') {
return Promise.resolve('sdxl_vae_fp16fix.safetensors');
}
if (modelFamily === 'SD1') {
return Promise.resolve('vae-ft-mse-840000-ema-pruned.safetensors');
}
// SD3 models have built-in VAE
return Promise.resolve(undefined);
}
return Promise.resolve(null);
}),
},
});
describe('buildSimpleSDWorkflow - Universal SD Support', () => {
let sdMockContext: any;
beforeEach(() => {
vi.clearAllMocks();
sdMockContext = createSDMockContext();
});
describe('Model Family Detection Tests', () => {
it('should detect SD3.5 model family', async () => {
const modelName = TEST_SD35_MODELS.MEDIUM;
const params = {
cfg: 4.0,
height: 1024,
prompt: 'SD3.5 family test',
steps: 28,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// SD3 models shouldn't need external VAE
});
it('should detect SDXL model family', async () => {
const modelName = TEST_SDXL_MODELS.BASE;
const params = {
cfg: 7.5,
height: 1024,
prompt: 'SDXL family test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// SDXL models should use external VAE
expect(sdMockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledWith(
'vae',
'SDXL',
);
});
it('should handle custom SD model', async () => {
const modelName = TEST_CUSTOM_SD;
const params = {
cfg: 7.5,
height: 512,
prompt: 'Custom SD model test',
steps: 20,
width: 512,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Custom models should check for custom VAE
expect(sdMockContext.modelResolverService.getAvailableVAEFiles).toHaveBeenCalled();
});
});
describe('Smart VAE Selection Tests', () => {
it('should not attach VAE for SD3 models (built-in VAE)', async () => {
const modelName = TEST_SD35_MODELS.LARGE;
const params = {
cfg: 4.0,
height: 1024,
prompt: 'SD3 built-in VAE test',
steps: 28,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// SD3 should not request external VAE
});
it('should attach optimal VAE for SDXL models', async () => {
const modelName = TEST_SDXL_MODELS.BASE;
const params = {
cfg: 7.5,
height: 1024,
prompt: 'SDXL VAE selection test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
expect(sdMockContext.modelResolverService.getOptimalComponent).toHaveBeenCalledWith(
'vae',
'SDXL',
);
});
it('should handle custom VAE for custom models', async () => {
const modelName = TEST_CUSTOM_SD;
const params = {
cfg: 7.5,
height: 512,
prompt: 'Custom VAE test',
steps: 20,
width: 512,
};
// Mock custom VAE availability
sdMockContext.modelResolverService.getAvailableVAEFiles.mockResolvedValue([
'custom_sd_lobe_vae.safetensors',
'vae-ft-mse-840000-ema-pruned.safetensors',
]);
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
expect(sdMockContext.modelResolverService.getAvailableVAEFiles).toHaveBeenCalled();
});
it('should fallback to built-in VAE when custom VAE unavailable', async () => {
const modelName = TEST_CUSTOM_SD;
const params = {
cfg: 7.5,
height: 512,
prompt: 'VAE fallback test',
steps: 20,
width: 512,
};
// Mock custom VAE not available
sdMockContext.modelResolverService.getAvailableVAEFiles.mockResolvedValue([
'vae-ft-mse-840000-ema-pruned.safetensors',
]);
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Should fall back to built-in VAE
});
});
describe('Dual Mode Support Tests', () => {
it('should build text-to-image workflow', async () => {
const modelName = TEST_SDXL_MODELS.BASE;
const params = {
cfg: 7.5,
height: 1024,
prompt: 'Text to image test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Should be in t2i mode (no input image)
});
it('should build image-to-image workflow', async () => {
const modelName = TEST_SDXL_MODELS.BASE;
const params = {
cfg: 7.5,
denoise: 0.75,
height: 1024,
imageUrl: 'https://example.com/input.jpg',
prompt: 'Image to image test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Should be in i2i mode with input image
});
it('should handle strength parameter mapping to denoise', async () => {
const modelName = TEST_SDXL_MODELS.BASE;
const params = {
cfg: 7.5,
height: 1024,
imageUrl: 'https://example.com/input.jpg',
prompt: 'Strength mapping test',
steps: 20,
strength: 0.8, // Frontend parameter
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Strength should be mapped to denoise internally
});
it('should handle imageUrls array parameter', async () => {
const modelName = TEST_SDXL_MODELS.BASE;
const params = {
cfg: 7.5,
height: 1024,
imageUrls: ['https://example.com/input1.jpg', 'https://example.com/input2.jpg'],
prompt: 'Multiple images test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Should use first image from array
});
});
describe('Parameter Validation Tests', () => {
it('should handle different CFG values by model family', async () => {
const testCases = [
{ model: TEST_SD35_MODELS.MEDIUM, cfg: 4.0, family: 'SD3' },
{ model: TEST_SDXL_MODELS.BASE, cfg: 7.5, family: 'SDXL' },
{ model: TEST_CUSTOM_SD, cfg: 7.5, family: 'SD1' },
];
for (const testCase of testCases) {
const params = {
cfg: testCase.cfg,
height: 1024,
prompt: `CFG test for ${testCase.family}`,
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(testCase.model, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
}
});
it('should handle different schedulers by model family', async () => {
const testCases = [
{ model: TEST_SD35_MODELS.MEDIUM, scheduler: 'sgm_uniform' },
{ model: TEST_SDXL_MODELS.BASE, scheduler: 'normal' },
{ model: TEST_CUSTOM_SD, scheduler: 'normal' },
];
for (const testCase of testCases) {
const params = {
cfg: 7.5,
height: 1024,
prompt: `Scheduler test: ${testCase.scheduler}`,
scheduler: testCase.scheduler,
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(testCase.model, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
}
});
it('should handle various image dimensions', async () => {
const dimensions = [
{ width: 512, height: 512 }, // SD1.5 default
{ width: 1024, height: 1024 }, // SDXL default
{ width: 768, height: 1024 }, // Portrait
{ width: 1344, height: 768 }, // Landscape
];
for (const dim of dimensions) {
const params = {
cfg: 7.5,
height: dim.height,
prompt: `Dimension test ${dim.width}x${dim.height}`,
steps: 20,
width: dim.width,
};
const result = await buildSimpleSDWorkflow(TEST_SDXL_MODELS.BASE, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
}
});
});
describe('Error Handling Tests', () => {
it('should handle unknown model gracefully', async () => {
const modelName = 'unknown-model.safetensors';
const params = {
cfg: 7.5,
height: 1024,
prompt: 'Unknown model test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(modelName, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
// Should work with default configuration
});
it('should handle VAE resolution failure', async () => {
const failingContext = {
...sdMockContext,
modelResolverService: {
...sdMockContext.modelResolverService,
getOptimalComponent: vi.fn().mockRejectedValue(new Error('VAE not found')),
},
};
const params = {
cfg: 7.5,
height: 1024,
prompt: 'VAE failure test',
steps: 20,
width: 1024,
};
// Should not throw for SD3 models (built-in VAE)
const result = await buildSimpleSDWorkflow(TEST_SD35_MODELS.MEDIUM, params, failingContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
it('should handle empty prompt', async () => {
const params = {
cfg: 7.5,
height: 1024,
prompt: '',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(TEST_SDXL_MODELS.BASE, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
});
describe('Advanced Features Tests', () => {
it('should support negative prompts', async () => {
const params = {
cfg: 7.5,
height: 1024,
negativePrompt: 'low quality, blurry',
prompt: 'High quality image',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(TEST_SDXL_MODELS.BASE, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
it('should handle seed generation', async () => {
const { generateUniqueSeeds } = await import('@lobechat/utils');
const params = {
cfg: 7.5,
height: 1024,
prompt: 'Seed test',
steps: 20,
width: 1024,
};
await buildSimpleSDWorkflow(TEST_SDXL_MODELS.BASE, params, sdMockContext);
expect(generateUniqueSeeds).toHaveBeenCalled();
});
it('should support custom sampler settings', async () => {
const params = {
cfg: 7.5,
height: 1024,
prompt: 'Custom sampler test',
samplerName: 'dpmpp_2m_sde',
scheduler: 'karras',
steps: 25,
width: 1024,
};
const result = await buildSimpleSDWorkflow(TEST_SDXL_MODELS.BASE, params, sdMockContext);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
});
describe('Backward Compatibility Tests', () => {
it('should maintain API compatibility with existing calls', async () => {
// Test with minimal parameters (existing API pattern)
const minimalParams = {
prompt: 'Backward compatibility test',
};
const result = await buildSimpleSDWorkflow(
TEST_SDXL_MODELS.BASE,
minimalParams,
sdMockContext,
);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
it('should handle legacy parameter names', async () => {
const legacyParams = {
cfg: 7.5,
height: 1024,
inputImage: 'https://example.com/legacy.jpg', // Legacy parameter
prompt: 'Legacy parameter test',
steps: 20,
width: 1024,
};
const result = await buildSimpleSDWorkflow(
TEST_SDXL_MODELS.BASE,
legacyParams,
sdMockContext,
);
expect(result).toHaveProperty('input');
expect(result).toHaveProperty('setInputNode');
expect(result).toHaveProperty('setOutputNode');
expect(result).toHaveProperty('workflow');
});
});
});

View file

@ -0,0 +1,392 @@
// @vitest-environment node
import { PromptBuilder } from '@saintno/comfyui-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
TEST_FLUX_MODELS,
TEST_SD35_MODELS,
} from '@/server/services/comfyui/__tests__/fixtures/testModels';
import { mockContext } from '@/server/services/comfyui/__tests__/helpers/mockContext';
import { setupAllMocks } from '@/server/services/comfyui/__tests__/setup/unifiedMocks';
import { WorkflowError } from '@/server/services/comfyui/errors';
import { buildFluxDevWorkflow } from '@/server/services/comfyui/workflows/flux-dev';
import { buildFluxKontextWorkflow } from '@/server/services/comfyui/workflows/flux-kontext';
import { buildFluxSchnellWorkflow } from '@/server/services/comfyui/workflows/flux-schnell';
import { buildSD35Workflow } from '@/server/services/comfyui/workflows/sd35';
import { buildSimpleSDWorkflow } from '@/server/services/comfyui/workflows/simple-sd';
// Create inline test parameters to avoid external dependencies
const TEST_PARAMETERS = {
'flux-dev': {
defaults: { cfg: 3.5, steps: 20, samplerName: 'euler', scheduler: 'simple' },
boundaries: { min: { cfg: 1, steps: 1 }, max: { cfg: 30, steps: 50 } },
},
'flux-schnell': {
defaults: { cfg: 1, steps: 4, samplerName: 'euler', scheduler: 'simple' },
boundaries: { min: { cfg: 1, steps: 1 }, max: { cfg: 1, steps: 8 } },
},
'flux-kontext': {
defaults: { strength: 0.8 },
},
'sd35': {
defaults: { cfg: 4, steps: 20, samplerName: 'euler', scheduler: 'sgm_uniform' },
boundaries: { min: { cfg: 1, steps: 1 }, max: { cfg: 20, steps: 100 } },
},
'sdxl': {
defaults: { cfg: 7.5, steps: 20, samplerName: 'euler', scheduler: 'normal' },
boundaries: { min: { cfg: 1, steps: 1 }, max: { cfg: 20, steps: 100 } },
},
} as const;
// Mock the utility functions globally
vi.mock('../utils/promptSplitter', () => ({
splitPromptForDualCLIP: vi.fn((prompt: string) => ({
clipLPrompt: prompt,
t5xxlPrompt: prompt,
})),
}));
vi.mock('../utils/weightDType', () => ({
selectOptimalWeightDtype: vi.fn(() => 'default'),
}));
vi.mock('../utils/modelResolver', () => ({
resolveModel: vi.fn((modelName: string) => {
const cleanName = modelName.replace(/^comfyui\//, '');
// Return mock configuration based on model name patterns
if (cleanName.includes('flux_dev') || cleanName.includes('flux-dev')) {
return { family: 'flux', modelFamily: 'FLUX', variant: 'dev' };
}
if (cleanName.includes('flux_schnell') || cleanName.includes('flux-schnell')) {
return { family: 'flux', modelFamily: 'FLUX', variant: 'schnell' };
}
if (cleanName.includes('flux_kontext') || cleanName.includes('kontext')) {
return { family: 'flux', modelFamily: 'FLUX', variant: 'kontext' };
}
if (cleanName.includes('sd3.5') || cleanName.includes('sd35')) {
return { family: 'sd35', modelFamily: 'SD3', variant: 'sd35' };
}
if (cleanName.includes('sdxl') || cleanName.includes('xl')) {
return { family: 'sdxl', modelFamily: 'SDXL', variant: 'sdxl' };
}
if (cleanName.includes('v1-5') || cleanName.includes('sd15')) {
return { family: 'sd15', modelFamily: 'SD1', variant: 'sd15' };
}
return null;
}),
}));
// Workflow builders configuration
type WorkflowBuilderFunction = (modelFileName: string, params: any, context: any) => Promise<any>;
interface WorkflowTestConfig {
name: string;
builder: WorkflowBuilderFunction;
modelName: string;
parameterKey: keyof typeof TEST_PARAMETERS;
specialFeatures?: string[];
errorTests?: boolean;
}
const WORKFLOW_CONFIGS: WorkflowTestConfig[] = [
{
name: 'FLUX Dev',
builder: buildFluxDevWorkflow,
modelName: TEST_FLUX_MODELS.DEV,
parameterKey: 'flux-dev',
specialFeatures: ['variable CFG', 'advanced sampler'],
},
{
name: 'FLUX Schnell',
builder: buildFluxSchnellWorkflow,
modelName: TEST_FLUX_MODELS.SCHNELL,
parameterKey: 'flux-schnell',
specialFeatures: ['fixed CFG', 'fast generation'],
},
{
name: 'FLUX Kontext',
builder: buildFluxKontextWorkflow,
modelName: TEST_FLUX_MODELS.KONTEXT,
parameterKey: 'flux-kontext',
specialFeatures: ['img2img support', 'vision capabilities'],
},
{
name: 'SD3.5',
builder: buildSD35Workflow,
modelName: TEST_SD35_MODELS.LARGE,
parameterKey: 'sd35',
specialFeatures: ['external encoders', 'SGM scheduler'],
errorTests: true,
},
{
name: 'Simple SD',
builder: buildSimpleSDWorkflow,
modelName: 'sd_xl_base_1.0.safetensors',
parameterKey: 'sdxl',
specialFeatures: ['VAE handling', 'legacy support'],
},
];
describe('Unified Workflow Tests', () => {
const { inputCalls } = setupAllMocks();
beforeEach(() => {
vi.clearAllMocks();
});
describe.each(WORKFLOW_CONFIGS)('$name Workflow', (config) => {
it('should create workflow with default parameters', async () => {
const fixture = TEST_PARAMETERS[config.parameterKey];
const params = {
prompt: 'A beautiful landscape',
...fixture!.defaults,
// Add standard dimensions for text-to-image models
width: 1024,
height: 1024,
};
const result = await config.builder(config.modelName, params, mockContext);
// Verify workflow result is returned
expect(result).toBeDefined();
expect(result).toHaveProperty('input'); // PromptBuilder mock returns object with input method
});
it('should create workflow with custom parameters', async () => {
const fixture = TEST_PARAMETERS[config.parameterKey];
const customParams = {
prompt: 'Custom prompt for testing',
width: 768,
height: 512,
steps: (fixture as any).boundaries?.max?.steps || 30,
cfg: (fixture as any).boundaries?.max?.cfg || 7.5,
};
const result = await config.builder(config.modelName, customParams, mockContext);
expect(result).toBeDefined();
expect(result).toHaveProperty('input');
});
it('should handle empty prompt gracefully', async () => {
const fixture = TEST_PARAMETERS[config.parameterKey];
const params = {
prompt: '',
...fixture!.defaults,
width: 1024,
height: 1024,
};
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
expect(result).toHaveProperty('input');
});
it('should handle boundary values correctly', async () => {
const fixture = TEST_PARAMETERS[config.parameterKey];
// Only test boundaries if they exist - Linus principle: don't test what doesn't exist
if ((fixture as any).boundaries) {
const minParams = {
prompt: 'Minimum value test',
width: 512,
height: 512,
steps: (fixture as any).boundaries.min.steps,
cfg: (fixture as any).boundaries.min.cfg,
};
const minResult = await config.builder(config.modelName, minParams, mockContext);
expect(minResult).toBeDefined();
const maxParams = {
prompt: 'Maximum value test',
width: 1024,
height: 1024,
steps: (fixture as any).boundaries.max.steps,
cfg: (fixture as any).boundaries.max.cfg,
};
const maxResult = await config.builder(config.modelName, maxParams, mockContext);
expect(maxResult).toBeDefined();
}
});
// Special feature tests
if (config.specialFeatures?.includes('img2img support')) {
it('should handle image-to-image parameters', async () => {
const params = {
prompt: 'Transform this image',
imageUrl: 'https://example.com/test.jpg',
strength: 0.8,
width: 1024,
height: 1024,
};
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
expect(result).toHaveProperty('input');
});
it('should handle multiple image URLs', async () => {
const params = {
prompt: 'Process multiple images',
imageUrls: ['https://example.com/img1.jpg', 'https://example.com/img2.jpg'],
strength: 0.75,
width: 1024,
height: 1024,
};
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
expect(result).toHaveProperty('input');
});
}
if (config.specialFeatures?.includes('variable CFG')) {
it('should support variable CFG values', async () => {
const params = {
prompt: 'Variable CFG test',
cfg: 5.0, // Different from default
width: 1024,
height: 1024,
};
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
expect(result).toHaveProperty('input');
});
}
if (config.specialFeatures?.includes('fixed CFG')) {
it('should use fixed CFG regardless of input', async () => {
const params = {
prompt: 'Fixed CFG test',
cfg: 7.0, // Should be ignored for Schnell
width: 1024,
height: 1024,
};
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
expect(result).toHaveProperty('input');
});
}
// Error handling tests for models that support them
if (config.errorTests) {
it('should throw WorkflowError when required components are missing', async () => {
// Create a context that simulates missing encoders
const mockContextNoEncoders = {
...mockContext,
modelResolverService: {
...mockContext.modelResolverService,
getOptimalComponent: vi.fn().mockResolvedValue(undefined),
},
};
const params = {
prompt: 'Test with missing encoders',
};
await expect(
config.builder(config.modelName, params, mockContextNoEncoders),
).rejects.toThrow(WorkflowError);
});
}
});
// Cross-workflow comparison tests
describe('Cross-Workflow Validation', () => {
it('should handle aspect ratio transformations consistently', async () => {
const aspectRatioTests = [
{ input: '16:9', expected: { width: 1024, height: 576 } },
{ input: '1:1', expected: { width: 1024, height: 1024 } },
{ input: '9:16', expected: { width: 576, height: 1024 } },
];
for (const ratioTest of aspectRatioTests) {
const params = {
prompt: 'Aspect ratio test',
width: ratioTest.expected.width,
height: ratioTest.expected.height,
};
// Test with multiple workflows
for (const config of WORKFLOW_CONFIGS.slice(0, 3)) {
// Test first 3 workflows
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
}
}
});
it('should handle seed parameter consistently', async () => {
const testSeeds = [undefined, 0, 12345, 999999];
for (const seed of testSeeds) {
const params = {
prompt: 'Seed consistency test',
seed,
width: 1024,
height: 1024,
};
// Test with workflows that support seed
for (const config of WORKFLOW_CONFIGS.filter((c) => c.name !== 'FLUX Kontext')) {
const result = await config.builder(config.modelName, params, mockContext);
expect(result).toBeDefined();
}
}
});
});
// Performance and validation tests
describe('Performance and Validation', () => {
it('should create workflows efficiently', async () => {
const startTime = Date.now();
// Create multiple workflows in parallel
const promises = WORKFLOW_CONFIGS.map((config) =>
config.builder(config.modelName, { prompt: 'Performance test' }, mockContext),
);
const results = await Promise.all(promises);
const endTime = Date.now();
// Verify all workflows were created
results.forEach((result) => {
expect(result).toBeDefined();
});
// Simple performance check - should complete within reasonable time
expect(endTime - startTime).toBeLessThan(1000); // Less than 1 second
});
it('should handle malformed parameters gracefully', async () => {
const malformedParams = [
{ prompt: null },
{ prompt: 'test', width: -100 },
{ prompt: 'test', height: 0 },
{ prompt: 'test', steps: -5 },
];
for (const params of malformedParams) {
for (const config of WORKFLOW_CONFIGS.slice(0, 2)) {
// Test with 2 workflows
// Should not throw - workflows should handle invalid params gracefully
try {
const result = await config.builder(config.modelName, params as any, mockContext);
expect(result).toBeDefined();
} catch (error) {
// If it throws, it should be a specific workflow error, not a generic JS error
expect(error).toBeInstanceOf(Error);
}
}
}
});
});
});

View file

@ -0,0 +1,110 @@
/**
* ComfyUI framework constants configuration
* Unified management of hardcoded values with environment variable overrides /
*/
/**
* Default configuration /
* BASE_URL不再处理环境变量
*/
export const COMFYUI_DEFAULTS = {
BASE_URL: 'http://localhost:8000',
CONNECTION_TIMEOUT: 30_000,
MAX_RETRIES: 3,
} as const;
/**
* FLUX model configuration / FLUX
* Removed over-engineered dynamic T5 selection, maintain simple fixed configuration / T5选择
*/
export const FLUX_MODEL_CONFIG = {
FILENAME_PREFIXES: {
DEV: 'LobeChat/%year%-%month%-%day%/FLUX_Dev',
KONTEXT: 'LobeChat/%year%-%month%-%day%/FLUX_Kontext',
KREA: 'LobeChat/%year%-%month%-%day%/FLUX_Krea',
SCHNELL: 'LobeChat/%year%-%month%-%day%/FLUX_Schnell',
},
} as const;
/**
* SD model configuration
* Fixed model and filename prefixes for SD models
*/
export const SD_MODEL_CONFIG = {
FILENAME_PREFIXES: {
CUSTOM: 'LobeChat/%year%-%month%-%day%/CustomSD',
SD15: 'LobeChat/%year%-%month%-%day%/SD15',
SD35: 'LobeChat/%year%-%month%-%day%/SD35',
SDXL: 'LobeChat/%year%-%month%-%day%/SDXL',
},
} as const;
/**
* Default workflow node parameters /
* Based on 2024 community best practices configuration / 2024
*/
/**
* Essential workflow defaults for internal use only
* These are hardcoded values used by workflow internals, not user-configurable parameters
*/
export const WORKFLOW_DEFAULTS = {
// FLUX specific settings
FLUX: {
BASE_SHIFT: 0.5,
CLIP_GUIDANCE: 1,
SAMPLER: 'euler',
SCHEDULER: 'simple', // Higher denoise for Kontext img2img
},
// Image dimensions and batch settings
IMAGE: {
BATCH_SIZE: 1, // workflow internal use
},
// Internal noise and sampling settings
SAMPLING: {
DENOISE: 1, // t2i mode internal use
MAX_SHIFT: 1.15, // FLUX internal parameter
},
// SD3.5 specific internal settings
SD3: {
SHIFT: 3, // SD3.5 ModelSamplingSD3 internal parameter
},
} as const;
/**
* Default negative prompt for all SD models
*/
export const DEFAULT_NEGATIVE_PROMPT = `worst quality, normal quality, low quality, low res, blurry, distortion, text, watermark, logo, banner, extra digits, cropped, jpeg artifacts, signature, username, error, sketch, duplicate, ugly, monochrome, horror, geometry, mutation, disgusting, bad anatomy, bad proportions, bad quality, deformed, disconnected limbs, out of frame, out of focus, dehydrated, disfigured, extra arms, extra limbs, extra hands, fused fingers, gross proportions, long neck, jpeg, malformed limbs, mutated, mutated hands, mutated limbs, missing arms, missing fingers, picture frame, poorly drawn hands, poorly drawn face, collage, pixel, pixelated, grainy, color aberration, amputee, autograph, bad illustration, beyond the borders, blank background, body out of frame, boring background, branding, cut off, dismembered, disproportioned, distorted, draft, duplicated features, extra fingers, extra legs, fault, flaw, grains, hazy, identifying mark, improper scale, incorrect physiology, incorrect ratio, indistinct, kitsch, low resolution, macabre, malformed, mark, misshapen, missing hands, missing legs, mistake, morbid, mutilated, off-screen, outside the picture, poorly drawn feet, printed words, render, repellent, replicate, reproduce, revolting dimensions, script, shortened, sign, split image, squint, storyboard, tiling, trimmed, unfocused, unattractive, unnatural pose, unreal engine, unsightly, written language`;
/**
* Supported model file formats
* Used for model file validation and detection
*/
export const SUPPORTED_MODEL_FORMATS = [
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.gguf', // GGUF format for quantized models
] as const;
/**
* Custom SD model configuration
* Fixed model and VAE filenames for custom SD models
*/
export const CUSTOM_SD_CONFIG = {
MODEL_FILENAME: 'custom_sd_lobe.safetensors', // Both custom models use same file
VAE_FILENAME: 'custom_sd_vae_lobe.safetensors', // Optional VAE file
} as const;
/**
* Component to ComfyUI node mappings
* Maps component types to their corresponding ComfyUI loader nodes and input fields
*/
export const COMPONENT_NODE_MAPPINGS: Record<string, { field: string; node: string }> = {
clip: { field: 'clip_name', node: 'CLIPLoader' },
t5: { field: 'clip_name', node: 'CLIPLoader' }, // T5 is also CLIP type
vae: { field: 'vae_name', node: 'VAELoader' },
// Main models (UNET) are fetched via getCheckpoints(), not here
} as const;

View file

@ -0,0 +1,843 @@
/**
* FLUX Model Registry - Separated for maintainability
* Contains all FLUX model family registrations
*/
import type { ModelConfig } from './modelRegistry';
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const FLUX_MODEL_REGISTRY: Record<string, ModelConfig> = {
// === Priority 1: Official Models (4 models) ===
'flux1-dev.safetensors': {
priority: 1,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell.safetensors': {
priority: 1,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev.safetensors': {
priority: 1,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev.safetensors': {
priority: 1,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// === Priority 2: Enterprise Optimized Models (106 models) ===
// 2.1 Enterprise Lightweight Models
'flux.1-lite-8B.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux.1-lite-8B-alpha.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux-mini.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'FLUX_Mini_3_2B.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux_shakker_labs_union_pro-fp8_e4m3fn.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
// 2.2 GGUF Series - FLUX.1-dev (11 models)
'flux1-dev-F16.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q8_0.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q6_K.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q5_K_M.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q5_K_S.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q4_K_M.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q4_K_S.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q4_0.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q3_K_M.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q3_K_S.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-Q2_K.gguf': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.3 GGUF Series - FLUX.1-schnell (11 models)
'flux1-schnell-F16.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q8_0.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q6_K.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q5_K_M.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q5_K_S.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q4_K_M.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q4_K_S.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q4_0.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q3_K_M.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q3_K_S.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-Q2_K.gguf': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.4 GGUF Series - FLUX.1-kontext (11 models)
'flux1-kontext-dev-F16.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q8_0.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q6_K.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q5_K_M.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q5_K_S.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q4_K_M.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q4_K_S.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q4_0.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q3_K_M.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q3_K_S.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-Q2_K.gguf': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.5 GGUF Series - FLUX.1-krea (11 models)
'flux1-krea-dev-F16.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q8_0.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q6_K.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q5_K_M.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q5_K_S.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q4_K_M.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q4_K_S.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q4_0.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q3_K_M.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q3_K_S.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-Q2_K.gguf': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.6 FP8 Series - dev variant (10 models)
'flux1-dev-fp8.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-dev-fp8-e4m3fn.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-dev-fp8-e5m2.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e5m2',
},
// 2.7 FP8 Series - schnell variant (4 models)
'flux1-schnell-fp8.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-schnell-fp8-e4m3fn.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-schnell-fp8-e5m2.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e5m2',
},
// 2.8 FP8 Series - kontext variant (3 models)
'flux1-dev-kontext_fp8_scaled.safetensors': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-kontext-dev-fp8-e4m3fn.safetensors': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-kontext-dev-fp8-e5m2.safetensors': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e5m2',
},
// 2.9 FP8 Series - krea variant (3 models)
'flux1-krea-dev_fp8_scaled.safetensors': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-krea-dev-fp8-e4m3fn.safetensors': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux1-krea-dev-fp8-e5m2.safetensors': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e5m2',
},
// 2.10 NF4 Quantization Series (7 models)
'flux1-dev-bnb-nf4.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-bnb-nf4-v2.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-bnb-nf4.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-kontext-dev-bnb-nf4.safetensors': {
priority: 2,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-krea-dev-bnb-nf4.safetensors': {
priority: 2,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.11 Technical Experimental Models - SVDQuant Series
'flux1-dev-svdquant-w4a4.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-svdquant-w4a4.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.12 Technical Experimental Models - TorchAO Series
'flux1-dev-torchao-int8.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-dev-torchao-int4.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-torchao-int8.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.13 Technical Experimental Models - optimum-quanto Series
'flux1-dev-quanto-qfloat8.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-quanto-qfloat8.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 2.14 Technical Experimental Models - MFLUX Series (Apple Silicon Optimized)
'flux1-dev-mflux-q4.safetensors': {
priority: 2,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux1-schnell-mflux-q4.safetensors': {
priority: 2,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// === Priority 3: Community Fine-tuned Models (48 models) ===
// 3.1 Jib Mix Flux系列
'Jib_Mix_Flux_v8_schnell.safetensors': {
priority: 3,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'Jib_mix_Flux_V11_Krea_b_00001_.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'jibMixFlux_v8.q4_0.gguf': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.2 Real Dream FLUX系列
'real_dream_flux_v1.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'real_dream_flux_beta.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'real_dream_flux_release.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'realDream_flux1V1.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'realDream_flux1V1_schnell.safetensors': {
priority: 3,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.3 Vision Realistic FLUX系列
'vision_realistic_flux_dev_v2.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'vision_realistic_flux_dev_fp8_no_clip_v2.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'vision_realistic_flux_v2_fp8.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'vision_realistic_flux_v2_dev.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'vision_realistic_flux_shakker.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.4 Flux Fusion系列
'flux_fusion_v2_4steps.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux_fusion_ds_merge.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux_fusion_v2_tensorart.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.5 PixelWave FLUX系列
'PixelWave_FLUX.1-dev_03.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'PixelWave_FLUX.1-schnell_04.safetensors': {
priority: 3,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.6 Fux Capacity系列
'Fux_Capacity_NSFW_v3.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'FuxCapacity2.1-Q8_0.gguf': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'FuxCapacity3.0_FP8.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'FuxCapacity3.1_FP16.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.7 Fluxmania系列
'FluxMania_Kreamania_v1.safetensors': {
priority: 3,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'Fluxmania_IV_fp8.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'Fluxmania_V6I.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'Fluxmania_V6I_fp16.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.8 Fluxed Up系列
'Fluxed_Up_NSFW_v2.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.9 企业级LiblibAI模型
'flux.1-ultra-realphoto-v2.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'f.1-dev-schnell-8steps-fp8.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'flux-muchen-asian.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'moyou-film-flux.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'firefly-fantasy-flux.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux-yanling-anime.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
// 3.10 Legacy Models for Compatibility
'Acorn_Spinning_FLUX_photorealism.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'CreArt_Hyper_Flux_Dev_8steps.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'Flux_Unchained_SCG_mixed.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'RealFlux_1.0b_Dev_Transformer.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'RealFlux_1.0b_Schnell.safetensors': {
priority: 3,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'UltraReal_FineTune_v4.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'UltraRealistic_FineTune_Project_v4.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'XPlus_2(GGUF_Q4).gguf': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'XPlus_2(GGUF_Q6).gguf': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'XPlus_2(GGUF_Q8).gguf': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'educational-flux-simplified.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux-depth-fp16.safetensors': {
priority: 3,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'commercial-flux-toolkit.safetensors': {
priority: 3,
variant: 'krea',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux-fill-object-removal.safetensors': {
priority: 3,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux-medical-environment-lora.safetensors': {
priority: 3,
variant: 'kontext',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'flux-schnell-dev-merged-fp8.safetensors': {
priority: 3,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'fp8_e4m3fn',
},
'schnellMODE_FLUX_S_v5_1.safetensors': {
priority: 3,
variant: 'schnell',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
'NF4_BnB_FLUX_dev_optimized.safetensors': {
priority: 3,
variant: 'dev',
modelFamily: 'FLUX',
recommendedDtype: 'default',
},
};
/* eslint-enable sort-keys-fix/sort-keys-fix */

View file

@ -0,0 +1,48 @@
/**
* ComfyUI Model Registry - Linus-style simple design
* Interface shared, registries split for maintainability
*/
import { FLUX_MODEL_REGISTRY } from './fluxModelRegistry';
import { SD_MODEL_REGISTRY } from './sdModelRegistry';
export interface ModelConfig {
modelFamily: string;
priority: number;
recommendedDtype?: 'default' | 'fp8_e4m3fn' | 'fp8_e4m3fn_fast' | 'fp8_e5m2';
variant: string;
}
// ===================================================================
// Combined Model Registry - FLUX + SD families
// ===================================================================
export const MODEL_REGISTRY: Record<string, ModelConfig> = {
...FLUX_MODEL_REGISTRY,
...SD_MODEL_REGISTRY,
};
/**
* Model ID to Variant mapping
* Maps actual frontend model IDs to their corresponding variants in registry
* Based on src/config/aiModels/comfyui.ts definitions
*/
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const MODEL_ID_VARIANT_MAP: Record<string, string> = {
// FLUX models
'flux-schnell': 'schnell', // comfyui/flux-schnell
'flux-dev': 'dev', // comfyui/flux-dev
'flux-krea-dev': 'krea', // comfyui/flux-krea-dev
'flux-kontext-dev': 'kontext', // comfyui/flux-kontext-dev
// SD3 models
'stable-diffusion-35': 'sd35', // comfyui/stable-diffusion-35
'stable-diffusion-35-inclclip': 'sd35-inclclip', // comfyui/stable-diffusion-35-inclclip
// SD1/SDXL models
'stable-diffusion-15': 'sd15-t2i', // comfyui/stable-diffusion-15
'stable-diffusion-xl': 'sdxl-t2i', // comfyui/stable-diffusion-xl
'stable-diffusion-refiner': 'sdxl-i2i', // comfyui/stable-diffusion-refiner
'stable-diffusion-custom': 'custom-sd', // comfyui/stable-diffusion-custom
'stable-diffusion-custom-refiner': 'custom-sd', // comfyui/stable-diffusion-custom-refiner
};
/* eslint-enable sort-keys-fix/sort-keys-fix */

View file

@ -0,0 +1,624 @@
/**
* Prompt Optimizer Configuration
* -
*/
/**
* Style keywords configuration - organized by category
* - 便
*/
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const STYLE_KEYWORDS = {
// Artists and platforms / 艺术家和平台
ARTISTS: [
'by greg rutkowski',
'by artgerm',
'by wlop',
'by alphonse mucha',
'by james gurney',
'by makoto shinkai',
'by ghibli',
'by hayao miyazaki',
'by tim burton',
'by banksy',
'trending on artstation',
'artstation',
'deviantart',
'pixiv',
'concept art',
'illustration',
'artwork',
'painting',
'drawing',
'digital painting',
],
// Art styles / 艺术风格
ART_STYLES: [
'photorealistic',
'photo realistic',
'realistic',
'hyperrealistic',
'hyper realistic',
'anime',
'anime style',
'manga',
'manga style',
'cartoon',
'cartoon style',
'oil painting',
'watercolor',
'watercolor painting',
'acrylic painting',
'sketch',
'pencil sketch',
'charcoal drawing',
'digital art',
'3d render',
'3d rendering',
'cgi',
'pixel art',
'8bit',
'16bit',
'retro pixel',
'cinematic',
'film still',
'movie scene',
'abstract',
'abstract art',
'surreal',
'surrealism',
'impressionist',
'impressionism',
'expressionist',
'expressionism',
'minimalist',
'minimalism',
'pop art',
'art nouveau',
'art deco',
'baroque',
'renaissance',
'gothic',
'cyberpunk',
'steampunk',
'dieselpunk',
'solarpunk',
'vaporwave',
'synthwave',
'retrowave',
],
// Lighting effects / 光照效果
LIGHTING: [
'dramatic lighting',
'soft lighting',
'hard lighting',
'studio lighting',
'golden hour',
'golden hour lighting',
'blue hour',
'magic hour',
'sunset lighting',
'sunrise lighting',
'neon lights',
'neon lighting',
'rim lighting',
'backlit',
'backlighting',
'volumetric lighting',
'god rays',
'crepuscular rays',
'natural lighting',
'ambient lighting',
'warm lighting',
'cold lighting',
'cool lighting',
'moody lighting',
'atmospheric lighting',
'cinematic lighting',
'chiaroscuro',
'low key lighting',
'high key lighting',
'diffused lighting',
'harsh lighting',
'candlelight',
'firelight',
'moonlight',
'sunlight',
'fluorescent',
'incandescent',
],
// Photography terms / 摄影术语
PHOTOGRAPHY: [
'depth of field',
'shallow depth of field',
'deep depth of field',
'bokeh',
'bokeh effect',
'motion blur',
'film grain',
'lens grain',
'chromatic aberration',
'lens distortion',
'fisheye',
'macro',
'macro photography',
'wide angle',
'ultra wide angle',
'telephoto',
'telephoto lens',
'portrait',
'portrait photography',
'landscape',
'landscape photography',
'street photography',
'aerial photography',
'drone photography',
'long exposure',
'time-lapse',
'close-up',
'extreme close-up',
'medium shot',
'wide shot',
'establishing shot',
'dof',
'35mm',
'35mm photograph',
'50mm',
'85mm',
'professional photograph',
'professional photography',
'dslr',
'mirrorless',
'medium format',
'hasselblad',
'canon',
'nikon',
'sony alpha',
'film photography',
'analog photography',
'polaroid',
'instant photo',
],
// Quality descriptions / 质量描述
QUALITY: [
'high quality',
'best quality',
'highest quality',
'top quality',
'masterpiece',
'award winning',
'professional',
'professional quality',
'4k',
'4k resolution',
'8k',
'8k resolution',
'uhd',
'ultra hd',
'full hd',
'hd',
'high resolution',
'high res',
'ultra detailed',
'highly detailed',
'super detailed',
'extremely detailed',
'insanely detailed',
'intricate',
'intricate details',
'fine details',
'sharp',
'sharp focus',
'crisp',
'crystal clear',
'pristine',
'flawless',
'perfect',
'stunning',
'beautiful',
'gorgeous',
'breathtaking',
'magnificent',
'exquisite',
],
// Rendering and effects / 渲染和效果
RENDERING: [
'octane render',
'octane',
'unreal engine',
'unreal engine 5',
'ue5',
'unity',
'blender',
'maya',
'cinema 4d',
'c4d',
'houdini',
'zbrush',
'substance painter',
'marmoset',
'keyshot',
'vray',
'v-ray',
'arnold render',
'redshift',
'cycles',
'cycles render',
'ray tracing',
'path tracing',
'global illumination',
'gi',
'ambient occlusion',
'ao',
'subsurface scattering',
'sss',
'pbr',
'physically based rendering',
'bloom',
'bloom effect',
'lens flare',
'post processing',
'color grading',
'tone mapping',
'hdr',
'high dynamic range',
],
// Color and mood / 颜色和氛围
COLOR_MOOD: [
'vibrant',
'vibrant colors',
'vivid',
'vivid colors',
'muted',
'muted colors',
'pastel',
'pastel colors',
'monochrome',
'black and white',
'grayscale',
'sepia',
'warm colors',
'cool colors',
'cold colors',
'neon colors',
'psychedelic',
'psychedelic colors',
'rainbow',
'iridescent',
'holographic',
'metallic',
'chrome',
'golden',
'silver',
'dark',
'dark mood',
'moody',
'atmospheric',
'ethereal',
'dreamy',
'dreamlike',
'surreal atmosphere',
'mysterious',
'mystical',
'magical',
'fantasy',
'epic',
'dramatic',
'intense',
'peaceful',
'serene',
'calm',
'tranquil',
'melancholic',
'nostalgic',
'romantic',
'whimsical',
'playful',
'cheerful',
'gloomy',
'ominous',
'eerie',
'creepy',
'horror',
'gothic atmosphere',
],
// Texture and materials / 纹理和材质
TEXTURE_MATERIAL: [
'glossy',
'matte',
'satin',
'rough',
'smooth',
'polished',
'brushed',
'textured',
'glass',
'crystal',
'transparent',
'translucent',
'reflective',
'refractive',
'metallic texture',
'chrome finish',
'copper',
'brass',
'bronze',
'steel',
'aluminum',
'titanium',
'wood',
'wooden',
'oak',
'mahogany',
'bamboo',
'marble',
'granite',
'stone',
'concrete',
'brick',
'fabric',
'cloth',
'silk',
'velvet',
'cotton',
'denim',
'leather',
'suede',
'fur',
'plastic',
'rubber',
'latex',
'organic',
'bio',
'liquid',
'fluid',
'gel',
'ice',
'frost',
'frozen',
'wet',
'dry',
'dusty',
'rusty',
'weathered',
'aged',
'vintage texture',
'retro texture',
],
} as const;
/**
* Style synonyms mapping for better recognition
*
*/
export const STYLE_SYNONYMS: Record<string, string[]> = {
// Photography variations
'photorealistic': [
'photo-realistic',
'photo realistic',
'lifelike',
'true-to-life',
'true to life',
],
'hyperrealistic': ['hyper-realistic', 'hyper realistic', 'ultra realistic', 'ultrarealistic'],
'depth of field': ['dof', 'depth-of-field', 'focal depth', 'focus depth'],
'bokeh': ['bokeh effect', 'background blur', 'out of focus background'],
// Art style variations
'cinematic': ['filmic', 'movie-like', 'film-style', 'theatrical', 'cinema style'],
'anime': ['anime-style', 'japanese animation', 'animestyle'],
'manga': ['manga-style', 'japanese comic', 'mangastyle'],
'3d render': ['3d-render', '3d rendering', '3d-rendering', 'three dimensional', 'cgi render'],
'digital art': ['digital-art', 'digital artwork', 'digital painting', 'digitalart'],
// Quality variations
'4k': ['4k resolution', '4k quality', 'ultra hd', 'uhd', '3840x2160', '4096x2160'],
'8k': ['8k resolution', '8k quality', 'ultra hd+', '7680x4320', '8192x4320'],
'high quality': ['high-quality', 'hq', 'hi quality', 'hi-quality', 'highquality'],
'masterpiece': ['master piece', 'master-piece', 'opus', 'magnum opus'],
// Lighting variations
'golden hour': ['golden-hour', 'magic hour', 'sunset light', 'sunrise light'],
'rim lighting': ['rim-lighting', 'rimlight', 'rim light', 'edge lighting'],
'volumetric lighting': ['volumetric-lighting', 'god rays', 'light rays', 'sun rays'],
// Rendering variations
'octane render': ['octane-render', 'octanerender', 'otoy octane'],
'unreal engine': ['unreal-engine', 'ue4', 'ue5', 'unrealengine'],
'ray tracing': ['ray-tracing', 'raytracing', 'rt', 'rtx'],
// Artist variations
'by greg rutkowski': ['greg rutkowski', 'rutkowski', 'greg-rutkowski'],
'by artgerm': ['artgerm', 'stanley lau', 'artgerm lau'],
'trending on artstation': ['artstation trending', 'artstation hq', 'artstation-hq'],
};
/**
* Compound styles that should be recognized as a whole
*
*/
export const COMPOUND_STYLES = [
// Studio and brand styles
'studio ghibli style',
'pixar style',
'disney style',
'dreamworks style',
'marvel style',
'dc comics style',
// Specific art movements
'art nouveau style',
'art deco style',
'pop art style',
'street art style',
'graffiti style',
// Game and media styles
'league of legends style',
'overwatch style',
'world of warcraft style',
'final fantasy style',
'zelda style',
'pokemon style',
// Photography styles
'national geographic style',
'vogue style',
'fashion photography',
'portrait photography',
'landscape photography',
'street photography',
'wildlife photography',
'macro photography',
// Specific artist styles
'van gogh style',
'picasso style',
'monet style',
'rembrandt style',
'da vinci style',
'warhol style',
'banksy style',
'tim burton style',
'wes anderson style',
'christopher nolan style',
// Technical compound terms
'physically based rendering',
'global illumination',
'subsurface scattering',
'ambient occlusion',
'chromatic aberration',
'depth of field',
'motion blur',
'lens flare',
// Atmosphere combinations
'cinematic lighting',
'dramatic lighting',
'studio lighting',
'natural lighting',
'volumetric fog',
'atmospheric perspective',
'aerial perspective',
// Quality combinations
'ultra high definition',
'ultra high quality',
'super high resolution',
'professional quality',
'production quality',
'broadcast quality',
'print quality',
// Complex styles
'cyberpunk aesthetic',
'steampunk aesthetic',
'vaporwave aesthetic',
'synthwave aesthetic',
'cottagecore aesthetic',
'dark academia aesthetic',
'y2k aesthetic',
'minimalist design',
'maximalist design',
'brutalist architecture',
'gothic architecture',
'baroque architecture',
] as const;
/**
* Precise adjective patterns for style extraction
*
*/
export const STYLE_ADJECTIVE_PATTERNS = {
// Visual quality related / 视觉质量相关
quality:
/^(sharp|blur(ry)?|clear|crisp|clean|smooth|rough|grainy|noisy|pristine|flawless|perfect|polished)$/i,
// Artistic style related / 艺术风格相关
artistic:
/^(abstract|surreal|minimal(ist)?|ornate|baroque|gothic|modern|contemporary|traditional|classical|vintage|retro|antique|futuristic|avant-garde)$/i,
// Color and lighting / 颜色和光照
visual:
/^(bright|dark|dim|vibrant|vivid|muted|saturated|desaturated|warm|cool|cold|hot|soft|hard|harsh|gentle|subtle|bold|pale|rich|deep)$/i,
// Mood and atmosphere / 情绪和氛围
mood: /^(dramatic|peaceful|chaotic|serene|calm|mysterious|mystical|magical|epic|legendary|heroic|romantic|melancholic|nostalgic|whimsical|playful|serious|solemn|cheerful|gloomy|ominous|eerie|creepy|scary|dreamy|ethereal|fantastical|moody|atmospheric)$/i,
// Texture and material / 纹理和材质
texture:
/^(metallic|wooden|glass(y)?|crystalline|fabric|leather|plastic|rubber|organic|synthetic|liquid|solid|transparent|translucent|opaque|reflective|matte|glossy|satin|rough|smooth|wet|dry|dusty|rusty|weathered|aged|new|fresh|worn)$/i,
// Size and scale / 尺寸和规模
scale:
/^(tiny|small|medium|large|huge|massive|gigantic|colossal|enormous|microscopic|miniature|oversized|epic-scale|human-scale|intimate|vast|infinite)$/i,
// Complexity and detail / 复杂度和细节
detail:
/^(simple|complex|intricate|elaborate|detailed|minimal|advanced|sophisticated|primitive|refined|crude|delicate|robust)$/i,
// Professional quality / 专业质量
professional:
/^(professional|amateur|masterful|skilled|expert|novice|polished|raw|finished|unfinished|complete|incomplete|refined|rough)$/i,
} as const;
/* eslint-enable sort-keys-fix/sort-keys-fix */
/**
* Get all style keywords as a flattened array
*
*/
export function getAllStyleKeywords(): readonly string[] {
return Object.values(STYLE_KEYWORDS).flat();
}
/**
* Get all compound styles
*
*/
export function getCompoundStyles(): readonly string[] {
return COMPOUND_STYLES;
}
/**
* Normalize a style term using synonyms
* 使
*/
export function normalizeStyleTerm(term: string): string {
const lowerTerm = term.toLowerCase();
// Check if this term is a synonym
for (const [canonical, synonyms] of Object.entries(STYLE_SYNONYMS)) {
if (synonyms.includes(lowerTerm)) {
return canonical;
}
}
return term;
}
/**
* Check if a word matches any style adjective pattern
*
*/
export function isStyleAdjective(word: string): boolean {
const lowerWord = word.toLowerCase();
return Object.values(STYLE_ADJECTIVE_PATTERNS).some((pattern) => pattern.test(lowerWord));
}
/**
* Extract style adjectives from words based on precise patterns
*
*/
export function extractStyleAdjectives(words: string[]): string[] {
return words.filter((word) => isStyleAdjective(word));
}

View file

@ -0,0 +1,508 @@
/**
* Stable Diffusion Model Registry - Separated for maintainability
* Contains all SD1.5, SDXL, and SD3.5 model family registrations
*/
import type { ModelConfig } from './modelRegistry';
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const SD_MODEL_REGISTRY: Record<string, ModelConfig> = {
// ===================================================================
// SD3.5 Model Family Registry
// ===================================================================
// SD3.5 Models (requires clip_g.safetensors)
'sd3.5_large.safetensors': {
priority: 1,
variant: 'sd35',
modelFamily: 'SD3',
},
'sd3.5_large_turbo.safetensors': {
priority: 2,
variant: 'sd35',
modelFamily: 'SD3',
},
'sd3.5_medium.safetensors': {
priority: 3,
variant: 'sd35',
modelFamily: 'SD3',
},
'sd3.5_large_fp8_scaled.safetensors': {
priority: 1,
variant: 'sd35',
modelFamily: 'SD3',
},
// SD3.5 Models (With CLIP - includes CLIP/T5 internally)
'sd3.5_medium_incl_clips_t5xxlfp8scaled.safetensors': {
priority: 1,
variant: 'sd35-inclclip',
modelFamily: 'SD3',
},
// === Custom SD Models (for user-uploaded models) ===
// These entries serve as examples for custom model support
'custom-sd-model.safetensors': {
priority: 3,
variant: 'custom-sd',
modelFamily: 'SD1',
},
'custom-sd-refiner.safetensors': {
priority: 3,
variant: 'custom-sd',
modelFamily: 'SD1',
},
// ===================================================================
// SD1.5 Model Family Registry (Built-in CLIP/VAE)
// ===================================================================
// === SD1.5 Official Models (Priority 1) ===
'v1-5-pruned-emaonly.safetensors': {
priority: 1,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-fp16.safetensors': {
priority: 1,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned.safetensors': {
priority: 1,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly.ckpt': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
// === SD1.5 Quantized Models (Priority 2) ===
'v1-5-pruned-emaonly-F16.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q8_0.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q6_K.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q5_K_M.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q5_K_S.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q4_K_M.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q4_K_S.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q4_0.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q3_K_M.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q3_K_S.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'v1-5-pruned-emaonly-Q2_K.gguf': {
priority: 2,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
// === SD1.5 Community Models (Priority 3) ===
'dreamshaper_8.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'DreamShaper_8_pruned.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'Deliberate_v2.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'Deliberate_v6.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'Realistic_Vision_V5.1.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'Realistic_Vision_V5.1_fp16-no-ema.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'realisticVisionV60B1_v60B1VAE.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'Chilloutmix.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'chilloutmix-Ni.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'chilloutmix_NiPrunedFp16Fix.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'chilloutmix_NiPrunedFp32Fix.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'braV7.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'guofeng3_v34.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'koreanDollLikeness_v20.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'AnythingV5Ink_ink.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'neverendingDream_v122.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'majestixMix_v70.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'kissMix2_v20.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'xxmix9realistic_v40.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'tangYuan_v50.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'flat2DAnimerge_v45Sharp.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'cyberrealistic_v33.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
'analog-diffusion-1.0.safetensors': {
priority: 3,
variant: 'sd15-t2i',
modelFamily: 'SD1',
},
// ===================================================================
// SDXL Model Family Registry (Built-in CLIP/VAE)
// ===================================================================
// === SDXL Text-to-Image Models (Priority 1) ===
'sd_xl_base_1.0.safetensors': {
priority: 1,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_turbo_1.0_fp16.safetensors': {
priority: 1,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0_0.9vae.safetensors': {
priority: 1,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
// === SDXL Image-to-Image Models (Refiner) ===
'sd_xl_refiner_1.0.safetensors': {
priority: 1,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
// === SDXL Quantized Models (Priority 2) ===
'sd_xl_base_1.0-F16.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q8_0.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q6_K.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q5_K_M.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q5_K_S.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q4_K_M.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q4_K_S.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q4_0.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q3_K_M.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q3_K_S.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sd_xl_base_1.0-Q2_K.gguf': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
// === SDXL Refiner Quantized Models ===
'sd_xl_refiner_1.0-F16.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q8_0.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q6_K.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q5_K_M.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q5_K_S.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q4_K_M.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q4_K_S.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q4_0.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q3_K_M.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q3_K_S.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
'sd_xl_refiner_1.0-Q2_K.gguf': {
priority: 2,
variant: 'sdxl-i2i',
modelFamily: 'SDXL',
},
// === SDXL Enterprise Models (Priority 2) ===
'SSD-1B.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'SSD-1B-modelspec.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sdxl_lightning_1step.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sdxl_lightning_4step.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sdxl_lightning_8step.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'diffusion_pytorch_model.fp16.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'lcm_lora_sdxl.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
// === SDXL Community Models (Priority 3) ===
'juggernautXL_v9Rdphoto2Lightning.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'realvisxlV50_v50Bakedvae.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'dreamshaperXL_v21TurboDPMSDE.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'ponyDiffusionV6XL_v6StartWithThisOne.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'novaAnimeXL_il.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'nebulaeAnimeStyleSDXL_v20.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'counterfeitxl_v25.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'animagineXLV31_v31.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'bluepencilXL_v100.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'sudachi_v10.safetensors': {
priority: 3,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
// === Playground Models (Based on SDXL Architecture) ===
'playground-v2.5-1024px-aesthetic.fp16.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'playground-v2.5-1024px-aesthetic.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'playground-v2-1024px-aesthetic.fp16.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
'playground-v2-1024px-aesthetic.safetensors': {
priority: 2,
variant: 'sdxl-t2i',
modelFamily: 'SDXL',
},
};
/* eslint-enable sort-keys-fix/sort-keys-fix */

View file

@ -0,0 +1,385 @@
/**
* System Components Registry Configuration
*/
import { ConfigError } from '@/server/services/comfyui/errors';
export interface ComponentConfig {
/** Compatible model variants (for LoRA and ControlNet) */
compatibleVariants?: string[];
/** ControlNet type (for ControlNet components only) */
controlnetType?: string;
/** Model family this component is designed for */
modelFamily: string;
/** Priority level: 1=Essential/Official, 2=Standard/Professional, 3=Optional/Community */
priority: number;
/** Component type */
type: string;
}
// Model family constants (for business logic reference)
export const MODEL_FAMILIES = {
FLUX: 'FLUX',
SD1: 'SD1',
SD3: 'SD3',
SDXL: 'SDXL',
} as const;
// Component type constants (for business logic reference)
export const COMPONENT_TYPES = {
CLIP: 'clip',
CONTROLNET: 'controlnet',
LORA: 'lora',
T5: 't5',
VAE: 'vae',
} as const;
// ControlNet type constants
export const CONTROLNET_TYPES = {
CANNY: 'canny',
DEPTH: 'depth',
HED: 'hed',
NORMAL: 'normal',
POSE: 'pose',
SCRIBBLE: 'scribble',
SEMANTIC: 'semantic',
} as const;
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const SYSTEM_COMPONENTS: Record<string, ComponentConfig> = {
// ===================================================================
// === ESSENTIAL COMPONENTS (Priority 1) ===
// ===================================================================
'ae.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 'vae',
},
'clip_l.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 'clip',
},
'clip_g.safetensors': {
modelFamily: 'SD3',
priority: 1,
type: 'clip',
},
't5xxl_fp16.safetensors': {
modelFamily: 'FLUX',
priority: 1,
type: 't5',
},
// ===================================================================
// === OPTIONAL COMPONENTS (Priority 2-3) ===
// ===================================================================
't5xxl_fp8_e4m3fn.safetensors': {
modelFamily: 'FLUX',
priority: 2,
type: 't5',
},
't5xxl_fp8_e4m3fn_scaled.safetensors': {
modelFamily: 'FLUX',
priority: 2,
type: 't5',
},
't5xxl_fp8_e5m2.safetensors': {
modelFamily: 'FLUX',
priority: 2,
type: 't5',
},
'google_t5-v1_1-xxl_encoderonly-fp16.safetensors': {
modelFamily: 'FLUX',
priority: 3,
type: 't5',
},
// ===================================================================
// === VAE MODELS ===
// ===================================================================
// SD1 VAE Models
'vae-ft-mse-840000-ema-pruned.safetensors': {
modelFamily: 'SD1',
priority: 1,
type: 'vae',
},
'sd-vae-ft-ema.safetensors': {
modelFamily: 'SD1',
priority: 1,
type: 'vae',
},
// SDXL VAE Models
'sdxl_vae.safetensors': {
modelFamily: 'SDXL',
priority: 1,
type: 'vae',
},
'sdxl.vae.safetensors': {
modelFamily: 'SDXL',
priority: 1,
type: 'vae',
},
'sd_xl_base_1.0_0.9vae.safetensors': {
modelFamily: 'SDXL',
priority: 2,
type: 'vae',
},
// ===================================================================
// === LORA ADAPTERS ===
// ===================================================================
// XLabs-AI Official FLUX LoRA Adapters (Priority 1 - Official)
'realism_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'anime_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'disney_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'scenery_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'art_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'mjv6_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'flux-realism-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'flux-lora-collection-8-styles.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
'disney-anime-art-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 1,
type: 'lora',
},
// LiblibAI Professional LoRA (Priority 2 - Professional)
'flux-kodak-grain-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
'flux-first-person-selfie-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
'flux-anime-rainbow-light-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
'flux-detailer-enhancement-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
// CivitAI Special Effects LoRA (Priority 2 - Professional)
'Envy_Flux_Reanimated_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
'Photon_Construct_Flux_V1_0_lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
// ModelScope LoRA Collection (Priority 2 - Professional)
'flux-ultimate-lora-collection.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
'artaug-flux-enhancement-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
'flux-canny-dev-lora.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 2,
type: 'lora',
},
// Community and Experimental LoRA (Priority 3 - Community)
'watercolor_painting_schnell_lora.safetensors': {
compatibleVariants: ['schnell'],
modelFamily: 'FLUX',
priority: 3,
type: 'lora',
},
'juggernaut_lora_flux.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 3,
type: 'lora',
},
'chinese-style-flux-lora-collection.safetensors': {
compatibleVariants: ['dev'],
modelFamily: 'FLUX',
priority: 3,
type: 'lora',
},
'flux-medical-environment-lora.safetensors': {
compatibleVariants: ['kontext'],
modelFamily: 'FLUX',
priority: 3,
type: 'lora',
},
'flux-fill-object-removal.safetensors': {
compatibleVariants: ['kontext'],
modelFamily: 'FLUX',
priority: 3,
type: 'lora',
},
// ===================================================================
// === CONTROLNET MODELS ===
// ===================================================================
// XLabs-AI Official FLUX ControlNet Models (Priority 1 - Official)
'flux-controlnet-canny-v3.safetensors': {
compatibleVariants: ['dev'],
controlnetType: 'canny',
modelFamily: 'FLUX',
priority: 1,
type: 'controlnet',
},
'flux-controlnet-depth-v3.safetensors': {
compatibleVariants: ['dev'],
controlnetType: 'depth',
modelFamily: 'FLUX',
priority: 1,
type: 'controlnet',
},
'flux-controlnet-hed-v3.safetensors': {
compatibleVariants: ['dev'],
controlnetType: 'hed',
modelFamily: 'FLUX',
priority: 1,
type: 'controlnet',
},
} as const;
/* eslint-enable sort-keys-fix/sort-keys-fix */
/**
* Get all components with names matching filters
*/
export function getAllComponentsWithNames(options?: {
compatibleVariant?: string;
controlnetType?: ComponentConfig['controlnetType'];
modelFamily?: ComponentConfig['modelFamily'];
priority?: number;
type?: ComponentConfig['type'];
}): Array<{ config: ComponentConfig; name: string }> {
return Object.entries(SYSTEM_COMPONENTS)
.filter(
([, config]) =>
(!options?.type || config.type === options.type) &&
(!options?.priority || config.priority === options.priority) &&
(!options?.modelFamily || config.modelFamily === options.modelFamily) &&
(!options?.compatibleVariant ||
(config.compatibleVariants &&
config.compatibleVariants.includes(options.compatibleVariant))) &&
(!options?.controlnetType || config.controlnetType === options.controlnetType),
)
.map(([name, config]) => ({ config, name }));
}
/**
* Get optimal component of specified type
*/
export function getOptimalComponent(
type: ComponentConfig['type'],
modelFamily: ComponentConfig['modelFamily'],
): string {
const components = getAllComponentsWithNames({ modelFamily, type }).sort(
(a, b) => a.config.priority - b.config.priority,
);
if (components.length === 0) {
throw new ConfigError(
`No ${type} components configured for model family ${modelFamily}`,
ConfigError.Reasons.MISSING_CONFIG,
{ modelFamily, type },
);
}
return components[0].name;
}

View file

@ -0,0 +1,70 @@
import type { PromptBuilder } from '@saintno/comfyui-sdk';
import type { WorkflowContext } from '@/server/services/comfyui/core/workflowBuilderService';
// Import all workflow builders
import { buildFluxDevWorkflow } from '@/server/services/comfyui/workflows/flux-dev';
import { buildFluxKontextWorkflow } from '@/server/services/comfyui/workflows/flux-kontext';
import { buildFluxSchnellWorkflow } from '@/server/services/comfyui/workflows/flux-schnell';
import { buildSD35Workflow } from '@/server/services/comfyui/workflows/sd35';
import { buildSimpleSDWorkflow } from '@/server/services/comfyui/workflows/simple-sd';
// Workflow builder type
type WorkflowBuilder = (
modelFileName: string,
params: Record<string, any>,
context: WorkflowContext,
) => Promise<PromptBuilder<any, any, any>>;
/**
* Variant to Workflow mapping
* Based on actual model registry variant values
*/
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const VARIANT_WORKFLOW_MAP: Record<string, WorkflowBuilder> = {
// FLUX variants
'dev': buildFluxDevWorkflow,
'schnell': buildFluxSchnellWorkflow,
'kontext': buildFluxKontextWorkflow,
'krea': buildFluxDevWorkflow,
// SD3 variants
'sd35': buildSD35Workflow, // needs external encoders
'sd35-inclclip': buildSimpleSDWorkflow, // built-in encoders
// SD1/SDXL variants
'sd15-t2i': buildSimpleSDWorkflow,
'sdxl-t2i': buildSimpleSDWorkflow,
'sdxl-i2i': buildSimpleSDWorkflow,
'custom-sd': buildSimpleSDWorkflow,
};
/**
* Architecture default workflows (when variant not matched)
*/
export const ARCHITECTURE_DEFAULT_MAP: Record<string, WorkflowBuilder> = {
FLUX: buildFluxDevWorkflow,
SD3: buildSD35Workflow,
SD1: buildSimpleSDWorkflow,
SDXL: buildSimpleSDWorkflow,
};
/* eslint-enable sort-keys-fix/sort-keys-fix */
/**
* Get the appropriate workflow builder for a given architecture and variant
*
* @param architecture - Model architecture (FLUX, SD3, SD1, SDXL)
* @param variant - Model variant (dev, schnell, kontext, sd35, etc.)
* @returns Workflow builder function or undefined if not found
*/
export function getWorkflowBuilder(
architecture: string,
variant?: string,
): WorkflowBuilder | undefined {
// Prefer variant mapping
if (variant && VARIANT_WORKFLOW_MAP[variant]) {
return VARIANT_WORKFLOW_MAP[variant];
}
// Fallback to architecture default
return ARCHITECTURE_DEFAULT_MAP[architecture];
}

View file

@ -0,0 +1,145 @@
/**
* ComfyUI Authentication Service
*
* Handles all authentication-related logic for ComfyUI connections
* Supports 4 authentication modes: none, basic, bearer, custom
*/
import type { ComfyUIKeyVault } from '@lobechat/types';
import { createBasicAuthCredentials } from '@lobechat/utils';
import type {
BasicCredentials,
BearerTokenCredentials,
CustomCredentials,
} from '@saintno/comfyui-sdk';
import debug from 'debug';
import { ServicesError } from '@/server/services/comfyui/errors';
const log = debug('lobe-image:comfyui:auth');
export class ComfyUIAuthService {
private credentials: BasicCredentials | BearerTokenCredentials | CustomCredentials | undefined;
private authHeaders: Record<string, string> | undefined;
constructor(options: ComfyUIKeyVault) {
log('🔐 Initializing authentication service');
this.validateOptions(options);
this.credentials = this.createCredentials(options);
this.authHeaders = this.createAuthHeaders(options);
log('✅ Authentication service initialized with type:', options.authType || 'none');
}
/**
* Get credentials for ComfyUI SDK
*/
getCredentials(): BasicCredentials | BearerTokenCredentials | CustomCredentials | undefined {
return this.credentials;
}
/**
* Get authentication headers for HTTP requests
*/
getAuthHeaders(): Record<string, string> | undefined {
return this.authHeaders;
}
/**
* Validate authentication options
*/
private validateOptions(options: ComfyUIKeyVault): void {
const { authType = 'none', apiKey, username, password, customHeaders } = options;
if (authType === 'basic' && (!username || !password)) {
throw new ServicesError(
'Basic authentication requires username and password',
ServicesError.Reasons.INVALID_ARGS,
{ authType },
);
}
if (authType === 'bearer' && !apiKey) {
throw new ServicesError(
'Bearer token authentication requires API key',
ServicesError.Reasons.INVALID_AUTH,
{ authType },
);
}
if (authType === 'custom' && (!customHeaders || Object.keys(customHeaders).length === 0)) {
throw new ServicesError(
'Custom authentication requires custom headers',
ServicesError.Reasons.INVALID_ARGS,
{ authType },
);
}
}
/**
* Create credentials object for ComfyUI SDK
*/
private createCredentials(
options: ComfyUIKeyVault,
): BasicCredentials | BearerTokenCredentials | CustomCredentials | undefined {
const { authType = 'none', apiKey, username, password, customHeaders } = options;
switch (authType) {
case 'basic': {
return {
password: password!,
type: 'basic',
username: username!,
} as BasicCredentials;
}
case 'bearer': {
return {
token: apiKey!,
type: 'bearer_token',
} as BearerTokenCredentials;
}
case 'custom': {
return {
headers: customHeaders!,
type: 'custom',
} as CustomCredentials;
}
default: {
return undefined;
}
}
}
/**
* Create authentication headers for direct HTTP requests
*/
private createAuthHeaders(options: ComfyUIKeyVault): Record<string, string> | undefined {
const { authType = 'none', apiKey, username, password, customHeaders } = options;
switch (authType) {
case 'basic': {
if (username && password) {
const basicAuth = createBasicAuthCredentials(username, password);
return { Authorization: `Basic ${basicAuth}` };
}
break;
}
case 'bearer': {
if (apiKey) {
return { Authorization: `Bearer ${apiKey}` };
}
break;
}
case 'custom': {
return customHeaders;
}
}
return undefined;
}
}

View file

@ -0,0 +1,249 @@
/**
* ComfyUI Client Service
*
* Central service layer for all ComfyUI SDK interactions
* Provides unified error handling and abstraction over SDK
* Uses modular services for authentication, connection, and caching
*/
import type { ComfyUIKeyVault } from '@lobechat/types';
import { CallWrapper, ComfyApi, PromptBuilder } from '@saintno/comfyui-sdk';
import debug from 'debug';
import { COMFYUI_DEFAULTS } from '@/server/services/comfyui/config/constants';
import { ComfyUIAuthService } from '@/server/services/comfyui/core/comfyUIAuthService';
import { ComfyUIConnectionService } from '@/server/services/comfyui/core/comfyUIConnectionService';
import { ErrorHandlerService } from '@/server/services/comfyui/core/errorHandlerService';
import { ServicesError } from '@/server/services/comfyui/errors';
import { TTLCacheManager } from '@/server/services/comfyui/utils/cacheManager';
const log = debug('lobe-image:comfyui:client');
/**
* Workflow execution result
*/
export interface WorkflowResult {
// Raw output data from workflow execution, keyed by node ID
_raw?: Record<string, any>;
images?: {
images?: Array<{
data: string;
height?: number;
mimeType: string;
width?: number;
}>;
};
}
/**
* Progress callback type
*/
export type ProgressCallback = (info: any) => void;
/**
* ComfyUI Client Service
* Encapsulates all SDK interactions using modular services
*/
export class ComfyUIClientService {
private client: ComfyApi;
private baseURL: string;
// Modular services for separation of concerns
private cacheManager: TTLCacheManager;
private authService: ComfyUIAuthService;
private connectionService: ComfyUIConnectionService;
private errorHandler: ErrorHandlerService;
constructor(options: ComfyUIKeyVault = {}) {
log('🏗️ Initializing ComfyUI Client Service');
this.errorHandler = new ErrorHandlerService();
try {
// Initialize modular services
this.authService = new ComfyUIAuthService(options);
this.cacheManager = new TTLCacheManager(60_000); // 1 minute TTL
this.connectionService = new ComfyUIConnectionService();
// Setup base URL
this.baseURL =
options.baseURL || process.env.COMFYUI_DEFAULT_URL || COMFYUI_DEFAULTS.BASE_URL;
// Initialize client with credentials from AuthService
this.client = new ComfyApi(this.baseURL, undefined, {
credentials: this.authService.getCredentials(),
});
this.client.init();
log('✅ ComfyUI Client Service initialized with baseURL:', this.baseURL);
} catch (error) {
// Use ErrorHandlerService to transform internal errors to framework errors
this.errorHandler.handleError(error);
}
}
/**
* Get authentication headers for image download
* This method provides auth headers to framework layer without exposing credentials
* @returns Authentication headers object, or undefined if no auth is configured
*/
getAuthHeaders(): Record<string, string> | undefined {
// Delegate to AuthService
return this.authService.getAuthHeaders();
}
/**
* Get the path for an image result
*/
getPathImage(imageInfo: any): string {
return this.client.getPathImage(imageInfo);
}
/**
* Upload an image to ComfyUI server
* @param file - The image data as Buffer or Blob
* @param fileName - The name for the uploaded file
* @returns The filename on ComfyUI server
*/
async uploadImage(file: Buffer | Blob, fileName: string): Promise<string> {
log('📤 Uploading image to ComfyUI:', fileName);
const result = await this.client.uploadImage(file, fileName);
if (!result) {
throw new ServicesError(
'Failed to upload image to ComfyUI server',
ServicesError.Reasons.UPLOAD_FAILED,
{ fileName, response: result },
);
}
log('✅ Image uploaded successfully:', result.info.filename);
return result.info.filename;
}
/**
* Execute a workflow
*/
async executeWorkflow(
workflow: PromptBuilder<any, any, any>,
onProgress?: ProgressCallback,
): Promise<WorkflowResult> {
log('🚀 Executing workflow...');
return new Promise<WorkflowResult>((resolve, reject) => {
new CallWrapper(this.client, workflow)
.onFinished((result: any) => {
log('✅ Workflow execution finished successfully');
log('🔍 Raw workflow result structure:', {
hasImages: 'images' in result,
hasRaw: '_raw' in result,
keys: Object.keys(result),
rawKeys: result._raw ? Object.keys(result._raw) : null,
});
resolve(result);
})
.onFailed((error: any) => {
log('❌ Workflow execution failed:', error?.message || error);
reject(error);
})
.onProgress((info: any) => {
log('⏳ Progress:', info);
onProgress?.(info);
})
.run();
});
}
/**
* Get available checkpoints from ComfyUI
* Wraps SDK method to avoid Law of Demeter violation
* Uses unified TTL cache for performance optimization
*/
async getCheckpoints(): Promise<string[]> {
return await this.cacheManager.get('checkpoints', async () => {
return await this.client.getCheckpoints();
});
}
/**
* Get available LoRAs from ComfyUI
* Wraps SDK method to avoid Law of Demeter violation
* Uses unified TTL cache for performance optimization
*/
async getLoras(): Promise<string[]> {
return await this.cacheManager.get('loras', async () => {
return await this.client.getLoras();
});
}
/**
* Get node definitions from ComfyUI
* Wraps SDK method to avoid Law of Demeter violation
* Uses unified TTL cache for performance optimization
* @param nodeName - Optional specific node name to query
*/
async getNodeDefs(nodeName?: string): Promise<any> {
const allNodeDefs = await this.cacheManager.get('nodeDefs', async () => {
return await this.client.getNodeDefs();
});
// Return specific node or all nodes
return nodeName && allNodeDefs ? { [nodeName]: allNodeDefs[nodeName] } : allNodeDefs;
}
/**
* Get sampler info from ComfyUI
* Wraps SDK method to avoid Law of Demeter violation
*/
async getSamplerInfo(): Promise<{ samplerName: string[]; scheduler: string[] }> {
const info = await this.client.getSamplerInfo();
return {
samplerName: this.extractStrings(info.sampler),
scheduler: this.extractStrings(info.scheduler),
};
}
/**
* Extract string values from sampler info arrays
* Handle both string arrays and tuple arrays like ['euler', { tooltip: 'info' }]
*/
private extractStrings(arr: any): string[] {
if (!Array.isArray(arr)) return [];
return arr
.map((item) => (Array.isArray(item) ? item[0] : item))
.filter((item) => typeof item === 'string');
}
/**
* Validate connection to ComfyUI server
* Delegates to ConnectionService for connection management
*/
async validateConnection(): Promise<boolean> {
return await this.connectionService.validateConnection(
this.baseURL,
this.authService.getAuthHeaders(),
);
}
/**
* Get connection status information
*/
getConnectionStatus() {
return this.connectionService.getStatus();
}
/**
* Get authentication service instance (for advanced usage)
*/
getAuthService(): ComfyUIAuthService {
return this.authService;
}
/**
* Get connection service instance (for advanced usage)
*/
getConnectionService(): ComfyUIConnectionService {
return this.connectionService;
}
}

View file

@ -0,0 +1,136 @@
/**
* ComfyUI Connection Service
*
* Handles connection validation and state management for ComfyUI server
* Provides TTL-based connection validation caching
*/
import debug from 'debug';
import { ServicesError } from '@/server/services/comfyui/errors';
const log = debug('lobe-image:comfyui:connection');
export class ComfyUIConnectionService {
private validated: boolean = false;
private lastValidationTime: number = 0;
private readonly validationTTL = 5 * 60 * 1000; // 5 minutes
constructor() {
log('🔗 Initializing connection service');
}
/**
* Check if connection is validated and not expired
*/
isValidated(): boolean {
if (!this.validated) return false;
const now = Date.now();
if (now - this.lastValidationTime > this.validationTTL) {
this.validated = false;
return false;
}
return true;
}
/**
* Mark connection as validated
*/
markAsValidated(): void {
this.validated = true;
this.lastValidationTime = Date.now();
log('✅ Connection marked as validated');
}
/**
* Invalidate connection (force re-validation)
*/
invalidate(): void {
this.validated = false;
log('❌ Connection invalidated, will require re-validation');
}
/**
* Validate connection to ComfyUI server
* Uses system_stats endpoint for health check
*/
async validateConnection(
baseURL: string,
authHeaders?: Record<string, string>,
): Promise<boolean> {
// Check if already validated and not expired
if (this.isValidated()) {
return true;
}
try {
// Use system_stats endpoint for health check
// This is a lightweight endpoint that returns system information
const url = `${baseURL}/system_stats`;
const headers = authHeaders || {};
log('🔍 Validating connection to:', url);
const response = await fetch(url, {
headers: {
...headers,
'Content-Type': 'application/json',
},
method: 'GET',
mode: 'cors',
});
// Just check if we got a successful response
if (!response.ok) {
this.invalidate();
// Throw ServicesError with status for error parser to handle
throw new ServicesError(
`HTTP ${response.status}: ${response.statusText}`,
ServicesError.Reasons.CONNECTION_FAILED,
{ endpoint: '/system_stats', status: response.status, statusText: response.statusText },
);
}
// Verify response is valid JSON
const data = await response.json();
if (!data || typeof data !== 'object') {
throw new ServicesError(
'Invalid response from ComfyUI server',
ServicesError.Reasons.CONNECTION_FAILED,
{ endpoint: '/system_stats' },
);
}
this.markAsValidated();
log('✅ Connection validated successfully');
return true;
} catch (error) {
// Reset connection state on any error
this.invalidate();
// Re-throw all errors - let the service layer handle error classification
throw error;
}
}
/**
* Get connection status information
*/
getStatus(): {
isValidated: boolean;
lastValidationTime: number | null;
timeUntilExpiry: number | null;
} {
const now = Date.now();
const timeUntilExpiry = this.validated
? Math.max(0, this.validationTTL - (now - this.lastValidationTime))
: null;
return {
isValidated: this.validated,
lastValidationTime: this.validated ? this.lastValidationTime : null,
timeUntilExpiry,
};
}
}

View file

@ -0,0 +1,538 @@
/**
* Error Handler Service
*
* Centralized error handling for ComfyUI runtime
* Maps internal errors to framework errors
*/
import {
AgentRuntimeError,
AgentRuntimeErrorType,
ILobeAgentRuntimeErrorType,
} from '@lobechat/model-runtime';
import { TRPCError } from '@trpc/server';
import { SYSTEM_COMPONENTS } from '@/server/services/comfyui/config/systemComponents';
import {
ConfigError,
ServicesError,
UtilsError,
WorkflowError,
isComfyUIInternalError,
} from '@/server/services/comfyui/errors';
import { ModelResolverError } from '@/server/services/comfyui/errors/modelResolverError';
import { getComponentInfo } from '@/server/services/comfyui/utils/componentInfo';
interface ComfyUIError {
code?: number | string;
details?: any;
message: string;
missingFileName?: string;
missingFileType?: 'model' | 'component';
status?: number;
type?: string;
userGuidance?: string;
}
interface ParsedError {
error: ComfyUIError;
errorType: ILobeAgentRuntimeErrorType;
}
/**
* Generate user guidance message based on missing file info
* Server-side version with access to full component information
* @param fileName - The missing file name
* @param fileType - The type of missing file
* @returns User-friendly guidance message
*/
function generateUserGuidance(fileName: string, fileType: 'model' | 'component'): string {
if (fileType === 'component') {
const componentInfo = getComponentInfo(fileName);
if (componentInfo) {
return `Missing ${componentInfo.displayName}: ${fileName}. Please download and place it in the ${componentInfo.folderPath} folder.`;
}
// Fallback for unknown components
return `Missing component file: ${fileName}. Please download and place it in the appropriate ComfyUI models folder.`;
}
// Main model files
return `Missing model file: ${fileName}. Please download and place it in the models/checkpoints folder.`;
}
/**
* Extract missing file information from error message
* Server-side version with access to SYSTEM_COMPONENTS
* @param message - Error message that may contain file names
* @returns Object with extracted file name and type, or null if no file found
*/
function extractMissingFileInfo(message: string): {
fileName: string;
fileType: 'model' | 'component';
} | null {
if (!message) return null;
// Check for "Expected one of:" pattern from enhanced model errors
const expectedPattern = /expected one of:\s*([^.]+\.(?:safetensors|ckpt|pt|pth))/i;
const expectedMatch = message.match(expectedPattern);
if (expectedMatch) {
// Extract the first file from the match
const fileName = expectedMatch[1].trim().split(',')[0].trim();
if (fileName) {
return {
fileName,
fileType: 'model',
};
}
}
// Common model file extensions - allow dots in filename
const modelFilePattern = /([\w.-]+\.(?:safetensors|ckpt|pt|pth))\b/gi;
const fileMatch = message.match(modelFilePattern);
if (fileMatch) {
const fileName = fileMatch[0];
// Use server-side SYSTEM_COMPONENTS to check if it's a system component
if (fileName in SYSTEM_COMPONENTS) {
return {
fileName,
fileType: 'component',
};
}
// If not found in SYSTEM_COMPONENTS, treat as main model
return {
fileName,
fileType: 'model',
};
}
return null;
}
/**
* Check if the error is a model-related error
* @param error - Error object
* @param message - Pre-extracted message
* @returns Whether it's a model error
*/
function isModelError(error: any, message?: string): boolean {
const errorMessage = message || error?.message || String(error);
const lowerMessage = errorMessage.toLowerCase();
// Check for explicit model error patterns
const hasModelErrorPattern =
lowerMessage.includes('model not found') ||
lowerMessage.includes('checkpoint not found') ||
lowerMessage.includes('model file not found') ||
lowerMessage.includes('ckpt_name') ||
lowerMessage.includes('no models available') ||
lowerMessage.includes('safetensors') ||
lowerMessage.includes('.ckpt') ||
lowerMessage.includes('.pt') ||
lowerMessage.includes('.pth') ||
error?.code === 'MODEL_NOT_FOUND';
// Also check if the error contains a model file that's missing
if (!hasModelErrorPattern) {
const fileInfo = extractMissingFileInfo(errorMessage);
return fileInfo !== null; // Any missing model file is considered a model error
}
return hasModelErrorPattern;
}
/**
* Check if the error is a ComfyUI SDK custom error
* @param error - Error object
* @returns Whether it's a SDK custom error
*/
function isSDKCustomError(error: any): boolean {
if (!error) return false;
// Check for SDK error class names
const errorName = error?.name || error?.constructor?.name || '';
const sdkErrorTypes = [
// Base error class
'CallWrapperError',
// Actual SDK error classes from comfyui-sdk
'WentMissingError',
'FailedCacheError',
'EnqueueFailedError',
'DisconnectedError',
'ExecutionFailedError',
'CustomEventError',
'ExecutionInterruptedError',
'MissingNodeError',
];
if (sdkErrorTypes.includes(errorName)) {
return true;
}
// Check for SDK error messages patterns
const message = error?.message || String(error);
const lowerMessage = message.toLowerCase();
return (
lowerMessage.includes('sdk error:') ||
lowerMessage.includes('call wrapper') ||
lowerMessage.includes('execution interrupted') ||
lowerMessage.includes('missing node type') ||
lowerMessage.includes('invalid model configuration') ||
lowerMessage.includes('workflow validation failed') ||
lowerMessage.includes('sdk timeout') ||
lowerMessage.includes('sdk configuration error')
);
}
/**
* Check if the error is a network connection error (including WebSocket)
* @param error - Error object
* @param message - Pre-extracted message
* @param code - Pre-extracted code
* @returns Whether it's a network connection error
*/
function isNetworkError(error: any, message?: string, code?: string | number): boolean {
const errorMessage = message || error?.message || String(error);
const lowerMessage = errorMessage.toLowerCase();
const errorCode = code || error?.code;
return (
// Basic network errors
errorMessage === 'fetch failed' ||
lowerMessage.includes('econnrefused') ||
lowerMessage.includes('enotfound') ||
lowerMessage.includes('etimedout') ||
lowerMessage.includes('network error') ||
lowerMessage.includes('connection refused') ||
lowerMessage.includes('connection timeout') ||
errorCode === 'ECONNREFUSED' ||
errorCode === 'ENOTFOUND' ||
errorCode === 'ETIMEDOUT' ||
// WebSocket specific errors
lowerMessage.includes('websocket') ||
lowerMessage.includes('ws connection') ||
lowerMessage.includes('connection lost to comfyui server') ||
errorCode === 'WS_CONNECTION_FAILED' ||
errorCode === 'WS_TIMEOUT' ||
errorCode === 'WS_HANDSHAKE_FAILED'
);
}
/**
* Check if the error is a ComfyUI workflow error
* @param error - Error object
* @param message - Pre-extracted message
* @returns Whether it's a workflow error
*/
function isWorkflowError(error: any, message?: string): boolean {
const errorMessage = message || error?.message || String(error);
const lowerMessage = errorMessage.toLowerCase();
// Check for structured workflow error fields
if (
error &&
typeof error === 'object' &&
(error.node_id || error.nodeId || error.node_type || error.nodeType)
) {
return true;
}
return (
lowerMessage.includes('node') ||
lowerMessage.includes('workflow') ||
lowerMessage.includes('execution') ||
lowerMessage.includes('prompt') ||
lowerMessage.includes('queue') ||
lowerMessage.includes('invalid input') ||
lowerMessage.includes('missing required') ||
lowerMessage.includes('node execution failed') ||
lowerMessage.includes('workflow validation') ||
error?.type === 'workflow_error'
);
}
/**
* Simple ComfyUI error parser
* Extracts error information and determines error type
*/
function parseComfyUIErrorMessage(error: any): ParsedError {
// Default error info
let message = 'Unknown error';
let status: number | undefined;
let code: string | undefined;
let missingFileName: string | undefined;
let missingFileType: 'model' | 'component' | undefined;
let userGuidance: string | undefined;
let errorType: ILobeAgentRuntimeErrorType = AgentRuntimeErrorType.ComfyUIBizError;
// Check for JSON parsing errors (indicates non-ComfyUI service)
if (
error instanceof SyntaxError ||
(error && typeof error === 'object' && error.name === 'SyntaxError')
) {
const syntaxMessage = error?.message || String(error);
if (syntaxMessage.includes('JSON') || syntaxMessage.includes('Unexpected token')) {
// JSON parsing failed - service is not ComfyUI
return {
error: {
message: 'Service is not ComfyUI - received non-JSON response',
type: 'SyntaxError',
userGuidance:
'The service at this URL is not a ComfyUI server. Please check your baseURL configuration.',
},
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey, // Trigger auth dialog
};
}
}
// Extract message
if (typeof error === 'string') {
message = error;
} else if (error instanceof Error) {
message = error.message;
code = (error as any).code;
} else if (error && typeof error === 'object') {
// Extract message from various possible sources (matching original logic)
const possibleMessage = [
error.exception_message, // ComfyUI specific field (highest priority)
error.error?.exception_message, // Nested ComfyUI exception message
error.error?.error, // Deeply nested error.error.error path
error.message,
error.error?.message,
error.data?.message,
error.body?.message,
error.response?.data?.message,
error.response?.data?.error?.message,
error.response?.text,
error.response?.body,
error.statusText,
].find(Boolean);
// Use the message or fallback to a generic error
if (!possibleMessage) {
message = 'Unknown error occurred';
} else {
message = possibleMessage;
}
// Extract status code from various possible locations
const possibleStatus = [
error.status,
error.statusCode,
error.details?.status, // ServicesError puts status in details
error.response?.status,
error.response?.statusCode,
error.error?.status,
error.error?.statusCode,
].find(Number.isInteger);
status = possibleStatus;
code = error.code || error.error?.code || error.response?.data?.code;
}
// Extract missing file information and generate guidance
const fileInfo = extractMissingFileInfo(message);
if (fileInfo) {
missingFileName = fileInfo.fileName;
missingFileType = fileInfo.fileType;
userGuidance = generateUserGuidance(fileInfo.fileName, fileInfo.fileType);
}
// Determine error type based on status code
if (status) {
switch (status) {
case 400:
case 401:
case 404: {
errorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
break;
}
case 403: {
errorType = AgentRuntimeErrorType.PermissionDenied;
break;
}
default: {
if (status >= 500) {
errorType = AgentRuntimeErrorType.ComfyUIServiceUnavailable;
}
}
}
}
// Check for more specific error types only if it's still a generic ComfyUIBizError
if (errorType === AgentRuntimeErrorType.ComfyUIBizError) {
if (isSDKCustomError(error)) {
// SDK errors remain as ComfyUIBizError
errorType = AgentRuntimeErrorType.ComfyUIBizError;
} else if (isNetworkError(error, message, code)) {
errorType = AgentRuntimeErrorType.ComfyUIServiceUnavailable;
} else if (isWorkflowError(error, message)) {
errorType = AgentRuntimeErrorType.ComfyUIWorkflowError;
} else if (isModelError(error, message)) {
errorType = AgentRuntimeErrorType.ModelNotFound;
}
}
const result = {
error: {
code,
message,
missingFileName,
missingFileType,
status,
type: error?.name || error?.type,
userGuidance,
},
errorType,
};
return result;
}
/**
* Error Handler Service
* Provides unified error handling and transformation
*/
export class ErrorHandlerService {
/**
* Handle and transform any error into framework error
* Enhanced to preserve more debugging information while maintaining compatibility
* @param error - The error to handle
* @throws {TRPCError} Always throws a properly formatted error with cause
*/
handleError(error: unknown): never {
// 1. If already a framework error, wrap in TRPCError
if (error && typeof error === 'object' && 'errorType' in error) {
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: 'ComfyUI service error',
});
}
// 2. Handle ComfyUI internal errors - enhance information preservation
if (isComfyUIInternalError(error)) {
const errorType = this.mapInternalErrorToRuntimeError(error);
// Enhanced: preserve more context information
const enhancedError = {
details: error.details || {},
message: error.message,
// Preserve original error type and reason
originalErrorType: error.constructor.name,
originalReason: error.reason,
// Note: Removed originalError to avoid serialization issues
};
const agentError = AgentRuntimeError.createImage({
error: enhancedError,
errorType: errorType as ILobeAgentRuntimeErrorType,
provider: 'comfyui',
});
throw new TRPCError({
cause: agentError,
code: 'INTERNAL_SERVER_ERROR',
message: error.message,
});
}
// 3. Parse other errors - use enhanced parser with more information
const { error: parsedError, errorType } = parseComfyUIErrorMessage(error);
// Enhanced: add more context
const enhancedParsedError = {
...parsedError,
// Add timestamp for debugging
timestamp: new Date().toISOString(),
// Note: Removed originalError to avoid serialization issues
};
const agentError = AgentRuntimeError.createImage({
error: enhancedParsedError,
errorType,
provider: 'comfyui',
});
throw new TRPCError({
cause: agentError,
code: 'INTERNAL_SERVER_ERROR',
message: parsedError.message || 'ComfyUI service error',
});
}
/**
* Map internal ComfyUI errors to runtime error types
*/
private mapInternalErrorToRuntimeError(
error: ConfigError | WorkflowError | UtilsError | ServicesError | ModelResolverError,
): string {
if (error instanceof ConfigError) {
const mapping: Record<string, string> = {
[ConfigError.Reasons.INVALID_CONFIG]: AgentRuntimeErrorType.ComfyUIBizError,
[ConfigError.Reasons.MISSING_CONFIG]: AgentRuntimeErrorType.ComfyUIBizError,
[ConfigError.Reasons.CONFIG_PARSE_ERROR]: AgentRuntimeErrorType.ComfyUIBizError,
[ConfigError.Reasons.REGISTRY_ERROR]: AgentRuntimeErrorType.ComfyUIBizError,
};
return mapping[error.reason] || AgentRuntimeErrorType.ComfyUIBizError;
}
if (error instanceof WorkflowError) {
const mapping: Record<string, string> = {
[WorkflowError.Reasons.INVALID_CONFIG]: AgentRuntimeErrorType.ComfyUIWorkflowError,
[WorkflowError.Reasons.MISSING_COMPONENT]: AgentRuntimeErrorType.ComfyUIModelError,
[WorkflowError.Reasons.MISSING_ENCODER]: AgentRuntimeErrorType.ComfyUIModelError,
[WorkflowError.Reasons.UNSUPPORTED_MODEL]: AgentRuntimeErrorType.ModelNotFound,
[WorkflowError.Reasons.INVALID_PARAMS]: AgentRuntimeErrorType.ComfyUIWorkflowError,
};
return mapping[error.reason] || AgentRuntimeErrorType.ComfyUIWorkflowError;
}
if (error instanceof ServicesError) {
// If error already has parsed errorType in details, use it directly
if (error.details?.errorType) {
return error.details.errorType;
}
// Otherwise use mapping table
const mapping: Record<string, string> = {
[ServicesError.Reasons.INVALID_ARGS]: AgentRuntimeErrorType.InvalidComfyUIArgs,
[ServicesError.Reasons.INVALID_AUTH]: AgentRuntimeErrorType.InvalidProviderAPIKey,
[ServicesError.Reasons.INVALID_CONFIG]: AgentRuntimeErrorType.InvalidComfyUIArgs,
[ServicesError.Reasons.CONNECTION_FAILED]: AgentRuntimeErrorType.InvalidProviderAPIKey, // Trigger auth dialog for connection issues
[ServicesError.Reasons.UPLOAD_FAILED]: AgentRuntimeErrorType.ComfyUIBizError,
[ServicesError.Reasons.EXECUTION_FAILED]: AgentRuntimeErrorType.ComfyUIWorkflowError,
[ServicesError.Reasons.MODEL_NOT_FOUND]: AgentRuntimeErrorType.ModelNotFound,
[ServicesError.Reasons.EMPTY_RESULT]: AgentRuntimeErrorType.ComfyUIBizError,
[ServicesError.Reasons.IMAGE_FETCH_FAILED]: AgentRuntimeErrorType.ComfyUIBizError,
[ServicesError.Reasons.IMAGE_TOO_LARGE]: AgentRuntimeErrorType.ComfyUIBizError,
[ServicesError.Reasons.UNSUPPORTED_PROTOCOL]: AgentRuntimeErrorType.ComfyUIBizError,
[ServicesError.Reasons.MODEL_VALIDATION_FAILED]: AgentRuntimeErrorType.ModelNotFound,
[ServicesError.Reasons.WORKFLOW_BUILD_FAILED]: AgentRuntimeErrorType.ComfyUIWorkflowError,
};
return mapping[error.reason] || AgentRuntimeErrorType.ComfyUIBizError;
}
if (error instanceof UtilsError || error instanceof ModelResolverError) {
const mapping: Record<string, string> = {
CONNECTION_ERROR: AgentRuntimeErrorType.ComfyUIServiceUnavailable,
DETECTION_FAILED: AgentRuntimeErrorType.ComfyUIBizError,
INVALID_API_KEY: AgentRuntimeErrorType.InvalidProviderAPIKey,
INVALID_MODEL_FORMAT: AgentRuntimeErrorType.ComfyUIBizError,
MODEL_NOT_FOUND: AgentRuntimeErrorType.ModelNotFound,
NO_BUILDER_FOUND: AgentRuntimeErrorType.ComfyUIWorkflowError,
PERMISSION_DENIED: AgentRuntimeErrorType.PermissionDenied,
ROUTING_FAILED: AgentRuntimeErrorType.ComfyUIWorkflowError,
SERVICE_UNAVAILABLE: AgentRuntimeErrorType.ComfyUIServiceUnavailable,
};
return mapping[error.reason] || AgentRuntimeErrorType.ComfyUIBizError;
}
return AgentRuntimeErrorType.ComfyUIBizError;
}
}

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