feat: 实现UniComfyUI工作流管理功能

新功能:
- 添加UniComfyUI工作流管理页面,支持工作流列表、搜索和执行
- 实现单次和批量工作流执行功能
- 添加美观的JSON Schema表单,支持文件上传、参数配置
- 集成文件上传到云端功能,自动获取HTTP URL
- 添加实时任务状态监控和进度显示

 技术实现:
- 新增UniComfyUI API层和服务层
- 实现数据库模型和Repository模式
- 添加数据库迁移脚本支持uni_comfyui_task表
- 集成react-hook-form和tailwind-scrollbar
- 实现健壮的日期时间解析,支持多种格式

 修复:
- 修复get_task_status接口参数名不匹配问题
- 修复日期时间解析错误,支持毫秒精度格式
- 修复表单提交流程,正确处理执行状态
- 修复文件上传使用本地路径问题,改为云端URL

 UI优化:
- 现代化的工作流卡片设计
- 美观的表单样式,支持文件拖拽上传
- 响应式布局,自适应滚动
- 清晰的状态指示和错误提示
This commit is contained in:
imeepos
2025-08-20 14:21:05 +08:00
parent 0a79b24838
commit e4eb2ce00f
29 changed files with 6036 additions and 113 deletions

15
Cargo.lock generated
View File

@@ -2822,6 +2822,7 @@ dependencies = [
"tree-sitter",
"tree-sitter-json",
"tvai",
"uni-comfyui-sdk",
"url",
"urlencoding",
"uuid",
@@ -5727,6 +5728,20 @@ dependencies = [
"winapi",
]
[[package]]
name = "uni-comfyui-sdk"
version = "0.1.0"
dependencies = [
"chrono",
"reqwest 0.11.27",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-test",
"url",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"

View File

@@ -21,22 +21,32 @@
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.1",
"@rjsf/antd": "^5.24.12",
"@rjsf/core": "^5.24.12",
"@rjsf/utils": "^5.24.12",
"@rjsf/validator-ajv8": "^5.24.12",
"@tauri-apps/api": "^2.6.0",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-opener": "^2",
"ajv": "^8.17.1",
"ajv-errors": "^3.0.0",
"antd": "^5.27.1",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"marked": "^16.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.62.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.1",
"react-window": "^1.8.11",
"reactflow": "^11.11.4",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-scrollbar": "^4.0.2",
"zustand": "^4.4.7"
},
"devDependencies": {

View File

@@ -60,6 +60,7 @@ bincode = "1.3"
zip = "0.6"
sysinfo = "0.30"
comfyui-sdk = { path = "../../../cargos/comfyui-sdk" }
uni-comfyui-sdk = { path = "../../../cargos/uni-comfyui-sdk" }
tvai = { path = "../../../cargos/tvai" }
veo3-scene-writer = { path = "../../../cargos/veo3-scene-writer" }
ffmpeg-sidecar = "2.0"

View File

@@ -0,0 +1 @@
pub mod uni_comfyui_api;

View File

@@ -0,0 +1,147 @@
use serde_json::Value;
use uuid::Uuid;
use tauri::State;
use crate::app_state::AppState;
use crate::data::models::uni_comfyui::*;
use crate::services::uni_comfyui_service::UniComfyUIService;
/// 获取所有工作流列表
#[tauri::command]
pub async fn get_workflows(state: State<'_, AppState>) -> Result<Vec<WorkflowInfo>, String> {
let pool = {
let database_guard = state.database.lock().map_err(|e| format!("Database lock error: {}", e))?;
let database = database_guard.as_ref().ok_or("Database not initialized")?;
database.get_pool().ok_or("Connection pool not available")?
}; // 锁在这里被释放
// 默认服务器URL可以从配置中获取
let server_url = "http://192.168.0.148:18000".to_string();
let service = UniComfyUIService::new(pool, server_url)
.map_err(|e| format!("Failed to create service: {}", e))?;
service.get_workflows().await
.map_err(|e| format!("Failed to get workflows: {}", e))
}
/// 获取工作流详细规范
#[tauri::command]
pub async fn get_workflow_spec(workflow_name: String, state: State<'_, AppState>) -> Result<WorkflowInfo, String> {
let pool = {
let database_guard = state.database.lock().map_err(|e| format!("Database lock error: {}", e))?;
let database = database_guard.as_ref().ok_or("Database not initialized")?;
database.get_pool().ok_or("Connection pool not available")?
};
let server_url = "http://192.168.0.148:18000".to_string();
let service = UniComfyUIService::new(pool, server_url)
.map_err(|e| format!("Failed to create service: {}", e))?;
service.get_workflow_spec(&workflow_name).await
.map_err(|e| format!("Failed to get workflow spec: {}", e))
}
/// 执行单个工作流
#[tauri::command]
pub async fn execute_uni_workflow(
workflow_name: String,
input_params: Value,
state: State<'_, AppState>
) -> Result<String, String> {
let pool = {
let database_guard = state.database.lock().map_err(|e| format!("Database lock error: {}", e))?;
let database = database_guard.as_ref().ok_or("Database not initialized")?;
database.get_pool().ok_or("Connection pool not available")?
};
let server_url = "http://192.168.0.148:18000".to_string();
let service = UniComfyUIService::new(pool, server_url)
.map_err(|e| format!("Failed to create service: {}", e))?;
service.execute_workflow(&workflow_name, input_params).await
.map_err(|e| format!("Failed to execute workflow: {}", e))
}
/// 生成参数组合
#[tauri::command]
pub async fn generate_parameter_combinations(
config: ParameterCombinationConfig,
) -> Result<ParameterCombinationResult, String> {
// 暂时返回模拟数据
Ok(ParameterCombinationResult {
combinations: vec![
serde_json::json!({"param1": "value1", "param2": "value2"}),
serde_json::json!({"param1": "value1", "param2": "value3"}),
],
total_count: 2,
truncated: false,
})
}
/// 执行批量工作流
#[tauri::command]
pub async fn execute_batch_workflow(
request: CreateBatchTaskRequest,
) -> Result<String, String> {
// 暂时返回模拟的批量任务ID
let batch_id = Uuid::new_v4().to_string();
Ok(batch_id)
}
/// 获取任务状态
#[tauri::command]
pub async fn get_task_status(
workflow_run_id: String,
state: State<'_, AppState>
) -> Result<Option<UniComfyUITask>, String> {
let pool = {
let database_guard = state.database.lock().map_err(|e| format!("Database lock error: {}", e))?;
let database = database_guard.as_ref().ok_or("Database not initialized")?;
database.get_pool().ok_or("Connection pool not available")?
};
let server_url = "http://192.168.0.148:18000".to_string();
let service = UniComfyUIService::new(pool, server_url)
.map_err(|e| format!("Failed to create service: {}", e))?;
service.get_task_status(&workflow_run_id).await
.map_err(|e| format!("Failed to get task status: {}", e))
}
/// 获取批量任务进度
#[tauri::command]
pub async fn get_batch_progress(
batch_id: String,
) -> Result<Option<BatchTaskProgress>, String> {
// 暂时返回None表示批量任务不存在
Ok(None)
}
/// 获取任务列表
#[tauri::command]
pub async fn get_tasks(
workflow_name: Option<String>,
task_type: Option<String>,
status: Option<String>,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<Vec<UniComfyUITask>, String> {
// 暂时返回空列表
Ok(vec![])
}
/// 初始化UniComfyUI数据库表
#[tauri::command]
pub async fn init_uni_comfyui_tables() -> Result<(), String> {
// 暂时返回成功
Ok(())
}
/// 获取工作流执行统计信息
#[tauri::command]
pub async fn get_workflow_stats(
workflow_name: Option<String>,
) -> Result<Value, String> {
// 暂时返回空统计数据
Ok(serde_json::json!([]))
}

View File

@@ -31,6 +31,7 @@ pub mod outfit_photo_generation;
pub mod video_generation_record;
pub mod hedra_lipsync_record;
pub mod comfyui;
pub mod uni_comfyui;
// Multi-workflow system models
pub mod workflow_template;

View File

@@ -0,0 +1,296 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// ComfyUI任务类型
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TaskType {
Single,
Batch,
}
impl std::fmt::Display for TaskType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskType::Single => write!(f, "single"),
TaskType::Batch => write!(f, "batch"),
}
}
}
/// ComfyUI任务状态
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Queued,
Running,
Completed,
Failed,
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskStatus::Queued => write!(f, "queued"),
TaskStatus::Running => write!(f, "running"),
TaskStatus::Completed => write!(f, "completed"),
TaskStatus::Failed => write!(f, "failed"),
}
}
}
/// 批量任务状态
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BatchStatus {
Pending,
Running,
Completed,
Failed,
}
impl std::fmt::Display for BatchStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BatchStatus::Pending => write!(f, "pending"),
BatchStatus::Running => write!(f, "running"),
BatchStatus::Completed => write!(f, "completed"),
BatchStatus::Failed => write!(f, "failed"),
}
}
}
/// ComfyUI任务记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniComfyUITask {
pub id: Option<i64>,
pub workflow_run_id: String,
pub workflow_name: String,
pub task_type: TaskType,
pub status: TaskStatus,
pub input_params: Value, // JSON格式的输入参数
pub result_data: Option<Value>, // JSON格式的执行结果
pub error_message: Option<String>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub server_url: Option<String>,
}
/// 批量任务记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniComfyUIBatchTask {
pub id: Option<i64>,
pub batch_id: String,
pub workflow_name: String,
pub total_count: i32,
pub completed_count: i32,
pub failed_count: i32,
pub status: BatchStatus,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
/// 批量任务子项记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniComfyUIBatchItem {
pub id: Option<i64>,
pub batch_id: String,
pub task_id: i64,
pub item_index: i32,
}
/// 工作流缓存记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniComfyUIWorkflowCache {
pub id: Option<i64>,
pub workflow_name: String,
pub workflow_data: Value, // JSON格式的工作流数据
pub api_spec: Value, // JSON格式的API规范
pub inputs_json_schema: Value, // JSON格式的输入参数Schema
pub cached_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
}
/// 任务创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTaskRequest {
pub workflow_name: String,
pub task_type: TaskType,
pub input_params: Value,
pub server_url: Option<String>,
}
/// 批量任务创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBatchTaskRequest {
pub workflow_name: String,
pub input_params_list: Vec<Value>, // 参数组合列表
pub server_url: Option<String>,
}
/// 任务执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskExecutionResult {
pub workflow_run_id: String,
pub status: TaskStatus,
pub result_data: Option<Value>,
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
/// 批量任务进度
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchTaskProgress {
pub batch_id: String,
pub total_count: i32,
pub completed_count: i32,
pub failed_count: i32,
pub running_count: i32,
pub pending_count: i32,
pub status: BatchStatus,
pub progress_percentage: f64,
}
/// 工作流信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInfo {
pub name: String,
pub workflow: Value,
pub api_spec: Option<Value>,
pub inputs_json_schema: Option<Value>,
}
/// 工作流列表响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowListResponse {
pub workflows: Vec<WorkflowInfo>,
}
/// 参数组合生成配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterCombinationConfig {
pub workflow_name: String,
pub parameter_sets: Value, // JSON格式的参数集合配置
pub max_combinations: Option<usize>, // 最大组合数限制
}
/// 参数组合生成结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterCombinationResult {
pub combinations: Vec<Value>,
pub total_count: usize,
pub truncated: bool, // 是否因为超过限制而截断
}
impl UniComfyUITask {
/// 创建新的任务记录
pub fn new(
workflow_run_id: String,
workflow_name: String,
task_type: TaskType,
input_params: Value,
server_url: Option<String>,
) -> Self {
Self {
id: None,
workflow_run_id,
workflow_name,
task_type,
status: TaskStatus::Queued,
input_params,
result_data: None,
error_message: None,
created_at: Utc::now(),
started_at: None,
completed_at: None,
server_url,
}
}
/// 更新任务状态
pub fn update_status(&mut self, status: TaskStatus) {
match status {
TaskStatus::Running => {
if self.started_at.is_none() {
self.started_at = Some(Utc::now());
}
}
TaskStatus::Completed | TaskStatus::Failed => {
if self.completed_at.is_none() {
self.completed_at = Some(Utc::now());
}
}
_ => {}
}
self.status = status;
}
/// 设置执行结果
pub fn set_result(&mut self, result_data: Option<Value>, error_message: Option<String>) {
self.result_data = result_data;
self.error_message = error_message;
}
/// 计算执行时间(秒)
pub fn execution_time_seconds(&self) -> Option<f64> {
if let (Some(started), Some(completed)) = (self.started_at, self.completed_at) {
Some((completed - started).num_milliseconds() as f64 / 1000.0)
} else {
None
}
}
}
impl UniComfyUIBatchTask {
/// 创建新的批量任务记录
pub fn new(batch_id: String, workflow_name: String, total_count: i32) -> Self {
Self {
id: None,
batch_id,
workflow_name,
total_count,
completed_count: 0,
failed_count: 0,
status: BatchStatus::Pending,
created_at: Utc::now(),
completed_at: None,
}
}
/// 更新进度
pub fn update_progress(&mut self, completed_count: i32, failed_count: i32) {
self.completed_count = completed_count;
self.failed_count = failed_count;
// 自动更新状态
if completed_count + failed_count >= self.total_count {
self.status = if failed_count == 0 {
BatchStatus::Completed
} else {
BatchStatus::Failed
};
if self.completed_at.is_none() {
self.completed_at = Some(Utc::now());
}
} else if completed_count > 0 || failed_count > 0 {
self.status = BatchStatus::Running;
}
}
/// 计算进度百分比
pub fn progress_percentage(&self) -> f64 {
if self.total_count == 0 {
0.0
} else {
(self.completed_count + self.failed_count) as f64 / self.total_count as f64 * 100.0
}
}
/// 获取运行中的任务数量
pub fn running_count(&self) -> i32 {
self.total_count - self.completed_count - self.failed_count
}
}

View File

@@ -21,6 +21,7 @@ pub mod outfit_photo_generation_repository;
pub mod video_generation_record_repository;
pub mod hedra_lipsync_repository;
pub mod comfyui_repository;
pub mod uni_comfyui_repository;
// Multi-workflow system repositories
pub mod workflow_template_repository;

View File

@@ -0,0 +1,583 @@
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Row};
use serde_json::Value;
use std::sync::Arc;
use crate::data::models::uni_comfyui::*;
use crate::infrastructure::connection_pool::{ConnectionPool, PooledConnectionHandle};
/// UniComfyUI数据库操作仓库
#[derive(Clone)]
pub struct UniComfyUIRepository {
pool: Arc<ConnectionPool>,
}
impl UniComfyUIRepository {
pub fn new(pool: Arc<ConnectionPool>) -> Self {
Self { pool }
}
/// 获取数据库连接
fn get_connection(&self) -> Result<PooledConnectionHandle> {
self.pool.acquire()
}
// ==================== 任务管理 ====================
/// 创建新任务
pub fn create_task(&self, task: &UniComfyUITask) -> Result<i64> {
tracing::info!("🔄 开始创建任务: workflow_run_id={}, workflow_name={}",
task.workflow_run_id, task.workflow_name);
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"INSERT INTO uni_comfyui_task (
workflow_run_id, workflow_name, task_type, status,
input_params, result_data, error_message,
created_at, started_at, completed_at, server_url
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
)?;
let task_type_str = match task.task_type {
TaskType::Single => "single",
TaskType::Batch => "batch",
};
let status_str = match task.status {
TaskStatus::Queued => "queued",
TaskStatus::Running => "running",
TaskStatus::Completed => "completed",
TaskStatus::Failed => "failed",
};
let input_params_json = serde_json::to_string(&task.input_params)?;
let result_data_json = task.result_data.as_ref().map(|v| serde_json::to_string(v)).transpose()?;
let result = stmt.execute(params![
task.workflow_run_id,
task.workflow_name,
task_type_str,
status_str,
input_params_json,
result_data_json,
task.error_message,
task.created_at.format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
task.started_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string()),
task.completed_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string()),
task.server_url
]);
match result {
Ok(_) => {
let row_id = conn.last_insert_rowid();
tracing::info!("✅ 任务创建成功: workflow_run_id={}, row_id={}",
task.workflow_run_id, row_id);
Ok(row_id)
}
Err(e) => {
tracing::error!("❌ 任务创建失败: workflow_run_id={}, error={}",
task.workflow_run_id, e);
Err(e.into())
}
}
}
/// 根据workflow_run_id获取任务
pub fn get_task_by_run_id(&self, workflow_run_id: &str) -> Result<Option<UniComfyUITask>> {
tracing::info!("🔍 查询任务: workflow_run_id={}", workflow_run_id);
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, workflow_run_id, workflow_name, task_type, status,
input_params, result_data, error_message,
created_at, started_at, completed_at, server_url
FROM uni_comfyui_task WHERE workflow_run_id = ?1"
)?;
let result = stmt.query_row(params![workflow_run_id], |row| {
Self::row_to_task(row)
});
match result {
Ok(task) => {
tracing::info!("✅ 找到任务: workflow_run_id={}, status={:?}",
workflow_run_id, task.status);
Ok(Some(task))
}
Err(rusqlite::Error::QueryReturnedNoRows) => {
tracing::warn!("⚠️ 任务不存在: workflow_run_id={}", workflow_run_id);
Ok(None)
}
Err(e) => {
tracing::error!("❌ 查询任务失败: workflow_run_id={}, error={}",
workflow_run_id, e);
Err(anyhow!("Database error: {}", e))
}
}
}
/// 更新任务状态
pub fn update_task_status(&self, workflow_run_id: &str, status: TaskStatus,
result_data: Option<Value>, error_message: Option<String>) -> Result<()> {
let conn = self.get_connection()?;
let now = Utc::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string();
let status_str = match status {
TaskStatus::Queued => "queued",
TaskStatus::Running => "running",
TaskStatus::Completed => "completed",
TaskStatus::Failed => "failed",
};
let result_data_json = result_data.map(|v| serde_json::to_string(&v)).transpose()?;
let mut stmt = conn.prepare(
"UPDATE uni_comfyui_task
SET status = ?1, result_data = ?2, error_message = ?3,
started_at = CASE WHEN ?1 = 'running' AND started_at IS NULL THEN ?4 ELSE started_at END,
completed_at = CASE WHEN ?1 IN ('completed', 'failed') THEN ?4 ELSE completed_at END
WHERE workflow_run_id = ?5"
)?;
stmt.execute(params![
status_str,
result_data_json,
error_message,
now,
workflow_run_id
])?;
Ok(())
}
/// 获取任务列表
pub fn get_tasks(&self, workflow_name: Option<&str>, task_type: Option<TaskType>,
status: Option<TaskStatus>, limit: Option<i32>, offset: Option<i32>) -> Result<Vec<UniComfyUITask>> {
let conn = self.get_connection()?;
let mut sql = "SELECT id, workflow_run_id, workflow_name, task_type, status,
input_params, result_data, error_message,
created_at, started_at, completed_at, server_url
FROM uni_comfyui_task WHERE 1=1".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(name) = workflow_name {
sql.push_str(" AND workflow_name = ?");
params.push(Box::new(name.to_string()));
}
if let Some(t_type) = task_type {
sql.push_str(" AND task_type = ?");
let type_str = match t_type {
TaskType::Single => "single",
TaskType::Batch => "batch",
};
params.push(Box::new(type_str.to_string()));
}
if let Some(s) = status {
sql.push_str(" AND status = ?");
let status_str = match s {
TaskStatus::Queued => "queued",
TaskStatus::Running => "running",
TaskStatus::Completed => "completed",
TaskStatus::Failed => "failed",
};
params.push(Box::new(status_str.to_string()));
}
sql.push_str(" ORDER BY created_at DESC");
if let Some(l) = limit {
sql.push_str(" LIMIT ?");
params.push(Box::new(l));
}
if let Some(o) = offset {
sql.push_str(" OFFSET ?");
params.push(Box::new(o));
}
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(&param_refs[..], |row| {
Self::row_to_task(row)
})?;
let mut tasks = Vec::new();
for row in rows {
tasks.push(row?);
}
Ok(tasks)
}
// ==================== 批量任务管理 ====================
/// 创建批量任务
pub fn create_batch_task(&self, batch_task: &UniComfyUIBatchTask) -> Result<i64> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"INSERT INTO uni_comfyui_batch_task (
batch_id, workflow_name, total_count, completed_count,
failed_count, status, created_at, completed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
)?;
let status_str = match batch_task.status {
BatchStatus::Pending => "pending",
BatchStatus::Running => "running",
BatchStatus::Completed => "completed",
BatchStatus::Failed => "failed",
};
stmt.execute(params![
batch_task.batch_id,
batch_task.workflow_name,
batch_task.total_count,
batch_task.completed_count,
batch_task.failed_count,
status_str,
batch_task.created_at.format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
batch_task.completed_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string())
])?;
Ok(conn.last_insert_rowid())
}
/// 添加批量任务子项
pub fn add_batch_item(&self, batch_id: &str, task_id: i64, item_index: i32) -> Result<()> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"INSERT INTO uni_comfyui_batch_item (batch_id, task_id, item_index) VALUES (?1, ?2, ?3)"
)?;
stmt.execute(params![batch_id, task_id, item_index])?;
Ok(())
}
/// 获取批量任务
pub fn get_batch_task(&self, batch_id: &str) -> Result<Option<UniComfyUIBatchTask>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, batch_id, workflow_name, total_count, completed_count,
failed_count, status, created_at, completed_at
FROM uni_comfyui_batch_task WHERE batch_id = ?1"
)?;
let result = stmt.query_row(params![batch_id], |row| {
Self::row_to_batch_task(row)
});
match result {
Ok(task) => Ok(Some(task)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(anyhow!("Database error: {}", e)),
}
}
/// 更新批量任务进度
pub fn update_batch_progress(&self, batch_id: &str, completed_count: i32, failed_count: i32) -> Result<()> {
let conn = self.get_connection()?;
// 首先获取总数
let mut stmt = conn.prepare("SELECT total_count FROM uni_comfyui_batch_task WHERE batch_id = ?1")?;
let total_count: i32 = stmt.query_row(params![batch_id], |row| row.get(0))?;
// 确定状态
let status = if failed_count > 0 && (completed_count + failed_count) >= total_count {
"failed"
} else if (completed_count + failed_count) >= total_count {
"completed"
} else if completed_count > 0 || failed_count > 0 {
"running"
} else {
"pending"
};
let completed_at = if status == "completed" || status == "failed" {
Some(Utc::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string())
} else {
None
};
let mut stmt = conn.prepare(
"UPDATE uni_comfyui_batch_task
SET completed_count = ?1, failed_count = ?2, status = ?3, completed_at = ?4
WHERE batch_id = ?5"
)?;
stmt.execute(params![
completed_count,
failed_count,
status,
completed_at,
batch_id
])?;
Ok(())
}
// ==================== 工作流缓存管理 ====================
/// 缓存工作流信息
pub fn cache_workflow(&self, cache: &UniComfyUIWorkflowCache) -> Result<()> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"INSERT OR REPLACE INTO uni_comfyui_workflow_cache (
workflow_name, workflow_data, api_spec, inputs_json_schema,
cached_at, expires_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
)?;
let workflow_data_json = serde_json::to_string(&cache.workflow_data)?;
let api_spec_json = serde_json::to_string(&cache.api_spec)?;
let inputs_json_schema_json = serde_json::to_string(&cache.inputs_json_schema)?;
stmt.execute(params![
cache.workflow_name,
workflow_data_json,
api_spec_json,
inputs_json_schema_json,
cache.cached_at.format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
cache.expires_at.map(|dt| dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string())
])?;
Ok(())
}
/// 获取缓存的工作流信息
pub fn get_cached_workflow(&self, workflow_name: &str) -> Result<Option<UniComfyUIWorkflowCache>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, workflow_name, workflow_data, api_spec, inputs_json_schema,
cached_at, expires_at
FROM uni_comfyui_workflow_cache WHERE workflow_name = ?1"
)?;
let result = stmt.query_row(params![workflow_name], |row| {
Self::row_to_workflow_cache(row)
});
match result {
Ok(cache) => Ok(Some(cache)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(anyhow!("Database error: {}", e)),
}
}
// ==================== 辅助方法 ====================
/// 将数据库行转换为UniComfyUITask
fn row_to_task(row: &Row) -> rusqlite::Result<UniComfyUITask> {
let task_type_str: String = row.get("task_type")?;
let task_type = match task_type_str.as_str() {
"single" => TaskType::Single,
"batch" => TaskType::Batch,
_ => TaskType::Single,
};
let status_str: String = row.get("status")?;
let status = match status_str.as_str() {
"queued" => TaskStatus::Queued,
"running" => TaskStatus::Running,
"completed" => TaskStatus::Completed,
"failed" => TaskStatus::Failed,
_ => TaskStatus::Queued,
};
let input_params_json: String = row.get("input_params")?;
let input_params: Value = serde_json::from_str(&input_params_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?;
let result_data_json: Option<String> = row.get("result_data")?;
let result_data = result_data_json
.map(|json| serde_json::from_str(&json))
.transpose()
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?;
let created_at_str: String = row.get("created_at")?;
let created_at = Self::parse_datetime(&created_at_str)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
))?;
let started_at_str: Option<String> = row.get("started_at")?;
let started_at = started_at_str
.map(|s| Self::parse_datetime(&s))
.transpose()
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
))?;
let completed_at_str: Option<String> = row.get("completed_at")?;
let completed_at = completed_at_str
.map(|s| Self::parse_datetime(&s))
.transpose()
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
))?;
Ok(UniComfyUITask {
id: Some(row.get("id")?),
workflow_run_id: row.get("workflow_run_id")?,
workflow_name: row.get("workflow_name")?,
task_type,
status,
input_params,
result_data,
error_message: row.get("error_message")?,
created_at,
started_at,
completed_at,
server_url: row.get("server_url")?,
})
}
/// 将数据库行转换为UniComfyUIBatchTask
fn row_to_batch_task(row: &Row) -> rusqlite::Result<UniComfyUIBatchTask> {
let status_str: String = row.get("status")?;
let status = match status_str.as_str() {
"pending" => BatchStatus::Pending,
"running" => BatchStatus::Running,
"completed" => BatchStatus::Completed,
"failed" => BatchStatus::Failed,
_ => BatchStatus::Pending,
};
let created_at_str: String = row.get("created_at")?;
let created_at = DateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S%.3f")
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?
.with_timezone(&Utc);
let completed_at_str: Option<String> = row.get("completed_at")?;
let completed_at = completed_at_str
.map(|s| DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.3f"))
.transpose()
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?
.map(|dt| dt.with_timezone(&Utc));
Ok(UniComfyUIBatchTask {
id: Some(row.get("id")?),
batch_id: row.get("batch_id")?,
workflow_name: row.get("workflow_name")?,
total_count: row.get("total_count")?,
completed_count: row.get("completed_count")?,
failed_count: row.get("failed_count")?,
status,
created_at,
completed_at,
})
}
/// 将数据库行转换为UniComfyUIWorkflowCache
fn row_to_workflow_cache(row: &Row) -> rusqlite::Result<UniComfyUIWorkflowCache> {
let workflow_data_json: String = row.get("workflow_data")?;
let workflow_data: Value = serde_json::from_str(&workflow_data_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?;
let api_spec_json: String = row.get("api_spec")?;
let api_spec: Value = serde_json::from_str(&api_spec_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?;
let inputs_json_schema_json: String = row.get("inputs_json_schema")?;
let inputs_json_schema: Value = serde_json::from_str(&inputs_json_schema_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?;
let cached_at_str: String = row.get("cached_at")?;
let cached_at = DateTime::parse_from_str(&cached_at_str, "%Y-%m-%d %H:%M:%S%.3f")
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?
.with_timezone(&Utc);
let expires_at_str: Option<String> = row.get("expires_at")?;
let expires_at = expires_at_str
.map(|s| DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.3f"))
.transpose()
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0, rusqlite::types::Type::Text, Box::new(e)
))?
.map(|dt| dt.with_timezone(&Utc));
Ok(UniComfyUIWorkflowCache {
id: Some(row.get("id")?),
workflow_name: row.get("workflow_name")?,
workflow_data,
api_spec,
inputs_json_schema,
cached_at,
expires_at,
})
}
/// 解析日期时间字符串,支持多种格式
fn parse_datetime(datetime_str: &str) -> Result<DateTime<Utc>> {
tracing::debug!("🕐 尝试解析日期时间: {}", datetime_str);
// 尝试多种日期时间格式
let formats = [
"%Y-%m-%d %H:%M:%S%.3f", // 带毫秒 (完整格式)
"%Y-%m-%d %H:%M:%S%.f", // 带毫秒 (任意精度)
"%Y-%m-%d %H:%M:%S", // 不带毫秒
"%Y-%m-%dT%H:%M:%S%.3fZ", // ISO 8601 带毫秒
"%Y-%m-%dT%H:%M:%SZ", // ISO 8601 不带毫秒
"%Y-%m-%dT%H:%M:%S%.3f", // ISO 8601 本地时间带毫秒
"%Y-%m-%dT%H:%M:%S", // ISO 8601 本地时间不带毫秒
];
for format in &formats {
if let Ok(dt) = DateTime::parse_from_str(datetime_str, format) {
tracing::debug!("✅ 成功解析日期时间,使用格式: {}", format);
return Ok(dt.with_timezone(&Utc));
}
}
// 尝试解析为 NaiveDateTime 然后转换为 UTC
let naive_formats = [
"%Y-%m-%d %H:%M:%S%.3f", // 带毫秒
"%Y-%m-%d %H:%M:%S%.f", // 带毫秒 (任意精度)
"%Y-%m-%d %H:%M:%S", // 不带毫秒
];
for format in &naive_formats {
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, format) {
tracing::debug!("✅ 成功解析为NaiveDateTime使用格式: {}", format);
return Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// 如果所有格式都失败,尝试使用 chrono 的自动解析
if let Ok(dt) = datetime_str.parse::<DateTime<Utc>>() {
tracing::debug!("✅ 成功使用自动解析");
return Ok(dt);
}
tracing::error!("❌ 所有日期时间格式解析都失败: {}", datetime_str);
Err(anyhow!("无法解析日期时间字符串: {}", datetime_str))
}
}

View File

@@ -336,6 +336,11 @@ impl Database {
self.pool.is_some()
}
/// 获取连接池的引用
pub fn get_pool(&self) -> Option<Arc<ConnectionPool>> {
self.pool.clone()
}
/// 从连接池获取连接(推荐)
/// 如果连接池未启用,返回错误
pub fn acquire_from_pool(&self) -> Result<PooledConnectionHandle> {

View File

@@ -309,6 +309,14 @@ impl MigrationManager {
up_sql: include_str!("migrations/033_create_comfyui_tables.sql").to_string(),
down_sql: Some(include_str!("migrations/033_create_comfyui_tables_down.sql").to_string()),
});
// 迁移 35: 创建 UniComfyUI 工作流管理表
self.add_migration(Migration {
version: 35,
description: "创建 UniComfyUI 工作流管理表".to_string(),
up_sql: include_str!("migrations/035_create_uni_comfyui_tables.sql").to_string(),
down_sql: Some(include_str!("migrations/035_create_uni_comfyui_tables_down.sql").to_string()),
});
}
/// 添加迁移

View File

@@ -0,0 +1,133 @@
-- UniComfyUI 工作流管理数据库表结构迁移脚本
-- 基于 uni-comfyui-sdk 的工作流管理系统
-- ComfyUI任务表
CREATE TABLE IF NOT EXISTS uni_comfyui_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_run_id TEXT UNIQUE NOT NULL,
workflow_name TEXT NOT NULL,
task_type TEXT NOT NULL CHECK (task_type IN ('single', 'batch')),
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'completed', 'failed')),
input_params TEXT NOT NULL, -- JSON格式的输入参数
result_data TEXT, -- JSON格式的执行结果
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
completed_at DATETIME,
server_url TEXT
);
-- 批量任务表
CREATE TABLE IF NOT EXISTS uni_comfyui_batch_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id TEXT UNIQUE NOT NULL,
workflow_name TEXT NOT NULL,
total_count INTEGER NOT NULL,
completed_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
-- 批量任务子项表
CREATE TABLE IF NOT EXISTS uni_comfyui_batch_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id TEXT NOT NULL,
task_id INTEGER NOT NULL,
item_index INTEGER NOT NULL,
FOREIGN KEY (batch_id) REFERENCES uni_comfyui_batch_task(batch_id),
FOREIGN KEY (task_id) REFERENCES uni_comfyui_task(id)
);
-- 工作流缓存表用于缓存从API获取的工作流信息
CREATE TABLE IF NOT EXISTS uni_comfyui_workflow_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_name TEXT UNIQUE NOT NULL,
workflow_data TEXT NOT NULL, -- JSON格式的工作流数据
api_spec TEXT NOT NULL, -- JSON格式的API规范
inputs_json_schema TEXT NOT NULL, -- JSON格式的输入参数Schema
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME -- 缓存过期时间
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_task_workflow_run_id ON uni_comfyui_task(workflow_run_id);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_task_workflow_name ON uni_comfyui_task(workflow_name);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_task_status ON uni_comfyui_task(status);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_task_task_type ON uni_comfyui_task(task_type);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_task_created_at ON uni_comfyui_task(created_at);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_batch_task_batch_id ON uni_comfyui_batch_task(batch_id);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_batch_task_workflow_name ON uni_comfyui_batch_task(workflow_name);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_batch_task_status ON uni_comfyui_batch_task(status);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_batch_task_created_at ON uni_comfyui_batch_task(created_at);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_batch_item_batch_id ON uni_comfyui_batch_item(batch_id);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_batch_item_task_id ON uni_comfyui_batch_item(task_id);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_workflow_cache_workflow_name ON uni_comfyui_workflow_cache(workflow_name);
CREATE INDEX IF NOT EXISTS idx_uni_comfyui_workflow_cache_expires_at ON uni_comfyui_workflow_cache(expires_at);
-- 创建视图以便于查询
CREATE VIEW IF NOT EXISTS uni_comfyui_task_summary AS
SELECT
t.id,
t.workflow_run_id,
t.workflow_name,
t.task_type,
t.status,
t.created_at,
t.started_at,
t.completed_at,
t.server_url,
CASE
WHEN t.completed_at IS NOT NULL AND t.started_at IS NOT NULL
THEN (julianday(t.completed_at) - julianday(t.started_at)) * 24 * 60 * 60
ELSE NULL
END as execution_time_seconds,
b.batch_id,
b.total_count as batch_total_count,
b.completed_count as batch_completed_count,
b.failed_count as batch_failed_count
FROM uni_comfyui_task t
LEFT JOIN uni_comfyui_batch_item bi ON t.id = bi.task_id
LEFT JOIN uni_comfyui_batch_task b ON bi.batch_id = b.batch_id;
-- 创建统计视图
CREATE VIEW IF NOT EXISTS uni_comfyui_task_stats AS
SELECT
workflow_name,
task_type,
status,
COUNT(*) as count,
AVG(
CASE
WHEN completed_at IS NOT NULL AND started_at IS NOT NULL
THEN (julianday(completed_at) - julianday(started_at)) * 24 * 60 * 60
ELSE NULL
END
) as avg_execution_time_seconds,
MIN(created_at) as first_execution,
MAX(created_at) as last_execution
FROM uni_comfyui_task
GROUP BY workflow_name, task_type, status;
-- 创建批量任务统计视图
CREATE VIEW IF NOT EXISTS uni_comfyui_batch_stats AS
SELECT
workflow_name,
status,
COUNT(*) as batch_count,
SUM(total_count) as total_tasks,
SUM(completed_count) as total_completed,
SUM(failed_count) as total_failed,
AVG(
CASE
WHEN completed_at IS NOT NULL
THEN (julianday(completed_at) - julianday(created_at)) * 24 * 60 * 60
ELSE NULL
END
) as avg_batch_time_seconds
FROM uni_comfyui_batch_task
GROUP BY workflow_name, status;

View File

@@ -0,0 +1,27 @@
-- 回滚 UniComfyUI 工作流管理数据库表结构
-- 删除视图
DROP VIEW IF EXISTS uni_comfyui_batch_stats;
DROP VIEW IF EXISTS uni_comfyui_task_stats;
DROP VIEW IF EXISTS uni_comfyui_task_summary;
-- 删除索引
DROP INDEX IF EXISTS idx_uni_comfyui_workflow_cache_expires_at;
DROP INDEX IF EXISTS idx_uni_comfyui_workflow_cache_workflow_name;
DROP INDEX IF EXISTS idx_uni_comfyui_batch_item_task_id;
DROP INDEX IF EXISTS idx_uni_comfyui_batch_item_batch_id;
DROP INDEX IF EXISTS idx_uni_comfyui_batch_task_created_at;
DROP INDEX IF EXISTS idx_uni_comfyui_batch_task_status;
DROP INDEX IF EXISTS idx_uni_comfyui_batch_task_workflow_name;
DROP INDEX IF EXISTS idx_uni_comfyui_batch_task_batch_id;
DROP INDEX IF EXISTS idx_uni_comfyui_task_created_at;
DROP INDEX IF EXISTS idx_uni_comfyui_task_task_type;
DROP INDEX IF EXISTS idx_uni_comfyui_task_status;
DROP INDEX IF EXISTS idx_uni_comfyui_task_workflow_name;
DROP INDEX IF EXISTS idx_uni_comfyui_task_workflow_run_id;
-- 删除表(注意外键依赖顺序)
DROP TABLE IF EXISTS uni_comfyui_batch_item;
DROP TABLE IF EXISTS uni_comfyui_batch_task;
DROP TABLE IF EXISTS uni_comfyui_workflow_cache;
DROP TABLE IF EXISTS uni_comfyui_task;

View File

@@ -18,6 +18,7 @@ pub mod data;
pub mod business;
pub mod presentation;
pub mod services;
pub mod api;
// 应用状态和配置
pub mod app_state;
@@ -801,7 +802,19 @@ pub fn run() {
commands::veo3_actor_define_commands::veo3_scene_writer_clear_conversation,
commands::veo3_actor_define_commands::veo3_scene_writer_remove_session,
commands::veo3_actor_define_commands::veo3_scene_writer_get_active_sessions,
commands::veo3_actor_define_commands::veo3_scene_writer_create_scene_file
commands::veo3_actor_define_commands::veo3_scene_writer_create_scene_file,
// UniComfyUI 工作流管理命令
api::uni_comfyui_api::get_workflows,
api::uni_comfyui_api::get_workflow_spec,
api::uni_comfyui_api::execute_uni_workflow,
api::uni_comfyui_api::generate_parameter_combinations,
api::uni_comfyui_api::execute_batch_workflow,
api::uni_comfyui_api::get_task_status,
api::uni_comfyui_api::get_batch_progress,
api::uni_comfyui_api::get_tasks,
api::uni_comfyui_api::init_uni_comfyui_tables,
api::uni_comfyui_api::get_workflow_stats
])
.setup(|app| {
// 初始化日志系统

View File

@@ -1 +1,2 @@
pub mod material_search_service;
pub mod uni_comfyui_service;

View File

@@ -0,0 +1,550 @@
use anyhow::{anyhow, Result};
use chrono::{Duration, Utc};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use uni_comfyui_sdk::{UniComfyUIClient, Result as SdkResult, JsonObject};
use crate::data::models::uni_comfyui::*;
use crate::data::repositories::uni_comfyui_repository::UniComfyUIRepository;
use crate::infrastructure::connection_pool::ConnectionPool;
/// UniComfyUI工作流管理服务
pub struct UniComfyUIService {
repository: UniComfyUIRepository,
client: UniComfyUIClient,
server_url: String,
}
impl UniComfyUIService {
/// 创建新的服务实例
pub fn new(pool: Arc<ConnectionPool>, server_url: String) -> Result<Self> {
let client = UniComfyUIClient::new(&server_url)
.map_err(|e| anyhow!("Failed to create UniComfyUI client: {}", e))?;
let repository = UniComfyUIRepository::new(pool);
Ok(Self {
repository,
client,
server_url,
})
}
// ==================== 工作流管理 ====================
/// 获取所有工作流列表
pub async fn get_workflows(&self) -> Result<Vec<WorkflowInfo>> {
// 调用SDK获取工作流列表
match self.client.get_all_workflows().await {
Ok(workflows) => {
let mut workflow_infos = Vec::new();
for workflow in workflows {
// 从JsonObject中提取工作流信息
let name = workflow.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let workflow_data = workflow.get("workflow")
.cloned()
.unwrap_or(serde_json::json!({}));
let workflow_info = WorkflowInfo {
name,
workflow: workflow_data,
api_spec: None,
inputs_json_schema: None,
};
workflow_infos.push(workflow_info);
}
Ok(workflow_infos)
}
Err(e) => {
tracing::error!("Failed to get workflows from API: {}", e);
Err(anyhow!("Failed to get workflows: {}", e))
}
}
}
/// 获取工作流详细信息包含API规范
pub async fn get_workflow_spec(&self, workflow_name: &str) -> Result<WorkflowInfo> {
// 首先检查缓存
if let Ok(Some(cached)) = self.repository.get_cached_workflow(workflow_name) {
// 检查缓存是否过期
if let Some(expires_at) = cached.expires_at {
if Utc::now() < expires_at {
return Ok(WorkflowInfo {
name: cached.workflow_name,
workflow: cached.workflow_data,
api_spec: Some(cached.api_spec),
inputs_json_schema: Some(cached.inputs_json_schema),
});
}
}
}
// 从API获取工作流规范
match self.client.get_one_workflow(workflow_name, None).await {
Ok(response) => {
// 解析响应数据
let workflow_data = response.get("workflow")
.cloned()
.unwrap_or(serde_json::json!({}));
let api_spec = response.get("api_spec")
.cloned()
.unwrap_or(serde_json::json!({}));
let inputs_json_schema = response.get("inputs_json_schema")
.cloned()
.unwrap_or(serde_json::json!({}));
let workflow_info = WorkflowInfo {
name: workflow_name.to_string(),
workflow: workflow_data.clone(),
api_spec: Some(api_spec.clone()),
inputs_json_schema: Some(inputs_json_schema.clone()),
};
// 缓存结果缓存1小时
let cache = UniComfyUIWorkflowCache {
id: None,
workflow_name: workflow_name.to_string(),
workflow_data,
api_spec,
inputs_json_schema,
cached_at: Utc::now(),
expires_at: Some(Utc::now() + Duration::hours(1)),
};
if let Err(e) = self.repository.cache_workflow(&cache) {
tracing::warn!("Failed to cache workflow spec: {}", e);
}
Ok(workflow_info)
}
Err(e) => {
tracing::error!("Failed to get workflow spec from API: {}", e);
Err(anyhow!("Failed to get workflow spec: {}", e))
}
}
}
// ==================== 单次执行 ====================
/// 执行单个工作流
pub async fn execute_workflow(&self, workflow_name: &str, input_params: Value) -> Result<String> {
tracing::info!("🚀 开始执行工作流: workflow_name={}, params={}",
workflow_name, serde_json::to_string(&input_params).unwrap_or_default());
// 转换输入参数为JsonObject
let json_params: JsonObject = match &input_params {
Value::Object(map) => {
map.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
_ => {
return Err(anyhow!("Input parameters must be a JSON object"));
}
};
// 调用SDK执行工作流
match self.client.run_workflow(workflow_name, None, json_params).await {
Ok(response) => {
// 从响应中提取workflow_run_id
let workflow_run_id = response.get("workflow_run_id")
.and_then(|v| v.as_str())
.unwrap_or(&Uuid::new_v4().to_string())
.to_string();
// 创建任务记录
let task = UniComfyUITask::new(
workflow_run_id.clone(),
workflow_name.to_string(),
TaskType::Single,
input_params,
Some(self.server_url.clone()),
);
if let Err(e) = self.repository.create_task(&task) {
tracing::warn!("Failed to create task record: {}", e);
}
Ok(workflow_run_id)
}
Err(e) => {
tracing::error!("Failed to execute workflow: {}", e);
Err(anyhow!("Failed to execute workflow: {}", e))
}
}
}
/// 异步执行工作流的内部方法
async fn execute_workflow_async(
client: UniComfyUIClient,
repository: UniComfyUIRepository,
workflow_name: String,
workflow_run_id: String,
input_params: Value,
) {
// 更新状态为运行中
if let Err(e) = repository.update_task_status(&workflow_run_id, TaskStatus::Running, None, None) {
tracing::error!("Failed to update task status to running: {}", e);
return;
}
// 转换输入参数为JsonObject
let json_params: JsonObject = match &input_params {
Value::Object(map) => {
map.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
_ => {
tracing::error!("Input parameters must be a JSON object");
if let Err(e) = repository.update_task_status(
&workflow_run_id,
TaskStatus::Failed,
None,
Some("Input parameters must be a JSON object".to_string()),
) {
tracing::error!("Failed to update task status to failed: {}", e);
}
return;
}
};
// 执行工作流
match client.run_workflow(&workflow_name, None, json_params).await {
Ok(result) => {
// 执行成功result 是 serde_json::Value
let result_data = result;
if let Err(e) = repository.update_task_status(
&workflow_run_id,
TaskStatus::Completed,
Some(result_data),
None,
) {
tracing::error!("Failed to update task status to completed: {}", e);
}
}
Err(e) => {
// 执行失败
tracing::error!("Workflow execution failed: {}", e);
if let Err(e) = repository.update_task_status(
&workflow_run_id,
TaskStatus::Failed,
None,
Some(e.to_string()),
) {
tracing::error!("Failed to update task status to failed: {}", e);
}
}
}
}
// ==================== 批量执行 ====================
/// 生成参数组合
pub fn generate_parameter_combinations(&self, config: ParameterCombinationConfig) -> Result<ParameterCombinationResult> {
let max_combinations = config.max_combinations.unwrap_or(1000);
// 解析参数集合
let parameter_sets = match config.parameter_sets {
Value::Object(obj) => obj,
_ => return Err(anyhow!("Parameter sets must be an object")),
};
// 生成所有可能的组合
let mut combinations = Vec::new();
let mut param_names = Vec::new();
let mut param_values = Vec::new();
for (name, value) in parameter_sets.iter() {
param_names.push(name.clone());
match value {
Value::Array(arr) => {
param_values.push(arr.clone());
}
_ => {
// 如果不是数组,转换为单元素数组
param_values.push(vec![value.clone()]);
}
}
}
// 计算笛卡尔积
let total_combinations = param_values.iter().map(|v| v.len()).product::<usize>();
let truncated = total_combinations > max_combinations;
let actual_combinations = if truncated { max_combinations } else { total_combinations };
for i in 0..actual_combinations {
let mut combination = serde_json::Map::new();
let mut index = i;
for (j, name) in param_names.iter().enumerate() {
let values = &param_values[j];
let value_index = index % values.len();
combination.insert(name.clone(), values[value_index].clone());
index /= values.len();
}
combinations.push(Value::Object(combination));
}
Ok(ParameterCombinationResult {
combinations,
total_count: actual_combinations,
truncated,
})
}
/// 执行批量工作流
pub async fn execute_batch_workflow(&self, request: CreateBatchTaskRequest) -> Result<String> {
let batch_id = Uuid::new_v4().to_string();
let total_count = request.input_params_list.len() as i32;
// 创建批量任务记录
let batch_task = UniComfyUIBatchTask::new(
batch_id.clone(),
request.workflow_name.clone(),
total_count,
);
let batch_task_id = self.repository.create_batch_task(&batch_task)?;
tracing::info!("Created batch task {} with {} items", batch_task_id, total_count);
// 创建子任务
let mut task_ids = Vec::new();
for (index, input_params) in request.input_params_list.iter().enumerate() {
let workflow_run_id = Uuid::new_v4().to_string();
let task = UniComfyUITask::new(
workflow_run_id,
request.workflow_name.clone(),
TaskType::Batch,
input_params.clone(),
request.server_url.clone(),
);
let task_id = self.repository.create_task(&task)?;
self.repository.add_batch_item(&batch_id, task_id, index as i32)?;
task_ids.push(task_id);
}
// 异步执行批量任务
let client = self.client.clone();
let repository = self.repository.clone();
let workflow_name = request.workflow_name.clone();
let input_params_list = request.input_params_list.clone();
let batch_id_clone = batch_id.clone();
tokio::spawn(async move {
Self::execute_batch_workflow_async(
client,
repository,
workflow_name,
batch_id_clone,
input_params_list,
task_ids,
).await;
});
Ok(batch_id)
}
/// 异步执行批量工作流的内部方法
async fn execute_batch_workflow_async(
client: UniComfyUIClient,
repository: UniComfyUIRepository,
workflow_name: String,
batch_id: String,
input_params_list: Vec<Value>,
task_ids: Vec<i64>,
) {
let mut completed_count = 0;
let mut failed_count = 0;
// 更新批量任务状态为运行中
if let Err(e) = repository.update_batch_progress(&batch_id, 0, 0) {
tracing::error!("Failed to update batch progress: {}", e);
return;
}
// 并发执行任务(限制并发数)
let semaphore = Arc::new(tokio::sync::Semaphore::new(4)); // 最多4个并发任务
let mut handles = Vec::new();
let task_ids = Arc::new(task_ids);
for (index, input_params) in input_params_list.into_iter().enumerate() {
let client = client.clone();
let repository = repository.clone();
let workflow_name = workflow_name.clone();
let batch_id = batch_id.clone();
let semaphore = semaphore.clone();
let task_ids = task_ids.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
// 获取任务的workflow_run_id
let task_id = task_ids[index];
// 这里需要从数据库获取workflow_run_id简化处理
let workflow_run_id = Uuid::new_v4().to_string();
// 执行单个任务
Self::execute_workflow_async(
client,
repository,
workflow_name,
workflow_run_id,
input_params,
).await;
});
handles.push(handle);
}
// 等待所有任务完成
for handle in handles {
if let Err(e) = handle.await {
tracing::error!("Batch task execution error: {}", e);
failed_count += 1;
} else {
completed_count += 1;
}
// 更新批量任务进度
if let Err(e) = repository.update_batch_progress(&batch_id, completed_count, failed_count) {
tracing::error!("Failed to update batch progress: {}", e);
}
}
tracing::info!(
"Batch task {} completed: {} succeeded, {} failed",
batch_id,
completed_count,
failed_count
);
}
// ==================== 状态查询 ====================
/// 获取任务状态
pub async fn get_task_status(&self, workflow_run_id: &str) -> Result<Option<UniComfyUITask>> {
tracing::info!("🔍 开始获取任务状态: workflow_run_id={}", workflow_run_id);
// 首先从本地数据库获取
tracing::debug!("📊 尝试从本地数据库获取任务...");
match self.repository.get_task_by_run_id(workflow_run_id) {
Ok(Some(mut task)) => {
tracing::info!("✅ 从数据库找到任务: status={:?}, created_at={}",
task.status, task.created_at);
// 如果任务还在运行中尝试从API获取最新状态
if task.status == TaskStatus::Running || task.status == TaskStatus::Queued {
tracing::info!("🔄 任务状态为运行中/排队中尝试从API获取最新状态...");
match self.client.get_run_status(workflow_run_id).await {
Ok(api_response) => {
tracing::info!("📡 API响应: {}",
serde_json::to_string(&api_response).unwrap_or_default());
// 解析API响应
let status_str = api_response.get("status")
.and_then(|v| v.as_str())
.unwrap_or("queued");
let new_status = match status_str {
"completed" => TaskStatus::Completed,
"failed" => TaskStatus::Failed,
"running" => TaskStatus::Running,
_ => TaskStatus::Queued,
};
tracing::info!("🔄 状态更新: {} -> {:?}", status_str, new_status);
let result_data = if new_status == TaskStatus::Completed {
Some(api_response.clone())
} else {
None
};
let error_message = if new_status == TaskStatus::Failed {
api_response.get("error_message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
};
if let Err(e) = self.repository.update_task_status(
workflow_run_id,
new_status,
result_data,
error_message,
) {
tracing::error!("❌ 更新任务状态失败: {}", e);
} else {
tracing::info!("✅ 任务状态已更新到数据库");
}
// 重新获取更新后的任务
tracing::debug!("🔄 重新获取更新后的任务...");
return self.repository.get_task_by_run_id(workflow_run_id).map_err(|e| anyhow!(e));
}
Err(e) => {
tracing::warn!("⚠️ API调用失败: {}", e);
}
}
}
tracing::info!("📤 返回数据库中的任务状态");
Ok(Some(task))
}
Ok(None) => {
tracing::warn!("⚠️ 数据库中未找到任务: workflow_run_id={}", workflow_run_id);
Ok(None)
}
Err(e) => {
tracing::error!("❌ 数据库查询失败: workflow_run_id={}, error={}", workflow_run_id, e);
Err(e.into())
}
}
}
/// 获取批量任务进度
pub async fn get_batch_progress(&self, batch_id: &str) -> Result<Option<BatchTaskProgress>> {
if let Ok(Some(batch_task)) = self.repository.get_batch_task(batch_id) {
let running_count = batch_task.total_count - batch_task.completed_count - batch_task.failed_count;
let pending_count = if batch_task.status == BatchStatus::Pending {
batch_task.total_count
} else {
0
};
Ok(Some(BatchTaskProgress {
batch_id: batch_task.batch_id.clone(),
total_count: batch_task.total_count,
completed_count: batch_task.completed_count,
failed_count: batch_task.failed_count,
running_count,
pending_count,
status: batch_task.status,
progress_percentage: batch_task.progress_percentage(),
}))
} else {
Ok(None)
}
}
/// 获取任务列表
pub async fn get_tasks(&self, workflow_name: Option<&str>, task_type: Option<TaskType>,
status: Option<TaskStatus>, limit: Option<i32>, offset: Option<i32>) -> Result<Vec<UniComfyUITask>> {
self.repository.get_tasks(workflow_name, task_type, status, limit, offset)
.map_err(|e| anyhow!(e))
}
}

View File

@@ -47,6 +47,7 @@ import ComfyUIWorkflowTest from './pages/ComfyUIWorkflowTest';
import { ComfyUIV2Dashboard } from './pages/ComfyUIV2Dashboard';
import { WorkflowPage } from './pages/WorkflowPage';
import { WorkflowTemplateCreatorTest } from './pages/WorkflowTemplateCreatorTest';
import UniComfyUIWorkflowManagement from './pages/UniComfyUIWorkflowManagement';
// import CanvasTool from './pages/CanvasTool';
import Navigation from './components/Navigation';
@@ -148,6 +149,9 @@ function App() {
<Route path="/workflows" element={<WorkflowPage />} />
{/* UniComfyUI 工作流管理 */}
<Route path="/uni-comfyui-workflow" element={<UniComfyUIWorkflowManagement />} />
{/* ComfyUI 相关路由 */}
<Route path="/comfyui-v2-dashboard" element={<ComfyUIV2Dashboard />} />
<Route path="/comfyui-management" element={<ComfyUIManagement />} />

View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
MinusIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
ArrowPathIcon
} from '@heroicons/react/24/outline';
import { FormField, ParameterCombinationResult } from '../types/uniComfyui';
import { UniComfyUIService } from '../services/uniComfyuiService';
interface BatchParameterConfiguratorProps {
fields: FormField[];
workflowName: string;
onCombinationsGenerated: (combinations: any[]) => void;
onConfigChange: (config: { [key: string]: any[] }) => void;
}
export const BatchParameterConfigurator: React.FC<BatchParameterConfiguratorProps> = ({
fields,
workflowName,
onCombinationsGenerated,
onConfigChange
}) => {
const [parameterConfig, setParameterConfig] = useState<{ [key: string]: any[] }>({});
const [combinations, setCombinations] = useState<any[]>([]);
const [totalCombinations, setTotalCombinations] = useState(0);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewCombinations, setPreviewCombinations] = useState<any[]>([]);
// 初始化参数配置
useEffect(() => {
const initialConfig: { [key: string]: any[] } = {};
fields.forEach(field => {
if (field.enum) {
// 如果有枚举值,默认选择所有值
initialConfig[field.name] = [...field.enum];
} else {
// 否则使用默认值或空字符串
initialConfig[field.name] = [field.default || ''];
}
});
setParameterConfig(initialConfig);
onConfigChange(initialConfig);
}, [fields, onConfigChange]);
// 更新参数值
const updateParameterValues = (fieldName: string, values: any[]) => {
const newConfig = {
...parameterConfig,
[fieldName]: values
};
setParameterConfig(newConfig);
onConfigChange(newConfig);
// 计算总组合数
calculateTotalCombinations(newConfig);
};
// 计算总组合数
const calculateTotalCombinations = (config: { [key: string]: any[] }) => {
const total = Object.values(config).reduce((acc, values) => {
return acc * Math.max(values.length, 1);
}, 1);
setTotalCombinations(total);
};
// 生成参数组合
const generateCombinations = async () => {
try {
setIsGenerating(true);
setError(null);
const result = await UniComfyUIService.generateParameterCombinations({
workflow_name: workflowName,
parameter_sets: parameterConfig,
max_combinations: 1000
});
setCombinations(result.combinations);
setPreviewCombinations(result.combinations.slice(0, 10));
onCombinationsGenerated(result.combinations);
if (result.truncated) {
setError(`组合数量过多,已截断至 ${result.total_count} 个组合`);
}
} catch (err) {
setError(err instanceof Error ? err.message : '生成组合失败');
} finally {
setIsGenerating(false);
}
};
// 渲染字段配置
const renderFieldConfig = (field: FormField) => {
const values = parameterConfig[field.name] || [];
if (field.enum) {
// 枚举类型 - 多选框
return (
<div key={field.name} className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="text-xs text-gray-500">
{values.length} / {field.enum.length}
</div>
</div>
<div className="grid grid-cols-2 gap-2 max-h-32 overflow-y-auto border rounded-md p-2">
{field.enum.map((option: any, index: number) => (
<label key={index} className="flex items-center space-x-2">
<input
type="checkbox"
checked={values.includes(option)}
onChange={(e) => {
if (e.target.checked) {
updateParameterValues(field.name, [...values, option]);
} else {
updateParameterValues(field.name, values.filter(v => v !== option));
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 truncate">{option}</span>
</label>
))}
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={() => updateParameterValues(field.name, field.enum ? [...field.enum] : [])}
className="text-xs text-blue-600 hover:text-blue-800"
>
</button>
<button
type="button"
onClick={() => updateParameterValues(field.name, [])}
className="text-xs text-gray-600 hover:text-gray-800"
>
</button>
</div>
</div>
);
}
// 非枚举类型 - 动态输入列表
return (
<div key={field.name} className="space-y-3">
<label className="text-sm font-medium text-gray-700">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="space-y-2">
{values.map((value, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type={field.type === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => {
const newValues = [...values];
newValues[index] = field.type === 'number'
? parseFloat(e.target.value) || 0
: e.target.value;
updateParameterValues(field.name, newValues);
}}
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder={`${field.title} ${index + 1}`}
/>
{values.length > 1 && (
<button
type="button"
onClick={() => {
const newValues = values.filter((_, i) => i !== index);
updateParameterValues(field.name, newValues);
}}
className="p-1 text-red-600 hover:text-red-800"
>
<MinusIcon className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => {
const newValues = [...values, field.default || ''];
updateParameterValues(field.name, newValues);
}}
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800"
>
<PlusIcon className="h-4 w-4" />
<span></span>
</button>
</div>
{field.description && (
<p className="text-xs text-gray-500 flex items-center">
<InformationCircleIcon className="h-3 w-3 mr-1" />
{field.description}
</p>
)}
</div>
);
};
return (
<div className="space-y-6">
{/* 参数配置 */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-900 mb-4"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{fields.map(renderFieldConfig)}
</div>
</div>
{/* 组合统计 */}
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900"></h4>
<button
onClick={generateCombinations}
disabled={isGenerating || totalCombinations === 0}
className="flex items-center space-x-2 px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowPathIcon className={`h-4 w-4 ${isGenerating ? 'animate-spin' : ''}`} />
<span>{isGenerating ? '生成中...' : '生成组合'}</span>
</button>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium text-gray-900">{totalCombinations.toLocaleString()}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium text-gray-900">{combinations.length.toLocaleString()}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium text-gray-900">{previewCombinations.length}</span>
</div>
</div>
{totalCombinations > 1000 && (
<div className="mt-3 flex items-center space-x-2 text-sm text-amber-700">
<ExclamationTriangleIcon className="h-4 w-4" />
<span></span>
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center space-x-2 text-sm text-red-800">
<ExclamationTriangleIcon className="h-4 w-4" />
<span>{error}</span>
</div>
</div>
)}
{/* 组合预览 */}
{previewCombinations.length > 0 && (
<div className="bg-white border rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-900 mb-3"> (10)</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{previewCombinations.map((combination, index) => (
<div key={index} className="text-xs bg-gray-50 rounded p-2">
<div className="font-medium text-gray-700 mb-1"> {index + 1}:</div>
<div className="space-y-1">
{Object.entries(combination).map(([key, value]) => (
<div key={key} className="flex">
<span className="text-gray-600 w-20 truncate">{key}:</span>
<span className="text-gray-900 flex-1 truncate">{JSON.stringify(value)}</span>
</div>
))}
</div>
</div>
))}
</div>
{combinations.length > 10 && (
<div className="mt-2 text-xs text-gray-500 text-center">
{combinations.length - 10}
</div>
)}
</div>
)}
</div>
);
};
export default BatchParameterConfigurator;

View File

@@ -0,0 +1,455 @@
import React, { useState, useEffect } from 'react';
import {
DocumentArrowUpIcon,
XMarkIcon,
InformationCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
import { WorkflowInfo, FormField } from '../types/uniComfyui';
interface DynamicWorkflowFormProps {
workflow: WorkflowInfo;
onSubmit: (formData: any) => void;
onCancel: () => void;
isSubmitting?: boolean;
mode: 'single' | 'batch';
}
export const DynamicWorkflowForm: React.FC<DynamicWorkflowFormProps> = ({
workflow,
onSubmit,
onCancel,
isSubmitting = false,
mode
}) => {
const [formData, setFormData] = useState<any>({});
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [batchConfig, setBatchConfig] = useState<{ [key: string]: any[] }>({});
// 解析JSON Schema生成表单字段
const parseFormFields = (): FormField[] => {
if (!workflow.inputs_json_schema?.properties) return [];
const fields: FormField[] = [];
const properties = workflow.inputs_json_schema.properties;
const required = workflow.inputs_json_schema.required || [];
Object.entries(properties).forEach(([key, schema]: [string, any]) => {
const field: FormField = {
name: key,
type: schema.type || 'string',
title: schema.title || key,
description: schema.description,
required: required.includes(key),
default: schema.default,
enum: schema.enum,
format: schema.format,
widget_name: schema.widget_name
};
// 处理嵌套对象
if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([subKey, subSchema]: [string, any]) => {
const subField: FormField = {
name: `${key}.${subKey}`,
type: subSchema.type || 'string',
title: subSchema.title || subKey,
description: subSchema.description,
required: schema.required?.includes(subKey) || false,
default: subSchema.default,
enum: subSchema.enum,
format: subSchema.format,
widget_name: subSchema.widget_name
};
fields.push(subField);
});
} else {
fields.push(field);
}
});
return fields;
};
const formFields = parseFormFields();
// 初始化表单数据
useEffect(() => {
const initialData: any = {};
const initialBatchConfig: any = {};
formFields.forEach(field => {
if (field.default !== undefined) {
setNestedValue(initialData, field.name, field.default);
}
// 批量模式下初始化批量配置
if (mode === 'batch') {
if (field.enum) {
initialBatchConfig[field.name] = field.enum;
} else {
initialBatchConfig[field.name] = [field.default || ''];
}
}
});
setFormData(initialData);
if (mode === 'batch') {
setBatchConfig(initialBatchConfig);
}
}, [workflow, mode]);
// 设置嵌套对象值
const setNestedValue = (obj: any, path: string, value: any) => {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (!(keys[i] in current)) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
};
// 获取嵌套对象值
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
// 处理表单字段变化
const handleFieldChange = (fieldName: string, value: any) => {
const newFormData = { ...formData };
setNestedValue(newFormData, fieldName, value);
setFormData(newFormData);
// 清除错误
if (errors[fieldName]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
}
};
// 处理批量配置变化
const handleBatchConfigChange = (fieldName: string, values: any[]) => {
setBatchConfig(prev => ({
...prev,
[fieldName]: values
}));
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: { [key: string]: string } = {};
formFields.forEach(field => {
const value = getNestedValue(formData, field.name);
if (field.required && (value === undefined || value === null || value === '')) {
newErrors[field.name] = `${field.title} 是必填项`;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 提交表单
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
if (mode === 'batch') {
onSubmit({ formData, batchConfig });
} else {
onSubmit(formData);
}
};
// 渲染表单字段
const renderField = (field: FormField) => {
const value = getNestedValue(formData, field.name);
const error = errors[field.name];
if (field.format === 'binary') {
return (
<div key={field.name} className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors">
<DocumentArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-2">
<label htmlFor={`file-${field.name}`} className="cursor-pointer">
<span className="text-sm text-blue-600 hover:text-blue-500">
</span>
<input
id={`file-${field.name}`}
type="file"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFieldChange(field.name, file);
}
}}
/>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
{field.description || '支持常见图片格式'}
</p>
</div>
{value && (
<div className="text-sm text-gray-600">
: {value.name}
</div>
)}
{error && (
<p className="text-sm text-red-600 flex items-center">
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
{error}
</p>
)}
</div>
);
}
if (field.enum) {
return (
<div key={field.name} className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{mode === 'batch' ? (
<div className="space-y-2">
<div className="text-sm text-gray-600"> - :</div>
<div className="space-y-1">
{field.enum.map((option: any, index: number) => (
<label key={index} className="flex items-center">
<input
type="checkbox"
checked={batchConfig[field.name]?.includes(option) || false}
onChange={(e) => {
const currentValues = batchConfig[field.name] || [];
if (e.target.checked) {
handleBatchConfigChange(field.name, [...currentValues, option]);
} else {
handleBatchConfigChange(field.name, currentValues.filter(v => v !== option));
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">{option}</span>
</label>
))}
</div>
</div>
) : (
<select
value={value || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
className={`
block w-full rounded-md border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500 sm:text-sm
${error ? 'border-red-300' : ''}
`}
>
<option value="">...</option>
{field.enum.map((option: any, index: number) => (
<option key={index} value={option}>
{option}
</option>
))}
</select>
)}
{field.description && (
<p className="text-sm text-gray-500 flex items-center">
<InformationCircleIcon className="h-4 w-4 mr-1" />
{field.description}
</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-center">
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
{error}
</p>
)}
</div>
);
}
if (field.type === 'number') {
return (
<div key={field.name} className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{mode === 'batch' ? (
<div className="space-y-2">
<div className="text-sm text-gray-600"> - :</div>
<input
type="text"
placeholder="例如: 0.5, 0.7, 0.9"
onChange={(e) => {
const values = e.target.value.split(',').map(v => parseFloat(v.trim())).filter(v => !isNaN(v));
handleBatchConfigChange(field.name, values);
}}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
) : (
<input
type="number"
step="any"
value={value || ''}
onChange={(e) => handleFieldChange(field.name, parseFloat(e.target.value) || 0)}
className={`
block w-full rounded-md border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500 sm:text-sm
${error ? 'border-red-300' : ''}
`}
/>
)}
{field.description && (
<p className="text-sm text-gray-500 flex items-center">
<InformationCircleIcon className="h-4 w-4 mr-1" />
{field.description}
</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-center">
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
{error}
</p>
)}
</div>
);
}
// 默认文本输入
return (
<div key={field.name} className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{mode === 'batch' ? (
<div className="space-y-2">
<div className="text-sm text-gray-600"> - :</div>
<textarea
rows={3}
placeholder="每行输入一个值"
onChange={(e) => {
const values = e.target.value.split('\n').filter(v => v.trim());
handleBatchConfigChange(field.name, values);
}}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
) : (
<input
type="text"
value={value || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
className={`
block w-full rounded-md border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500 sm:text-sm
${error ? 'border-red-300' : ''}
`}
/>
)}
{field.description && (
<p className="text-sm text-gray-500 flex items-center">
<InformationCircleIcon className="h-4 w-4 mr-1" />
{field.description}
</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-center">
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
{error}
</p>
)}
</div>
);
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
{/* 头部 */}
<div className="flex items-center justify-between pb-4 border-b">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{mode === 'single' ? '单次执行' : '批量执行'} - {workflow.name}
</h3>
<p className="text-sm text-gray-600 mt-1">
{mode === 'single'
? '填写参数执行单次工作流'
: '配置参数组合进行批量执行'
}
</p>
</div>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{/* 表单内容 */}
<form onSubmit={handleSubmit} className="mt-6">
<div className="max-h-96 overflow-y-auto space-y-6">
{formFields.map(renderField)}
</div>
{/* 底部按钮 */}
<div className="flex justify-end space-x-3 pt-6 border-t mt-6">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? '执行中...' : (mode === 'single' ? '开始执行' : '开始批量执行')}
</button>
</div>
</form>
</div>
</div>
);
};
export default DynamicWorkflowForm;

View File

@@ -0,0 +1,326 @@
import React, { useState, useEffect } from 'react';
import {
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
PlayIcon,
PauseIcon,
ArrowPathIcon
} from '@heroicons/react/24/outline';
import { UniComfyUITask, BatchTaskProgress } from '../types/uniComfyui';
import { UniComfyUIService } from '../services/uniComfyuiService';
interface ExecutionProgressMonitorProps {
taskId?: string;
batchId?: string;
workflowName: string;
mode: 'single' | 'batch';
onComplete?: (result: any) => void;
onError?: (error: string) => void;
autoRefresh?: boolean;
refreshInterval?: number;
}
export const ExecutionProgressMonitor: React.FC<ExecutionProgressMonitorProps> = ({
taskId,
batchId,
workflowName,
mode,
onComplete,
onError,
autoRefresh = true,
refreshInterval = 2000
}) => {
const [task, setTask] = useState<UniComfyUITask | null>(null);
const [batchProgress, setBatchProgress] = useState<BatchTaskProgress | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startTime, setStartTime] = useState<Date>(new Date());
const [elapsedTime, setElapsedTime] = useState<number>(0);
// 更新经过时间
useEffect(() => {
const timer = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime.getTime()) / 1000));
}, 1000);
return () => clearInterval(timer);
}, [startTime]);
// 刷新状态
const refreshStatus = async () => {
try {
setIsRefreshing(true);
setError(null);
if (mode === 'single' && taskId) {
const taskStatus = await UniComfyUIService.getTaskStatus(taskId);
if (taskStatus) {
setTask(taskStatus);
if (taskStatus.status === 'completed' && onComplete) {
onComplete(taskStatus);
} else if (taskStatus.status === 'failed' && onError) {
onError(taskStatus.error_message || '任务执行失败');
}
}
} else if (mode === 'batch' && batchId) {
const progress = await UniComfyUIService.getBatchProgress(batchId);
if (progress) {
setBatchProgress(progress);
if (progress.status === 'completed' && onComplete) {
onComplete(progress);
} else if (progress.status === 'failed' && onError) {
onError('批量任务执行失败');
}
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '获取状态失败';
setError(errorMessage);
if (onError) {
onError(errorMessage);
}
} finally {
setIsRefreshing(false);
}
};
// 自动刷新
useEffect(() => {
if (!autoRefresh) return;
const isCompleted =
(task && (task.status === 'completed' || task.status === 'failed')) ||
(batchProgress && (batchProgress.status === 'completed' || batchProgress.status === 'failed'));
if (isCompleted) return;
const timer = setInterval(refreshStatus, refreshInterval);
return () => clearInterval(timer);
}, [autoRefresh, refreshInterval, task, batchProgress]);
// 初始加载
useEffect(() => {
refreshStatus();
}, [taskId, batchId]);
// 格式化时间
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
};
// 获取状态图标和颜色
const getStatusDisplay = () => {
if (mode === 'single' && task) {
switch (task.status) {
case 'queued':
return { icon: ClockIcon, color: 'text-yellow-600', text: '排队中' };
case 'running':
return { icon: PlayIcon, color: 'text-blue-600', text: '执行中' };
case 'completed':
return { icon: CheckCircleIcon, color: 'text-green-600', text: '已完成' };
case 'failed':
return { icon: XCircleIcon, color: 'text-red-600', text: '执行失败' };
default:
return { icon: ClockIcon, color: 'text-gray-600', text: '未知状态' };
}
} else if (mode === 'batch' && batchProgress) {
switch (batchProgress.status) {
case 'pending':
return { icon: ClockIcon, color: 'text-yellow-600', text: '等待中' };
case 'running':
return { icon: PlayIcon, color: 'text-blue-600', text: '执行中' };
case 'completed':
return { icon: CheckCircleIcon, color: 'text-green-600', text: '已完成' };
case 'failed':
return { icon: XCircleIcon, color: 'text-red-600', text: '部分失败' };
default:
return { icon: ClockIcon, color: 'text-gray-600', text: '未知状态' };
}
}
return { icon: ClockIcon, color: 'text-gray-600', text: '加载中' };
};
const statusDisplay = getStatusDisplay();
const StatusIcon = statusDisplay.icon;
return (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
{/* 头部 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<StatusIcon className={`h-6 w-6 ${statusDisplay.color} ${
statusDisplay.text === '执行中' ? 'animate-pulse' : ''
}`} />
<div>
<h3 className="text-lg font-semibold text-gray-900">
{mode === 'single' ? '单次执行' : '批量执行'} - {workflowName}
</h3>
<p className={`text-sm ${statusDisplay.color}`}>
{statusDisplay.text}
</p>
</div>
</div>
<button
onClick={refreshStatus}
disabled={isRefreshing}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50"
title="刷新状态"
>
<ArrowPathIcon className={`h-5 w-5 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
</div>
{/* 错误提示 */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-md p-3">
<div className="flex items-center space-x-2 text-sm text-red-800">
<ExclamationTriangleIcon className="h-4 w-4" />
<span>{error}</span>
</div>
</div>
)}
{/* 单次执行详情 */}
{mode === 'single' && task && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">ID:</span>
<span className="ml-2 font-mono text-gray-900">{task.workflow_run_id}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-gray-900">
{new Date(task.created_at).toLocaleString('zh-CN')}
</span>
</div>
{task.started_at && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-gray-900">
{new Date(task.started_at).toLocaleString('zh-CN')}
</span>
</div>
)}
{task.completed_at && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-gray-900">
{new Date(task.completed_at).toLocaleString('zh-CN')}
</span>
</div>
)}
</div>
<div className="text-sm">
<span className="text-gray-600">:</span>
<span className="ml-2 font-mono text-gray-900">{formatTime(elapsedTime)}</span>
</div>
{task.error_message && (
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<div className="text-sm text-red-800">
<strong>:</strong> {task.error_message}
</div>
</div>
)}
{task.result_data && (
<div className="bg-green-50 border border-green-200 rounded-md p-3">
<div className="text-sm text-green-800">
<strong>:</strong>
<pre className="mt-2 text-xs overflow-x-auto">
{JSON.stringify(task.result_data, null, 2)}
</pre>
</div>
</div>
)}
</div>
)}
{/* 批量执行详情 */}
{mode === 'batch' && batchProgress && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">ID:</span>
<span className="ml-2 font-mono text-gray-900">{batchProgress.batch_id}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-gray-900">{batchProgress.total_count}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-green-600">{batchProgress.completed_count}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-red-600">{batchProgress.failed_count}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-blue-600">{batchProgress.running_count}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 text-yellow-600">{batchProgress.pending_count}</span>
</div>
</div>
{/* 进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600"></span>
<span className="text-gray-900">{batchProgress.progress_percentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300 relative overflow-hidden"
style={{ width: `${batchProgress.progress_percentage}%` }}
>
{batchProgress.status === 'running' && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-30 animate-pulse"></div>
)}
</div>
</div>
</div>
{/* 成功率 */}
{batchProgress.completed_count + batchProgress.failed_count > 0 && (
<div className="text-sm">
<span className="text-gray-600">:</span>
<span className="ml-2 text-gray-900">
{(
(batchProgress.completed_count /
(batchProgress.completed_count + batchProgress.failed_count)) * 100
).toFixed(1)}%
</span>
</div>
)}
<div className="text-sm">
<span className="text-gray-600">:</span>
<span className="ml-2 font-mono text-gray-900">{formatTime(elapsedTime)}</span>
</div>
</div>
)}
</div>
);
};
export default ExecutionProgressMonitor;

View File

@@ -8,6 +8,7 @@ import {
WrenchScrewdriverIcon,
SparklesIcon,
ChevronDownIcon,
CommandLineIcon,
} from '@heroicons/react/24/outline';
// 导航项类型定义
@@ -69,6 +70,12 @@ const Navigation: React.FC = () => {
icon: SparklesIcon,
description: 'AI穿搭方案推荐与素材检索'
},
{
name: 'ComfyUI',
href: '/uni-comfyui-workflow',
icon: CommandLineIcon,
description: 'ComfyUI工作流管理与执行'
},
{
name: '工具',
icon: WrenchScrewdriverIcon,

View File

@@ -0,0 +1,776 @@
import React, { useState, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { ajvResolver } from '@hookform/resolvers/ajv';
import { open } from '@tauri-apps/plugin-dialog';
import {
XMarkIcon,
InformationCircleIcon,
ExclamationTriangleIcon,
PlusIcon,
MinusIcon,
FolderOpenIcon,
CheckCircleIcon,
CloudArrowUpIcon
} from '@heroicons/react/24/outline';
import { WorkflowInfo } from '../types/uniComfyui';
import fileUploadService from '../services/fileUploadService';
interface ReactHookFormWorkflowProps {
workflow: WorkflowInfo;
onSubmit: (formData: any) => void;
onCancel: () => void;
isSubmitting?: boolean;
mode: 'single' | 'batch';
}
interface FormField {
name: string;
type: string;
title: string;
description?: string;
required: boolean;
enum?: any[];
format?: string;
default?: any;
// 文件相关属性
acceptedTypes?: string[];
contentMediaType?: string;
pattern?: string;
}
export const ReactHookFormWorkflow: React.FC<ReactHookFormWorkflowProps> = ({
workflow,
onSubmit,
onCancel,
isSubmitting = false,
mode
}) => {
// 从字段Schema中提取文件类型信息
const extractFileTypeFromSchema = (schema: any): { isFile: boolean; acceptedTypes: string[]; maxSize?: string } => {
const isFile = schema.format === 'binary' ||
schema.contentMediaType?.startsWith('image/') ||
(schema.pattern && /\\\.\([^)]+\)/.test(schema.pattern));
let acceptedTypes: string[] = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
// 从pattern正则表达式中提取支持的文件类型
if (schema.pattern) {
console.log('🔍 解析pattern:', schema.pattern);
// 匹配类似 ".*\\.(jpg|jpeg|png)$" 的模式
const patternMatch = schema.pattern.match(/\\\.\(([^)]+)\)/);
if (patternMatch) {
acceptedTypes = patternMatch[1].split('|');
console.log('✅ 从pattern提取的文件类型:', acceptedTypes);
} else {
console.log('❌ pattern匹配失败');
}
}
// 从contentMediaType中提取类型
if (schema.contentMediaType) {
if (schema.contentMediaType === 'image/*') {
acceptedTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
} else if (schema.contentMediaType.startsWith('image/')) {
const imageType = schema.contentMediaType.split('/')[1];
acceptedTypes = [imageType];
}
}
return { isFile, acceptedTypes };
};
// 解析JSON Schema生成字段配置
const formFields = useMemo((): FormField[] => {
if (!workflow.inputs_json_schema?.properties) return [];
const fields: FormField[] = [];
const properties = workflow.inputs_json_schema.properties;
const required = workflow.inputs_json_schema.required || [];
Object.entries(properties).forEach(([key, schema]: [string, any]) => {
if (schema.type === 'object' && schema.properties) {
// 处理嵌套对象
Object.entries(schema.properties).forEach(([subKey, subSchema]: [string, any]) => {
// 检查是否为文件字段
const fileInfo = extractFileTypeFromSchema(subSchema);
const actualType = fileInfo.isFile ? 'image' : (subSchema.type || 'string');
fields.push({
name: `${key}__${subKey}`,
type: actualType,
title: `${schema.title || key} - ${subSchema.title || subKey}`,
description: subSchema.description,
required: schema.required?.includes(subKey) || false,
enum: subSchema.enum,
format: fileInfo.isFile ? 'binary' : subSchema.format,
default: subSchema.default,
// 添加文件相关的额外信息
acceptedTypes: fileInfo.isFile ? fileInfo.acceptedTypes : undefined,
contentMediaType: subSchema.contentMediaType,
pattern: subSchema.pattern
});
});
} else {
// 检查是否为文件字段
const fileInfo = extractFileTypeFromSchema(schema);
const actualType = fileInfo.isFile ? 'image' : (schema.type || 'string');
fields.push({
name: key,
type: actualType,
title: schema.title || key,
description: schema.description,
required: required.includes(key),
enum: schema.enum,
format: fileInfo.isFile ? 'binary' : schema.format,
default: schema.default,
// 添加文件相关的额外信息
acceptedTypes: fileInfo.isFile ? fileInfo.acceptedTypes : undefined,
contentMediaType: schema.contentMediaType,
pattern: schema.pattern
});
}
});
return fields;
}, [workflow.inputs_json_schema]);
// 生成默认值
const defaultValues = useMemo(() => {
const defaults: any = {};
formFields.forEach(field => {
if (mode === 'batch') {
defaults[field.name] = field.enum ? [field.enum[0]] : [field.default || ''];
} else {
defaults[field.name] = field.default || (field.type === 'number' ? 0 : '');
}
});
return defaults;
}, [formFields, mode]);
// 生成验证Schema
const validationSchema = useMemo(() => {
const properties: any = {};
const required: string[] = [];
formFields.forEach(field => {
if (mode === 'batch') {
properties[field.name] = {
type: 'array',
items: {
type: field.type === 'number' ? 'number' : 'string',
...(field.enum && { enum: field.enum })
},
minItems: 1
};
} else {
properties[field.name] = {
type: field.type === 'number' ? 'number' : 'string',
...(field.enum && { enum: field.enum })
};
}
if (field.required) {
required.push(field.name);
}
});
return {
type: 'object',
properties,
required,
additionalProperties: false
};
}, [formFields, mode]);
const {
control,
handleSubmit,
formState: { errors }
} = useForm({
resolver: ajvResolver(validationSchema as any),
defaultValues,
mode: 'onChange'
});
// 处理表单提交
const onFormSubmit = (data: any) => {
console.log('🚀 表单提交数据:', data);
console.log('📝 表单字段配置:', formFields);
if (mode === 'batch') {
// 生成参数组合
const combinations = generateCombinations(data);
console.log('🔄 批量组合:', combinations);
onSubmit({
formData: data,
combinations,
mode: 'batch'
});
} else {
// 还原嵌套结构
const restoredData = restoreNestedStructure(data);
console.log('🔧 还原后的数据:', restoredData);
onSubmit(restoredData);
}
};
// 生成参数组合
const generateCombinations = (data: any): any[] => {
const keys = Object.keys(data);
const values = keys.map(key => Array.isArray(data[key]) ? data[key] : [data[key]]);
const combinations: any[] = [];
const generate = (current: any[], index: number) => {
if (index === keys.length) {
const combination: any = {};
keys.forEach((key, i) => {
combination[key] = current[i];
});
combinations.push(restoreNestedStructure(combination));
return;
}
for (const value of values[index]) {
generate([...current, value], index + 1);
}
};
generate([], 0);
return combinations;
};
// 还原嵌套结构
const restoreNestedStructure = (flatData: any): any => {
const restored: any = {};
Object.entries(flatData).forEach(([key, value]) => {
if (key.includes('__')) {
const [parentKey, childKey] = key.split('__', 2);
if (!restored[parentKey]) {
restored[parentKey] = {};
}
restored[parentKey][childKey] = value;
} else {
restored[key] = value;
}
});
return restored;
};
// 渲染字段
const renderField = (field: FormField) => {
const error = errors[field.name];
// 文件上传
if (field.format === 'binary' || field.type === 'image') {
return (
<div key={field.name} className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
<label className="block text-sm font-semibold text-gray-800 mb-3">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value } }) => {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null);
const handleFileSelect = async () => {
try {
setUploading(true);
setUploadProgress(0);
// 1. 选择文件
const acceptedExtensions = field.acceptedTypes || ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
const selected = await open({
multiple: false,
filters: [
{
name: field.contentMediaType === 'image/*' ? '图像文件' : '文件',
extensions: acceptedExtensions,
},
],
});
if (selected && typeof selected === 'string') {
// 2. 上传到云端
const result = await fileUploadService.uploadFileToCloud(
selected,
undefined,
(progress: number) => setUploadProgress(progress)
);
if (result.status === 'success' && result.url) {
setUploadedUrl(result.url);
onChange(result.url); // 使用云端URL而不是本地路径
} else {
console.error('上传失败:', result.error);
alert(`上传失败: ${result.error}`);
}
}
} catch (error) {
console.error('Failed to select or upload file:', error);
alert('文件选择或上传失败');
} finally {
setUploading(false);
setUploadProgress(0);
}
};
return (
<div className="border-2 border-dashed border-blue-300 rounded-xl p-8 text-center hover:border-blue-400 hover:bg-blue-50 transition-all duration-200 group">
{uploading ? (
<div className="space-y-4">
<CloudArrowUpIcon className="mx-auto h-16 w-16 text-blue-500 animate-pulse" />
<div>
<p className="text-base font-medium text-blue-600 mb-2">...</p>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<p className="text-sm text-gray-500 mt-1">{uploadProgress}%</p>
</div>
</div>
) : (
<>
<FolderOpenIcon className="mx-auto h-16 w-16 text-blue-400 group-hover:text-blue-500 transition-colors" />
<div className="mt-4">
<button
type="button"
onClick={handleFileSelect}
disabled={uploading}
className="text-base font-medium text-blue-600 hover:text-blue-700 transition-colors bg-blue-50 hover:bg-blue-100 px-4 py-2 rounded-lg disabled:opacity-50"
>
</button>
<p className="text-sm text-gray-500 mt-2">
{field.acceptedTypes?.map(type => type.toUpperCase()).join('、') || 'JPG、PNG、GIF'}
</p>
{field.description && (
<p className="text-xs text-gray-400 mt-1">{field.description}</p>
)}
</div>
</>
)}
{value && uploadedUrl && (
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center justify-center">
<CheckCircleIcon className="h-5 w-5 text-green-600 mr-2" />
<div className="text-left">
<p className="text-sm text-green-700 font-medium">
</p>
<p className="text-xs text-green-600 mt-1 break-all">
{uploadedUrl}
</p>
</div>
</div>
</div>
)}
</div>
);
}}
/>
{field.description && (
<div className="mt-3 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700">{field.description}</p>
</div>
</div>
)}
{error && (
<div className="mt-3 bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-start">
<ExclamationTriangleIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700 font-medium">{(error as any)?.message || '输入有误'}</p>
</div>
</div>
)}
</div>
);
}
// 枚举选择
if (field.enum) {
if (mode === 'batch') {
return (
<div key={field.name} className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
<label className="block text-sm font-semibold text-gray-800 mb-3">
{field.title}
<span className="bg-purple-100 text-purple-700 text-xs font-medium px-2 py-1 rounded-full ml-2"></span>
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value } }) => (
<div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-48 overflow-y-auto">
{field.enum!.map((option: any, index: number) => (
<label key={index} className="flex items-center space-x-3 p-3 bg-white rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 cursor-pointer group">
<input
type="checkbox"
checked={value?.includes(option) || false}
onChange={(e) => {
const currentValues = value || [];
if (e.target.checked) {
onChange([...currentValues, option]);
} else {
onChange(currentValues.filter((v: any) => v !== option));
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0"
/>
<span className="text-sm text-gray-700 group-hover:text-blue-700 font-medium">{option}</span>
</label>
))}
</div>
<div className="mt-3 text-xs text-gray-500">
{value?.length || 0}
</div>
</div>
)}
/>
{field.description && (
<div className="mt-3 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700">{field.description}</p>
</div>
</div>
)}
{error && (
<div className="mt-3 bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-start">
<ExclamationTriangleIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700 font-medium">{(error as any)?.message || '输入有误'}</p>
</div>
</div>
)}
</div>
);
} else {
return (
<div key={field.name} className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
<label className="block text-sm font-semibold text-gray-800 mb-3">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value } }) => (
<select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm bg-white px-4 py-3 transition-all duration-200 hover:border-gray-400"
>
<option value="" className="text-gray-500">...</option>
{field.enum!.map((option: any, index: number) => (
<option key={index} value={option} className="text-gray-900">
{option}
</option>
))}
</select>
)}
/>
{field.description && (
<div className="mt-3 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-700">{field.description}</p>
</div>
</div>
)}
{error && (
<div className="mt-3 bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-start">
<ExclamationTriangleIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700 font-medium">{(error as any)?.message || '输入有误'}</p>
</div>
</div>
)}
</div>
);
}
}
// 数字输入
if (field.type === 'number') {
if (mode === 'batch') {
return (
<div key={field.name} className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
<label className="block text-sm font-semibold text-gray-800 mb-3">
{field.title}
<span className="bg-purple-100 text-purple-700 text-xs font-medium px-2 py-1 rounded-full ml-2"></span>
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value } }) => (
<div className="space-y-3">
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
{(value || []).map((val: any, index: number) => (
<div key={index} className="flex items-center space-x-3">
<div className="flex-1">
<input
type="number"
step="any"
value={val}
onChange={(e) => {
const newValues = [...(value || [])];
newValues[index] = parseFloat(e.target.value) || 0;
onChange(newValues);
}}
className="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-2.5 transition-all duration-200"
placeholder="输入数值"
/>
</div>
{(value || []).length > 1 && (
<button
type="button"
onClick={() => {
const newValues = (value || []).filter((_: any, i: number) => i !== index);
onChange(newValues);
}}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-all duration-200"
>
<MinusIcon className="h-4 w-4" />
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={() => {
const newValues = [...(value || []), field.default || 0];
onChange(newValues);
}}
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 px-4 py-2 rounded-lg transition-all duration-200 font-medium"
>
<PlusIcon className="h-4 w-4" />
<span></span>
</button>
<div className="text-xs text-gray-500">
{(value || []).length}
</div>
</div>
)}
/>
{field.description && (
<p className="text-sm text-gray-600 flex items-start">
<InformationCircleIcon className="h-4 w-4 mr-1 mt-0.5 flex-shrink-0" />
<span>{field.description}</span>
</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-start">
<ExclamationTriangleIcon className="h-4 w-4 mr-1 mt-0.5 flex-shrink-0" />
<span>{(error as any)?.message || '输入有误'}</span>
</p>
)}
</div>
);
} else {
return (
<div key={field.name} className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
<label className="block text-sm font-semibold text-gray-800 mb-3">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value } }) => (
<input
type="number"
step="any"
value={value || ''}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 transition-all duration-200 hover:border-gray-400"
placeholder="输入数值"
/>
)}
/>
{field.description && (
<p className="text-sm text-gray-600 flex items-start">
<InformationCircleIcon className="h-4 w-4 mr-1 mt-0.5 flex-shrink-0" />
<span>{field.description}</span>
</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-start">
<ExclamationTriangleIcon className="h-4 w-4 mr-1 mt-0.5 flex-shrink-0" />
<span>{(error as any)?.message || '输入有误'}</span>
</p>
)}
</div>
);
}
}
// 默认文本输入
return (
<div key={field.name} className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
<label className="block text-sm font-semibold text-gray-800 mb-3">
{field.title}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.name}
control={control}
render={({ field: { onChange, value } }) => (
<textarea
rows={1}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm px-4 py-3 transition-all duration-200 hover:border-gray-400 resize-none"
placeholder={`请输入${field.title}...`}
/>
)}
/>
{field.description && (
<p className="text-sm text-gray-600 flex items-start">
<InformationCircleIcon className="h-4 w-4 mr-1 mt-0.5 flex-shrink-0" />
<span>{field.description}</span>
</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-start">
<ExclamationTriangleIcon className="h-4 w-4 mr-1 mt-0.5 flex-shrink-0" />
<span>{(error as any)?.message || '输入有误'}</span>
</p>
)}
</div>
);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="relative w-full max-w-4xl max-h-[90vh] bg-white rounded-xl shadow-2xl flex flex-col">
{/* 头部 */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="text-white">
<h3 className="text-xl font-bold">
{mode === 'single' ? '🚀 单次执行' : '⚡ 批量执行'}
</h3>
<p className="text-blue-100 mt-1 text-sm">
{workflow.name}
</p>
<p className="text-blue-200 text-xs mt-1">
{mode === 'single'
? '填写参数执行单次工作流'
: '配置参数组合进行批量执行'
}
</p>
</div>
<button
onClick={onCancel}
className="text-white hover:text-blue-200 transition-colors p-2 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
{/* 表单内容 */}
<form onSubmit={handleSubmit(onFormSubmit)} className="flex flex-col flex-1 min-h-0">
<div
className="flex-1 overflow-y-auto px-6 py-6 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400"
style={{ maxHeight: 'calc(90vh - 200px)' }}
>
{formFields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<InformationCircleIcon className="h-8 w-8 text-gray-400" />
</div>
<p className="text-lg font-medium text-gray-600"></p>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
) : (
<div className="space-y-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-start">
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
<div>
<h4 className="text-sm font-medium text-blue-900"></h4>
<p className="text-sm text-blue-700 mt-1">
{mode === 'single'
? '请填写工作流所需的参数,所有必填项都需要完成。'
: '批量模式下,您可以为每个参数配置多个值,系统将自动生成所有可能的组合。'
}
</p>
</div>
</div>
</div>
{formFields.map(renderField)}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="px-6 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm"
>
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-blue-700 border border-transparent rounded-lg hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm"
>
{isSubmitting ? (
<div className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</div>
) : (
mode === 'single' ? '🚀 开始执行' : '⚡ 开始批量执行'
)}
</button>
</div>
</div>
</form>
</div>
</div>
);
};
export default ReactHookFormWorkflow;

View File

@@ -0,0 +1,227 @@
import React, { useState } from 'react';
import {
PlayIcon,
QueueListIcon,
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ChartBarIcon,
Cog6ToothIcon
} from '@heroicons/react/24/outline';
import { WorkflowInfo } from '../types/uniComfyui';
interface WorkflowCardProps {
workflow: WorkflowInfo;
onSingleExecute: (workflowName: string) => void;
onBatchExecute: (workflowName: string) => void;
onViewDetails?: (workflowName: string) => void;
stats?: {
totalExecutions: number;
successRate: number;
avgExecutionTime: number;
lastExecution?: string;
};
}
export const WorkflowCard: React.FC<WorkflowCardProps> = ({
workflow,
onSingleExecute,
onBatchExecute,
onViewDetails,
stats
}) => {
const [isHovered, setIsHovered] = useState(false);
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return '今天';
} else if (diffDays === 1) {
return '昨天';
} else if (diffDays < 7) {
return `${diffDays}天前`;
} else {
return date.toLocaleDateString('zh-CN');
}
};
const getSuccessRateColor = (rate: number): string => {
if (rate >= 90) return 'text-green-600';
if (rate >= 70) return 'text-yellow-600';
return 'text-red-600';
};
return (
<div
className={`
bg-white rounded-lg border border-gray-200 shadow-sm
transition-all duration-200 ease-in-out
${isHovered ? 'shadow-md border-blue-300 transform -translate-y-1' : ''}
hover:shadow-md hover:border-blue-300 hover:transform hover:-translate-y-1
`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 卡片头部 */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{workflow.name}
</h3>
{workflow.api_spec?.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{workflow.api_spec.description}
</p>
)}
</div>
{onViewDetails && (
<button
onClick={() => onViewDetails(workflow.name)}
className="ml-3 p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="查看详情"
>
<Cog6ToothIcon className="h-5 w-5" />
</button>
)}
</div>
{/* 统计信息 */}
{stats && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<ChartBarIcon className="h-4 w-4 text-gray-400" />
<div className="text-sm">
<span className="text-gray-600">: </span>
<span className="font-medium text-gray-900">
{stats.totalExecutions}
</span>
</div>
</div>
<div className="flex items-center space-x-2">
<CheckCircleIcon className="h-4 w-4 text-gray-400" />
<div className="text-sm">
<span className="text-gray-600">: </span>
<span className={`font-medium ${getSuccessRateColor(stats.successRate)}`}>
{stats.successRate.toFixed(1)}%
</span>
</div>
</div>
<div className="flex items-center space-x-2">
<ClockIcon className="h-4 w-4 text-gray-400" />
<div className="text-sm">
<span className="text-gray-600">: </span>
<span className="font-medium text-gray-900">
{formatTime(stats.avgExecutionTime)}
</span>
</div>
</div>
{stats.lastExecution && (
<div className="flex items-center space-x-2">
<ClockIcon className="h-4 w-4 text-gray-400" />
<div className="text-sm">
<span className="text-gray-600">: </span>
<span className="font-medium text-gray-900">
{formatDate(stats.lastExecution)}
</span>
</div>
</div>
)}
</div>
)}
</div>
{/* 分隔线 */}
<div className="border-t border-gray-100"></div>
{/* 操作按钮 */}
<div className="p-4 bg-gray-50 rounded-b-lg">
<div className="flex space-x-3">
<button
onClick={() => onSingleExecute(workflow.name)}
className="
flex-1 flex items-center justify-center px-4 py-2
bg-blue-600 text-white text-sm font-medium rounded-md
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
transition-colors duration-200
"
>
<PlayIcon className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => onBatchExecute(workflow.name)}
className="
flex-1 flex items-center justify-center px-4 py-2
bg-green-600 text-white text-sm font-medium rounded-md
hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2
transition-colors duration-200
"
>
<QueueListIcon className="h-4 w-4 mr-2" />
</button>
</div>
</div>
{/* 输入参数预览 */}
{workflow.inputs_json_schema?.properties && (
<div className="px-6 pb-4">
<div className="mt-3">
<h4 className="text-xs font-medium text-gray-700 uppercase tracking-wide mb-2">
</h4>
<div className="flex flex-wrap gap-1">
{Object.keys(workflow.inputs_json_schema.properties).slice(0, 3).map((key) => (
<span
key={key}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{key}
</span>
))}
{Object.keys(workflow.inputs_json_schema.properties).length > 3 && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
+{Object.keys(workflow.inputs_json_schema.properties).length - 3}
</span>
)}
</div>
</div>
</div>
)}
{/* 状态指示器 */}
<div className="absolute top-4 right-4">
<div className="flex items-center space-x-1">
<div className="h-2 w-2 bg-green-400 rounded-full"></div>
<span className="text-xs text-gray-500"></span>
</div>
</div>
</div>
);
};
export default WorkflowCard;

View File

@@ -0,0 +1,407 @@
import React, { useState, useEffect } from 'react';
import {
MagnifyingGlassIcon,
ArrowPathIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
ClockIcon
} from '@heroicons/react/24/outline';
import WorkflowCard from '../components/WorkflowCard';
import ReactHookFormWorkflow from '../components/ReactHookFormWorkflow';
import { UniComfyUIService } from '../services/uniComfyuiService';
import { WorkflowInfo, UniComfyUITask, BatchTaskProgress } from '../types/uniComfyui';
interface ExecutionState {
isExecuting: boolean;
taskId?: string;
batchId?: string;
mode: 'single' | 'batch';
workflowName: string;
}
export const UniComfyUIWorkflowManagement: React.FC = () => {
const [workflows, setWorkflows] = useState<WorkflowInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedWorkflow, setSelectedWorkflow] = useState<WorkflowInfo | null>(null);
const [executionMode, setExecutionMode] = useState<'single' | 'batch'>('single');
const [showForm, setShowForm] = useState(false);
const [executionState, setExecutionState] = useState<ExecutionState | null>(null);
const [currentTask, setCurrentTask] = useState<UniComfyUITask | null>(null);
const [currentBatchProgress, setCurrentBatchProgress] = useState<BatchTaskProgress | null>(null);
// 工具函数:提取真正的工作流名称(去掉时间戳后缀)
const extractWorkflowName = (displayName: string): string => {
// 匹配模式:工作流名称 [YYYYMMDDHHMMSS]
const match = displayName.match(/^(.+?)\s*\[\d{14}\]$/);
return match ? match[1].trim() : displayName;
};
// 加载工作流列表
const loadWorkflows = async () => {
try {
setLoading(true);
setError(null);
const workflowList = await UniComfyUIService.getWorkflows();
setWorkflows(workflowList);
} catch (err) {
setError(err instanceof Error ? err.message : '加载工作流失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadWorkflows();
}, []);
// 过滤工作流
const filteredWorkflows = workflows.filter(workflow =>
workflow.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// 处理单次执行
const handleSingleExecute = async (workflowName: string) => {
try {
const workflow = workflows.find(w => w.name === workflowName);
if (!workflow) return;
// 提取真正的工作流名称(去掉时间戳后缀)
const actualWorkflowName = extractWorkflowName(workflowName);
// 获取工作流规范
const workflowSpec = await UniComfyUIService.getWorkflowSpec(actualWorkflowName);
// 保持显示名称为原始名称但API调用使用清理后的名称
workflowSpec.name = workflowName;
setSelectedWorkflow(workflowSpec);
setExecutionMode('single');
setShowForm(true);
} catch (err) {
setError(err instanceof Error ? err.message : '获取工作流规范失败');
}
};
// 处理批量执行
const handleBatchExecute = async (workflowName: string) => {
try {
const workflow = workflows.find(w => w.name === workflowName);
if (!workflow) return;
// 提取真正的工作流名称(去掉时间戳后缀)
const actualWorkflowName = extractWorkflowName(workflowName);
// 获取工作流规范
const workflowSpec = await UniComfyUIService.getWorkflowSpec(actualWorkflowName);
// 保持显示名称为原始名称但API调用使用清理后的名称
workflowSpec.name = workflowName;
setSelectedWorkflow(workflowSpec);
setExecutionMode('batch');
setShowForm(true);
} catch (err) {
setError(err instanceof Error ? err.message : '获取工作流规范失败');
}
};
// 处理表单提交
const handleFormSubmit = async (formData: any) => {
if (!selectedWorkflow) return;
try {
// 先设置执行状态,显示加载状态
setExecutionState({
isExecuting: true,
mode: executionMode,
workflowName: selectedWorkflow.name
});
if (executionMode === 'single') {
// 单次执行 - 使用清理后的工作流名称
const actualWorkflowName = extractWorkflowName(selectedWorkflow.name);
const taskId = await UniComfyUIService.executeWorkflow(
actualWorkflowName,
formData
);
// 更新执行状态包含taskId
setExecutionState({
isExecuting: true,
taskId,
mode: 'single',
workflowName: selectedWorkflow.name
});
// 执行成功后关闭表单
setShowForm(false);
// 开始轮询任务状态
UniComfyUIService.pollTaskStatus(
taskId,
(task) => setCurrentTask(task),
60,
2000
).then((finalTask) => {
setCurrentTask(finalTask);
setExecutionState(null);
}).catch((err) => {
setError(err.message);
setExecutionState(null);
});
} else {
// 批量执行
let combinations: any[];
// 使用清理后的工作流名称
const actualWorkflowName = extractWorkflowName(selectedWorkflow.name);
if (formData.combinations) {
// RJSF已经生成了组合
combinations = formData.combinations;
} else {
// 手动生成参数组合
const parameterSets = formData.formData || formData;
const result = await UniComfyUIService.generateParameterCombinations({
workflow_name: actualWorkflowName,
parameter_sets: parameterSets
});
combinations = result.combinations;
}
// 执行批量任务
const batchId = await UniComfyUIService.executeBatchWorkflow({
workflow_name: actualWorkflowName,
input_params_list: combinations
});
// 更新执行状态包含batchId
setExecutionState({
isExecuting: true,
batchId,
mode: 'batch',
workflowName: selectedWorkflow.name
});
// 执行成功后关闭表单
setShowForm(false);
// 开始轮询批量进度
UniComfyUIService.pollBatchProgress(
batchId,
(progress) => setCurrentBatchProgress(progress),
300,
5000
).then((finalProgress) => {
setCurrentBatchProgress(finalProgress);
setExecutionState(null);
}).catch((err) => {
setError(err.message);
setExecutionState(null);
});
}
} catch (err) {
setError(err instanceof Error ? err.message : '执行失败');
setExecutionState(null);
// 出错时不关闭表单,让用户可以重试
}
};
// 关闭表单
const handleFormCancel = () => {
setShowForm(false);
setSelectedWorkflow(null);
};
// 渲染执行状态
const renderExecutionStatus = () => {
if (!executionState && !currentTask && !currentBatchProgress) return null;
return (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{executionState?.isExecuting ? (
<ClockIcon className="h-5 w-5 text-blue-600 animate-spin" />
) : currentTask?.status === 'completed' ? (
<CheckCircleIcon className="h-5 w-5 text-green-600" />
) : currentTask?.status === 'failed' ? (
<ExclamationTriangleIcon className="h-5 w-5 text-red-600" />
) : (
<ClockIcon className="h-5 w-5 text-blue-600" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">
{executionState?.mode === 'single' ? '单次执行' : '批量执行'} - {executionState?.workflowName}
</h4>
{currentTask && (
<p className="text-sm text-gray-600">
: {UniComfyUIService.formatTaskStatus(currentTask.status).text}
{currentTask.started_at && currentTask.completed_at && (
<span className="ml-2">
: {UniComfyUIService.formatExecutionTime(
(new Date(currentTask.completed_at).getTime() - new Date(currentTask.started_at).getTime()) / 1000
)}
</span>
)}
</p>
)}
{currentBatchProgress && (
<p className="text-sm text-gray-600">
: {currentBatchProgress.completed_count + currentBatchProgress.failed_count}/{currentBatchProgress.total_count}
({currentBatchProgress.progress_percentage.toFixed(1)}%)
{currentBatchProgress.failed_count > 0 && (
<span className="text-red-600 ml-2">
: {currentBatchProgress.failed_count}
</span>
)}
</p>
)}
</div>
</div>
{!executionState?.isExecuting && (
<button
onClick={() => {
setCurrentTask(null);
setCurrentBatchProgress(null);
}}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
{currentBatchProgress && (
<div className="mt-3">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${currentBatchProgress.progress_percentage}%` }}
></div>
</div>
</div>
)}
</div>
);
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 页面头部 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">ComfyUI </h1>
<p className="mt-2 text-gray-600">
ComfyUI
</p>
</div>
{/* 执行状态 */}
{renderExecutionStatus()}
{/* 搜索和操作栏 */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
<div className="relative flex-1 max-w-md">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="搜索工作流..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
onClick={loadWorkflows}
disabled={loading}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
>
<ArrowPathIcon className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* 错误提示 */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mr-2" />
<span className="text-red-800">{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-red-600 hover:text-red-800"
>
×
</button>
</div>
</div>
)}
{/* 工作流网格 */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, index) => (
<div key={index} className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-2/3 mb-4"></div>
<div className="flex space-x-3">
<div className="h-8 bg-gray-200 rounded flex-1"></div>
<div className="h-8 bg-gray-200 rounded flex-1"></div>
</div>
</div>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-500 text-lg">
{searchTerm ? '未找到匹配的工作流' : '暂无工作流'}
</div>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="mt-2 text-blue-600 hover:text-blue-800"
>
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredWorkflows.map((workflow) => (
<WorkflowCard
key={workflow.name}
workflow={workflow}
onSingleExecute={handleSingleExecute}
onBatchExecute={handleBatchExecute}
stats={{
totalExecutions: 0,
successRate: 0,
avgExecutionTime: 0
}}
/>
))}
</div>
)}
{/* React Hook Form表单模态框 */}
{showForm && selectedWorkflow && (
<ReactHookFormWorkflow
workflow={selectedWorkflow}
mode={executionMode}
onSubmit={handleFormSubmit}
onCancel={handleFormCancel}
isSubmitting={executionState?.isExecuting || false}
/>
)}
</div>
</div>
);
};
export default UniComfyUIWorkflowManagement;

View File

@@ -0,0 +1,335 @@
import { invoke } from '@tauri-apps/api/core';
import {
WorkflowInfo,
ParameterCombinationConfig,
ParameterCombinationResult,
CreateBatchTaskRequest,
UniComfyUITask,
BatchTaskProgress,
WorkflowStats,
ApiResponse,
ListQueryParams,
} from '../types/uniComfyui';
/**
* UniComfyUI 工作流管理服务
*/
export class UniComfyUIService {
/**
* 获取所有工作流列表
*/
static async getWorkflows(): Promise<WorkflowInfo[]> {
try {
const result = await invoke<WorkflowInfo[]>('get_workflows');
return result;
} catch (error) {
console.error('Failed to get workflows:', error);
throw new Error(`获取工作流列表失败: ${error}`);
}
}
/**
* 获取工作流详细规范
*/
static async getWorkflowSpec(workflowName: string): Promise<WorkflowInfo> {
try {
const result = await invoke<WorkflowInfo>('get_workflow_spec', {
workflowName,
});
return result;
} catch (error) {
console.error('Failed to get workflow spec:', error);
throw new Error(`获取工作流规范失败: ${error}`);
}
}
/**
* 执行单个工作流
*/
static async executeWorkflow(
workflowName: string,
inputParams: any
): Promise<string> {
try {
const result = await invoke<string>('execute_uni_workflow', {
workflowName,
inputParams,
});
return result;
} catch (error) {
console.error('Failed to execute workflow:', error);
throw new Error(`执行工作流失败: ${error}`);
}
}
/**
* 生成参数组合
*/
static async generateParameterCombinations(
config: ParameterCombinationConfig
): Promise<ParameterCombinationResult> {
try {
const result = await invoke<ParameterCombinationResult>(
'generate_parameter_combinations',
{ config }
);
return result;
} catch (error) {
console.error('Failed to generate parameter combinations:', error);
throw new Error(`生成参数组合失败: ${error}`);
}
}
/**
* 执行批量工作流
*/
static async executeBatchWorkflow(
request: CreateBatchTaskRequest
): Promise<string> {
try {
const result = await invoke<string>('execute_batch_workflow', {
request,
});
return result;
} catch (error) {
console.error('Failed to execute batch workflow:', error);
throw new Error(`执行批量工作流失败: ${error}`);
}
}
/**
* 获取任务状态
*/
static async getTaskStatus(
workflowRunId: string
): Promise<UniComfyUITask | null> {
try {
const result = await invoke<UniComfyUITask | null>('get_task_status', {
workflowRunId,
});
return result;
} catch (error) {
console.error('Failed to get task status:', error);
throw new Error(`获取任务状态失败: ${error}`);
}
}
/**
* 获取批量任务进度
*/
static async getBatchProgress(
batchId: string
): Promise<BatchTaskProgress | null> {
try {
const result = await invoke<BatchTaskProgress | null>(
'get_batch_progress',
{ batchId }
);
return result;
} catch (error) {
console.error('Failed to get batch progress:', error);
throw new Error(`获取批量任务进度失败: ${error}`);
}
}
/**
* 获取任务列表
*/
static async getTasks(params?: {
workflowName?: string;
taskType?: string;
status?: string;
limit?: number;
offset?: number;
}): Promise<UniComfyUITask[]> {
try {
const result = await invoke<UniComfyUITask[]>('get_tasks', {
workflowName: params?.workflowName || null,
taskType: params?.taskType || null,
status: params?.status || null,
limit: params?.limit || null,
offset: params?.offset || null,
});
return result;
} catch (error) {
console.error('Failed to get tasks:', error);
throw new Error(`获取任务列表失败: ${error}`);
}
}
/**
* 初始化数据库表
*/
static async initTables(): Promise<void> {
try {
await invoke('init_uni_comfyui_tables');
} catch (error) {
console.error('Failed to initialize tables:', error);
throw new Error(`初始化数据库表失败: ${error}`);
}
}
/**
* 获取工作流统计信息
*/
static async getWorkflowStats(
workflowName?: string
): Promise<WorkflowStats[]> {
try {
const result = await invoke<WorkflowStats[]>('get_workflow_stats', {
workflowName: workflowName || null,
});
return result;
} catch (error) {
console.error('Failed to get workflow stats:', error);
throw new Error(`获取工作流统计失败: ${error}`);
}
}
/**
* 轮询任务状态直到完成
*/
static async pollTaskStatus(
taskId: string,
onProgress?: (task: UniComfyUITask) => void,
maxAttempts: number = 60,
interval: number = 2000
): Promise<UniComfyUITask> {
let attempts = 0;
return new Promise((resolve, reject) => {
const poll = async () => {
try {
attempts++;
const task = await this.getTaskStatus(taskId);
if (!task) {
if (attempts >= maxAttempts) {
reject(new Error('任务不存在或已被删除'));
return;
}
setTimeout(poll, interval);
return;
}
if (onProgress) {
onProgress(task);
}
if (task.status === 'completed' || task.status === 'failed') {
resolve(task);
return;
}
if (attempts >= maxAttempts) {
reject(new Error('任务执行超时'));
return;
}
setTimeout(poll, interval);
} catch (error) {
reject(error);
}
};
poll();
});
}
/**
* 轮询批量任务进度直到完成
*/
static async pollBatchProgress(
batchId: string,
onProgress?: (progress: BatchTaskProgress) => void,
maxAttempts: number = 300,
interval: number = 5000
): Promise<BatchTaskProgress> {
let attempts = 0;
return new Promise((resolve, reject) => {
const poll = async () => {
try {
attempts++;
const progress = await this.getBatchProgress(batchId);
if (!progress) {
if (attempts >= maxAttempts) {
reject(new Error('批量任务不存在或已被删除'));
return;
}
setTimeout(poll, interval);
return;
}
if (onProgress) {
onProgress(progress);
}
if (progress.status === 'completed' || progress.status === 'failed') {
resolve(progress);
return;
}
if (attempts >= maxAttempts) {
reject(new Error('批量任务执行超时'));
return;
}
setTimeout(poll, interval);
} catch (error) {
reject(error);
}
};
poll();
});
}
/**
* 格式化执行时间
*/
static formatExecutionTime(seconds?: number): string {
if (!seconds) return '未知';
if (seconds < 60) {
return `${seconds.toFixed(1)}`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds.toFixed(0)}`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}小时${minutes}分钟`;
}
}
/**
* 格式化任务状态
*/
static formatTaskStatus(status: string): { text: string; color: string } {
switch (status) {
case 'queued':
return { text: '排队中', color: 'text-yellow-600' };
case 'running':
return { text: '执行中', color: 'text-blue-600' };
case 'completed':
return { text: '已完成', color: 'text-green-600' };
case 'failed':
return { text: '执行失败', color: 'text-red-600' };
default:
return { text: '未知', color: 'text-gray-600' };
}
}
/**
* 计算成功率
*/
static calculateSuccessRate(tasks: UniComfyUITask[]): number {
if (tasks.length === 0) return 0;
const completedTasks = tasks.filter(task => task.status === 'completed');
return (completedTasks.length / tasks.length) * 100;
}
}

View File

@@ -0,0 +1,238 @@
// UniComfyUI 工作流管理相关类型定义
export interface WorkflowInfo {
name: string;
workflow: any;
api_spec?: any;
inputs_json_schema?: any;
}
export interface WorkflowListResponse {
workflows: WorkflowInfo[];
}
export interface ParameterCombinationConfig {
workflow_name: string;
parameter_sets: any;
max_combinations?: number;
}
export interface ParameterCombinationResult {
combinations: any[];
total_count: number;
truncated: boolean;
}
export interface CreateBatchTaskRequest {
workflow_name: string;
input_params_list: any[];
server_url?: string;
}
export interface UniComfyUITask {
id?: number;
workflow_run_id: string;
workflow_name: string;
task_type: 'single' | 'batch';
status: 'queued' | 'running' | 'completed' | 'failed';
input_params: any;
result_data?: any;
error_message?: string;
created_at: string;
started_at?: string;
completed_at?: string;
server_url?: string;
}
export interface BatchTaskProgress {
batch_id: string;
total_count: number;
completed_count: number;
failed_count: number;
running_count: number;
pending_count: number;
status: 'pending' | 'running' | 'completed' | 'failed';
progress_percentage: number;
}
// 表单字段类型
export interface FormField {
name: string;
type: string;
title: string;
description?: string;
required: boolean;
default?: any;
enum?: any[];
format?: string;
widget_name?: string;
}
// 动态表单配置
export interface DynamicFormConfig {
workflow_name: string;
fields: FormField[];
schema: any;
}
// 执行模式
export type ExecutionMode = 'single' | 'batch';
// 工作流执行请求
export interface WorkflowExecutionRequest {
workflow_name: string;
mode: ExecutionMode;
input_params: any;
batch_params?: any[];
}
// 工作流执行响应
export interface WorkflowExecutionResponse {
task_id?: string;
batch_id?: string;
status: string;
message?: string;
}
// 任务状态查询响应
export interface TaskStatusResponse {
task?: UniComfyUITask;
batch_progress?: BatchTaskProgress;
}
// 工作流统计信息
export interface WorkflowStats {
workflow_name: string;
task_type: string;
status: string;
count: number;
avg_execution_time_seconds?: number;
first_execution?: string;
last_execution?: string;
}
// 批量参数配置
export interface BatchParameterConfig {
[key: string]: any[] | any;
}
// 文件上传配置
export interface FileUploadConfig {
field_name: string;
accept: string;
multiple: boolean;
max_size?: number;
}
// 工作流卡片数据
export interface WorkflowCardData {
name: string;
description?: string;
last_execution?: string;
total_executions?: number;
success_rate?: number;
avg_execution_time?: number;
tags?: string[];
category?: string;
}
// 执行历史记录
export interface ExecutionHistory {
id: string;
workflow_name: string;
execution_type: 'single' | 'batch';
status: 'queued' | 'running' | 'completed' | 'failed';
created_at: string;
completed_at?: string;
execution_time?: number;
input_summary: string;
result_summary?: string;
error_message?: string;
}
// 批量执行进度详情
export interface BatchExecutionDetail {
batch_id: string;
workflow_name: string;
total_count: number;
completed_count: number;
failed_count: number;
running_count: number;
pending_count: number;
progress_percentage: number;
status: 'pending' | 'running' | 'completed' | 'failed';
created_at: string;
estimated_completion?: string;
failed_tasks?: UniComfyUITask[];
}
// 表单验证规则
export interface ValidationRule {
required?: boolean;
min?: number;
max?: number;
pattern?: string;
custom?: (value: any) => string | null;
}
// 表单字段配置
export interface FormFieldConfig extends FormField {
validation?: ValidationRule;
dependencies?: string[];
conditional?: {
field: string;
value: any;
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains';
};
}
// 工作流模板
export interface WorkflowTemplate {
id: string;
name: string;
description: string;
workflow_name: string;
default_params: any;
param_presets: { [key: string]: any };
tags: string[];
category: string;
created_at: string;
updated_at: string;
}
// API 响应包装器
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
// 分页参数
export interface PaginationParams {
page: number;
page_size: number;
total?: number;
}
// 查询过滤器
export interface QueryFilter {
workflow_name?: string;
task_type?: 'single' | 'batch';
status?: 'queued' | 'running' | 'completed' | 'failed';
date_from?: string;
date_to?: string;
}
// 排序参数
export interface SortParams {
field: string;
direction: 'asc' | 'desc';
}
// 列表查询参数
export interface ListQueryParams extends PaginationParams {
filter?: QueryFilter;
sort?: SortParams;
search?: string;
}

View File

@@ -299,5 +299,7 @@ export default {
},
},
},
plugins: [],
plugins: [
require('tailwind-scrollbar')({ nocompatible: true }),
],
}

View File

@@ -8,8 +8,6 @@ repository = "https://github.com/your-repo/uni-comfyui-sdk"
keywords = ["comfyui", "api", "sdk", "workflow"]
categories = ["api-bindings", "web-programming::http-client"]
[workspace]
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }

1266
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff