feat: polish workflow studio and workspace assets
This commit is contained in:
@@ -60,6 +60,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
|||||||
|
|
||||||
# Streaming
|
# Streaming
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,14 @@
|
|||||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||||
|
|
||||||
|
## 🖼️ 界面预览
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|||||||
BIN
Binary file not shown.
+11
-1
@@ -28,9 +28,19 @@ proxy_log_retention_days = 30
|
|||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
|
# boosapi = use embedded Workflow API bridge; external/cloud = proxy Workflow
|
||||||
|
# API and WebSocket to a full ComfyUI-compatible backend.
|
||||||
|
backend = "cloud"
|
||||||
|
docker_image = "boosapi-comfyui:multi-tenant"
|
||||||
|
container_name = "boosapi-comfyui-workflow"
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
max_restarts = 5
|
||||||
base_url = "http://127.0.0.1:8188"
|
base_url = "https://cloud.comfy.org"
|
||||||
|
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||||
|
# For Comfy Cloud:
|
||||||
|
# backend = "cloud"
|
||||||
|
# base_url = "https://cloud.comfy.org"
|
||||||
|
# api_key = "your-comfy-cloud-api-key"
|
||||||
agent_model = "gpt-5.5"
|
agent_model = "gpt-5.5"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
FROM boosapi-comfyui:multi-tenant
|
||||||
|
|
||||||
|
ARG COMFYUI_TAG=v0.22.2
|
||||||
|
|
||||||
|
WORKDIR /workspace/ComfyUI
|
||||||
|
|
||||||
|
COPY docker/build-comfyui-src /tmp/ComfyUI-updated
|
||||||
|
|
||||||
|
RUN find /workspace/ComfyUI -mindepth 1 \
|
||||||
|
! -path '/workspace/ComfyUI/venv' \
|
||||||
|
! -path '/workspace/ComfyUI/venv/*' \
|
||||||
|
-exec rm -rf {} + \
|
||||||
|
&& cp -a /tmp/ComfyUI-updated/. /workspace/ComfyUI/ \
|
||||||
|
&& rm -rf /tmp/ComfyUI-updated \
|
||||||
|
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
|
||||||
|
uv pip install --python venv/bin/python -r requirements.txt \
|
||||||
|
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
uv pip install --python venv/bin/python six \
|
||||||
|
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
||||||
|
|
||||||
|
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
||||||
|
|
||||||
|
RUN python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
path = Path("/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py")
|
||||||
|
if not path.exists():
|
||||||
|
raise SystemExit("BoosAPI custom node missing after ComfyUI update")
|
||||||
|
PY
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -4,7 +4,7 @@ import { spawn } from 'child_process';
|
|||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || 'https://github.com/Comfy-Org/ComfyUI_frontend.git';
|
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || 'https://github.com/Comfy-Org/ComfyUI_frontend.git';
|
||||||
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.9.19';
|
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1';
|
||||||
const DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
const DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
||||||
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
||||||
|
|
||||||
@@ -27,8 +27,14 @@ function run(command, args, options = {}) {
|
|||||||
await fs.mkdir(path.dirname(DIR), { recursive: true });
|
await fs.mkdir(path.dirname(DIR), { recursive: true });
|
||||||
await fs.rm(DIR, { recursive: true, force: true });
|
await fs.rm(DIR, { recursive: true, force: true });
|
||||||
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, DIR]);
|
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, DIR]);
|
||||||
await run('npm', ['ci'], { cwd: DIR });
|
const hasPnpmLock = await fs.access(path.join(DIR, 'pnpm-lock.yaml')).then(() => true, () => false);
|
||||||
await run('npm', ['run', 'build'], { cwd: DIR });
|
if (hasPnpmLock) {
|
||||||
|
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR });
|
||||||
|
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: DIR });
|
||||||
|
} else {
|
||||||
|
await run('npm', ['ci'], { cwd: DIR });
|
||||||
|
await run('npm', ['run', 'build'], { cwd: DIR });
|
||||||
|
}
|
||||||
await fs.rm(DIST, { recursive: true, force: true });
|
await fs.rm(DIST, { recursive: true, force: true });
|
||||||
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
|
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
|
||||||
console.log(`workflow-ui installed to ${DIST}`);
|
console.log(`workflow-ui installed to ${DIST}`);
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ pub struct ProxyConfig {
|
|||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct ComfyUIConfig {
|
pub struct ComfyUIConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend: ComfyUIBackend,
|
||||||
|
#[serde(default = "default_comfyui_docker_image")]
|
||||||
|
pub docker_image: String,
|
||||||
|
#[serde(default = "default_comfyui_container_name")]
|
||||||
|
pub container_name: String,
|
||||||
pub install_dir: Option<PathBuf>,
|
pub install_dir: Option<PathBuf>,
|
||||||
pub python_bin: String,
|
pub python_bin: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -59,13 +65,35 @@ pub struct ComfyUIConfig {
|
|||||||
pub max_restarts: u32,
|
pub max_restarts: u32,
|
||||||
pub external_url: Option<String>,
|
pub external_url: Option<String>,
|
||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
|
pub api_key: Option<String>,
|
||||||
pub agent_model: String,
|
pub agent_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ComfyUIBackend {
|
||||||
|
#[default]
|
||||||
|
Boosapi,
|
||||||
|
External,
|
||||||
|
Cloud,
|
||||||
|
Docker,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_comfyui_docker_image() -> String {
|
||||||
|
"boosapi-comfyui:multi-tenant".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_comfyui_container_name() -> String {
|
||||||
|
"boosapi-comfyui-workflow".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ComfyUIConfig {
|
impl Default for ComfyUIConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
backend: ComfyUIBackend::Boosapi,
|
||||||
|
docker_image: default_comfyui_docker_image(),
|
||||||
|
container_name: default_comfyui_container_name(),
|
||||||
install_dir: None,
|
install_dir: None,
|
||||||
python_bin: "python3".to_string(),
|
python_bin: "python3".to_string(),
|
||||||
port: 8188,
|
port: 8188,
|
||||||
@@ -73,12 +101,25 @@ impl Default for ComfyUIConfig {
|
|||||||
max_restarts: 5,
|
max_restarts: 5,
|
||||||
external_url: None,
|
external_url: None,
|
||||||
base_url: Some("http://127.0.0.1:8188".to_string()),
|
base_url: Some("http://127.0.0.1:8188".to_string()),
|
||||||
|
api_key: None,
|
||||||
agent_model: "gpt-4o".to_string(),
|
agent_model: "gpt-4o".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComfyUIConfig {
|
impl ComfyUIConfig {
|
||||||
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
|
matches!(self.backend, ComfyUIBackend::External | ComfyUIBackend::Cloud)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uses_cloud_backend(&self) -> bool {
|
||||||
|
matches!(self.backend, ComfyUIBackend::Cloud)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uses_docker_backend(&self) -> bool {
|
||||||
|
matches!(self.backend, ComfyUIBackend::Docker)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn target_base_url(&self) -> String {
|
pub fn target_base_url(&self) -> String {
|
||||||
self.external_url
|
self.external_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct AppState {
|
|||||||
pub db: Arc<SqlitePool>,
|
pub db: Arc<SqlitePool>,
|
||||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||||
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
||||||
|
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
|
||||||
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ impl AppState {
|
|||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
|
register_captcha: Arc::new(
|
||||||
|
services::register_captcha::RegisterCaptchaStore::new(),
|
||||||
|
),
|
||||||
registration_limiter: Arc::new(
|
registration_limiter: Arc::new(
|
||||||
services::registration_limiter::RegistrationLimiter::new(),
|
services::registration_limiter::RegistrationLimiter::new(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::{
|
|||||||
auth::{jwt::generate_jwt, password::verify_password},
|
auth::{jwt::generate_jwt, password::verify_password},
|
||||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
services::register_captcha::CaptchaVerifyError,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -25,6 +26,10 @@ pub struct RegisterRequest {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
#[serde(rename = "captchaId", alias = "captcha_id")]
|
||||||
|
pub captcha_id: String,
|
||||||
|
#[serde(rename = "captchaCode", alias = "captcha_code")]
|
||||||
|
pub captcha_code: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -59,6 +64,8 @@ pub struct UserProfile {
|
|||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/api/auth/register-captcha", get(register_captcha))
|
||||||
|
.route("/api/users/register-captcha", get(register_captcha))
|
||||||
.route("/api/auth/register", post(register))
|
.route("/api/auth/register", post(register))
|
||||||
.route("/api/users/register", post(register))
|
.route("/api/users/register", post(register))
|
||||||
.route("/api/auth/login", post(login))
|
.route("/api/auth/login", post(login))
|
||||||
@@ -73,6 +80,27 @@ pub fn routes() -> Router<AppState> {
|
|||||||
|
|
||||||
// ── Handlers ─────────────────────────────────────────────────────────
|
// ── Handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GET /api/users/register-captcha
|
||||||
|
async fn register_captcha(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let challenge = state.register_captcha.issue(Some(addr.ip()));
|
||||||
|
let id = challenge.id;
|
||||||
|
let image = challenge.image;
|
||||||
|
let expires_in_seconds = challenge.expires_in_seconds;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": id,
|
||||||
|
"image": image,
|
||||||
|
"expiresInSeconds": expires_in_seconds,
|
||||||
|
"data": {
|
||||||
|
"id": id,
|
||||||
|
"image": image,
|
||||||
|
"expiresInSeconds": expires_in_seconds,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/auth/register
|
/// POST /api/auth/register
|
||||||
async fn register(
|
async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -93,6 +121,19 @@ async fn register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client_ip = Some(addr.ip());
|
let client_ip = Some(addr.ip());
|
||||||
|
if let Err(err) =
|
||||||
|
state
|
||||||
|
.register_captcha
|
||||||
|
.verify(&payload.captcha_id, &payload.captcha_code, client_ip)
|
||||||
|
{
|
||||||
|
let message = match err {
|
||||||
|
CaptchaVerifyError::Missing => "请输入图像验证码",
|
||||||
|
CaptchaVerifyError::Expired => "图像验证码已过期,请刷新后重试",
|
||||||
|
CaptchaVerifyError::Mismatch => "图像验证码错误,请重新输入",
|
||||||
|
};
|
||||||
|
return Err(AppError::BadRequest(message.into()));
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(limit) = state
|
if let Err(limit) = state
|
||||||
.registration_limiter
|
.registration_limiter
|
||||||
.check_and_record_attempt(client_ip, &payload.email)
|
.check_and_record_attempt(client_ip, &payload.email)
|
||||||
|
|||||||
+324
-1
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{Path, Query, State},
|
extract::{ws::Message, Path, Query, State, WebSocketUpgrade},
|
||||||
http::{HeaderMap, Method, StatusCode, Uri},
|
http::{HeaderMap, Method, StatusCode, Uri},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::{any, get, post},
|
routing::{any, get, post},
|
||||||
@@ -20,7 +20,9 @@ use axum::{
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tokio_tungstenite::{connect_async, tungstenite};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
use tungstenite::client::IntoClientRequest;
|
||||||
|
|
||||||
/// Shared HTTP client
|
/// Shared HTTP client
|
||||||
fn http_client() -> Client {
|
fn http_client() -> Client {
|
||||||
@@ -85,11 +87,186 @@ pub fn routes() -> Router<AppState> {
|
|||||||
// Public ComfyUI reverse proxy for the embedded iframe UI.
|
// Public ComfyUI reverse proxy for the embedded iframe UI.
|
||||||
.route("/api/comfyui/proxy", any(proxy_static_root))
|
.route("/api/comfyui/proxy", any(proxy_static_root))
|
||||||
.route("/api/comfyui/proxy/", any(proxy_static_root))
|
.route("/api/comfyui/proxy/", any(proxy_static_root))
|
||||||
|
.route("/api/comfyui/proxy/ws", get(proxy_ws))
|
||||||
|
.route(
|
||||||
|
"/api/comfyui/proxy/api/experiment/models",
|
||||||
|
get(proxy_model_folders),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/comfyui/proxy/api/experiment/models/{folder}",
|
||||||
|
get(proxy_models_in_folder),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/comfyui/proxy/api/view_metadata/{folder}",
|
||||||
|
get(proxy_model_metadata),
|
||||||
|
)
|
||||||
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
|
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
|
||||||
// HTML page (public)
|
// HTML page (public)
|
||||||
.route("/comfyui", get(comfyui_page))
|
.route("/comfyui", get(comfyui_page))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn proxy_model_folders(State(state): State<AppState>) -> AppResult<Response> {
|
||||||
|
let mut folders = proxy_to_comfyui_json(&state, "GET", "/api/experiment/models", None).await?;
|
||||||
|
if let Some(items) = folders.as_array_mut() {
|
||||||
|
if !items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.get("name").and_then(Value::as_str) == Some("boosapi_models"))
|
||||||
|
{
|
||||||
|
items.insert(0, serde_json::json!({ "name": "boosapi_models" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||||
|
folders.to_string(),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_models_in_folder(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(folder): Path<String>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
if folder == "boosapi_models" {
|
||||||
|
let models = list_boosapi_models(&state).await?;
|
||||||
|
let items: Vec<Value> = models
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| serde_json::json!({ "name": name, "pathIndex": 0 }))
|
||||||
|
.collect();
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||||
|
Value::Array(items).to_string(),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
let path = format!("/api/experiment/models/{}", urlencoding(&folder));
|
||||||
|
proxy_to_comfyui_response(&state, "GET", &path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_model_metadata(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(folder): Path<String>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
if folder == "boosapi_models" {
|
||||||
|
let filename = uri
|
||||||
|
.query()
|
||||||
|
.and_then(|query| {
|
||||||
|
query.split('&').find_map(|part| {
|
||||||
|
let (key, value) = part.split_once('=')?;
|
||||||
|
(key == "filename").then_some(value.replace("%2F", "/").replace("%20", " "))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "BoosAPI model".to_string());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"title": filename,
|
||||||
|
"author": "BoosAPI",
|
||||||
|
"description": "BoosAPI 后台模型路由。双击可创建 BoosAPI 节点,实际调用本地 /v1 模型网关。",
|
||||||
|
"modelspec.tags": "boosapi,api,model",
|
||||||
|
});
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||||
|
body.to_string(),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
let path = match uri.query() {
|
||||||
|
Some(query) => format!("/api/view_metadata/{}?{}", urlencoding(&folder), query),
|
||||||
|
None => format!("/api/view_metadata/{}", urlencoding(&folder)),
|
||||||
|
};
|
||||||
|
proxy_to_comfyui_response(&state, "GET", &path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_ws(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
uri: Uri,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let mut target = comfyui_base(&state)
|
||||||
|
.replacen("https://", "wss://", 1)
|
||||||
|
.replacen("http://", "ws://", 1);
|
||||||
|
target.push_str("/ws");
|
||||||
|
if let Some(query) = uri.query() {
|
||||||
|
target.push('?');
|
||||||
|
target.push_str(query);
|
||||||
|
}
|
||||||
|
Ok(ws.on_upgrade(move |socket| proxy_ws_bridge(socket, target)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target: String) {
|
||||||
|
let request = match target.as_str().into_client_request() {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(err) => {
|
||||||
|
let _ = client_socket
|
||||||
|
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into()))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Ok((upstream_socket, _)) = connect_async(request).await else {
|
||||||
|
let _ = client_socket
|
||||||
|
.send(Message::Text("ComfyUI websocket connect failed".into()))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
let (mut upstream_write, mut upstream_read) = upstream_socket.split();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
from_upstream = upstream_read.next() => {
|
||||||
|
match from_upstream {
|
||||||
|
Some(Ok(message)) => {
|
||||||
|
let outgoing = match message {
|
||||||
|
tungstenite::Message::Text(text) => Message::Text(text.to_string().into()),
|
||||||
|
tungstenite::Message::Binary(bytes) => Message::Binary(bytes.to_vec().into()),
|
||||||
|
tungstenite::Message::Ping(bytes) => Message::Ping(bytes.to_vec().into()),
|
||||||
|
tungstenite::Message::Pong(bytes) => Message::Pong(bytes.to_vec().into()),
|
||||||
|
tungstenite::Message::Close(frame) => {
|
||||||
|
let _ = client_socket.send(Message::Close(frame.map(|frame| axum::extract::ws::CloseFrame {
|
||||||
|
code: frame.code.into(),
|
||||||
|
reason: frame.reason.to_string().into(),
|
||||||
|
}))).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tungstenite::Message::Frame(_) => continue,
|
||||||
|
};
|
||||||
|
if client_socket.send(outgoing).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(_)) | None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
from_client = client_socket.recv() => {
|
||||||
|
match from_client {
|
||||||
|
Some(Ok(message)) => {
|
||||||
|
let outgoing = match message {
|
||||||
|
Message::Text(text) => tungstenite::Message::Text(text.to_string().into()),
|
||||||
|
Message::Binary(bytes) => tungstenite::Message::Binary(bytes.to_vec().into()),
|
||||||
|
Message::Ping(bytes) => tungstenite::Message::Ping(bytes.to_vec().into()),
|
||||||
|
Message::Pong(bytes) => tungstenite::Message::Pong(bytes.to_vec().into()),
|
||||||
|
Message::Close(frame) => {
|
||||||
|
let _ = upstream_write.send(tungstenite::Message::Close(frame.map(|frame| tungstenite::protocol::CloseFrame {
|
||||||
|
code: frame.code.into(),
|
||||||
|
reason: frame.reason.to_string().into(),
|
||||||
|
}))).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if upstream_write.send(outgoing).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(_)) | None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Business route handlers (require auth) ───────────────────────────
|
// ── Business route handlers (require auth) ───────────────────────────
|
||||||
|
|
||||||
/// POST /api/comfyui/prompt
|
/// POST /api/comfyui/prompt
|
||||||
@@ -243,6 +420,7 @@ async fn proxy_static_request(
|
|||||||
.replace("comfyui", "BoosAPI");
|
.replace("comfyui", "BoosAPI");
|
||||||
// Rewrite URLs
|
// Rewrite URLs
|
||||||
let rewritten = rewrite_proxy_urls(&rebranded);
|
let rewritten = rewrite_proxy_urls(&rebranded);
|
||||||
|
let rewritten = inject_boosapi_model_library_patch(&rewritten);
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
@@ -274,6 +452,96 @@ async fn proxy_static_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn proxy_to_comfyui_json(
|
||||||
|
state: &AppState,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
let client = http_client();
|
||||||
|
let url = format!("{}{}", comfyui_base(state), path);
|
||||||
|
let req = match method {
|
||||||
|
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
|
||||||
|
_ => client.get(&url),
|
||||||
|
};
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::UpstreamError(text));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&text).map_err(|e| AppError::UpstreamError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_to_comfyui_response(
|
||||||
|
state: &AppState,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let client = http_client();
|
||||||
|
let url = format!("{}{}", comfyui_base(state), path);
|
||||||
|
let req = match method {
|
||||||
|
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
|
||||||
|
_ => client.get(&url),
|
||||||
|
};
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let content_type = resp
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/json")
|
||||||
|
.to_string();
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||||
|
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_boosapi_models(state: &AppState) -> AppResult<Vec<String>> {
|
||||||
|
let rows: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT DISTINCT COALESCE(NULLIF(TRIM(model_mapping), ''), model_pattern)
|
||||||
|
FROM token_routes
|
||||||
|
WHERE COALESCE(enabled, 1) = 1
|
||||||
|
AND TRIM(COALESCE(model_pattern, '')) <> ''
|
||||||
|
ORDER BY 1 COLLATE NOCASE",
|
||||||
|
)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut models: Vec<String> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.filter(|value| seen.insert(value.clone()))
|
||||||
|
.collect();
|
||||||
|
if models.is_empty() {
|
||||||
|
models.extend([
|
||||||
|
"gpt-5.5".to_string(),
|
||||||
|
"gpt-5.4".to_string(),
|
||||||
|
"gpt-image-2".to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /comfyui — HTML page with iframe
|
/// GET /comfyui — HTML page with iframe
|
||||||
async fn comfyui_page() -> Html<&'static str> {
|
async fn comfyui_page() -> Html<&'static str> {
|
||||||
Html(
|
Html(
|
||||||
@@ -335,6 +603,61 @@ fn rewrite_proxy_urls(html: &str) -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inject_boosapi_model_library_patch(html: &str) -> String {
|
||||||
|
let patch = r#"<script>
|
||||||
|
(() => {
|
||||||
|
const isBoosapiModelText = (text) => {
|
||||||
|
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return value && !value.includes('boosapi_models') && !value.endsWith('_models');
|
||||||
|
};
|
||||||
|
const isImageModel = (name) => /image|dall|flux|sd|midjourney|stable|wan|seedream/i.test(name);
|
||||||
|
const addBoosapiNode = (model) => {
|
||||||
|
try {
|
||||||
|
const graph = window.app?.graph || window.LiteGraph?.LGraphCanvas?.active_canvas?.graph;
|
||||||
|
const canvas = window.app?.canvas || window.LiteGraph?.LGraphCanvas?.active_canvas;
|
||||||
|
if (!graph || !window.LiteGraph?.createNode) return false;
|
||||||
|
const nodeType = isImageModel(model) ? 'BoosAPITextToImage' : 'BoosAPILLM';
|
||||||
|
const node = window.LiteGraph.createNode(nodeType, nodeType);
|
||||||
|
if (!node) return false;
|
||||||
|
node.pos = canvas?.convertEventToCanvasOffset
|
||||||
|
? canvas.convertEventToCanvasOffset({ clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 })
|
||||||
|
: [120, 120];
|
||||||
|
graph.add(node);
|
||||||
|
const modelWidget = node.widgets?.find((item) => item.name === 'model');
|
||||||
|
if (modelWidget) modelWidget.value = model;
|
||||||
|
graph.setDirtyCanvas?.(true, true);
|
||||||
|
canvas?.selectNode?.(node);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BoosAPI] failed to add model node', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const patchModelClicks = () => {
|
||||||
|
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
|
||||||
|
if (el.__boosapiModelPatch) return;
|
||||||
|
el.__boosapiModelPatch = true;
|
||||||
|
el.addEventListener('dblclick', (event) => {
|
||||||
|
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!isBoosapiModelText(text)) return;
|
||||||
|
const hasBoosapiAncestor = Array.from(el.parentElement?.querySelectorAll?.('*') || [])
|
||||||
|
.some((child) => String(child.textContent || '').includes('boosapi_models'));
|
||||||
|
if (!hasBoosapiAncestor && !text.match(/gpt|claude|gemini|image|dall|flux|seed|deepseek|qwen|glm|doubao|wan/i)) return;
|
||||||
|
if (addBoosapiNode(text)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('click', () => setTimeout(patchModelClicks, 100), true);
|
||||||
|
setInterval(patchModelClicks, 1200);
|
||||||
|
setTimeout(patchModelClicks, 500);
|
||||||
|
})();
|
||||||
|
</script>"#;
|
||||||
|
html.replace("</head>", &format!("{patch}\n</head>"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build query string from params
|
/// Build query string from params
|
||||||
fn build_query_string(params: &HashMap<String, String>) -> String {
|
fn build_query_string(params: &HashMap<String, String>) -> String {
|
||||||
if params.is_empty() {
|
if params.is_empty() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{auth::jwt::validate_jwt, db::models::downstream_api_key::DownstreamApiKey, AppState};
|
use crate::{auth::jwt::validate_jwt, db::models::downstream_api_key::DownstreamApiKey, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{DefaultBodyLimit, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::sse::{Event, Sse},
|
response::sse::{Event, Sse},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -59,12 +59,15 @@ emotion: 平静
|
|||||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/comfyui-agent/status", get(status_handler))
|
.route("/api/comfyui-agent/status", get(status_handler))
|
||||||
.route("/api/comfyui-agent/models", get(models_handler))
|
.route("/api/comfyui-agent/models", get(models_handler))
|
||||||
.route("/api/comfyui-agent/image-models", get(image_models_handler))
|
.route("/api/comfyui-agent/image-models", get(image_models_handler))
|
||||||
.route("/api/comfyui-agent/chat", post(chat_handler))
|
.route("/api/comfyui-agent/chat", post(chat_handler))
|
||||||
|
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
+927
-107
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
use crate::config::ComfyUIConfig;
|
use crate::config::ComfyUIConfig;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{path::Path, sync::Arc, time::Instant};
|
use std::{path::Path, process::Stdio, sync::Arc, time::Instant};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
process::{Child, Command},
|
process::{Child, Command},
|
||||||
sync::{Mutex, RwLock},
|
sync::{Mutex, RwLock},
|
||||||
@@ -67,7 +67,7 @@ impl ComfyUIProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn internal_base_url(&self) -> String {
|
pub fn internal_base_url(&self) -> String {
|
||||||
if self.config.enabled {
|
if self.config.enabled || self.config.uses_docker_backend() {
|
||||||
format!("http://127.0.0.1:{}", self.config.port)
|
format!("http://127.0.0.1:{}", self.config.port)
|
||||||
} else {
|
} else {
|
||||||
self.config.target_base_url()
|
self.config.target_base_url()
|
||||||
@@ -92,7 +92,9 @@ impl ComfyUIProcess {
|
|||||||
pub async fn status_response(&self) -> ComfyUIStatusResponse {
|
pub async fn status_response(&self) -> ComfyUIStatusResponse {
|
||||||
let status = self.status().await;
|
let status = self.status().await;
|
||||||
let base_url = self.internal_base_url();
|
let base_url = self.internal_base_url();
|
||||||
let mode = if self.config.enabled {
|
let mode = if self.config.uses_docker_backend() {
|
||||||
|
"docker"
|
||||||
|
} else if self.config.enabled {
|
||||||
"embedded"
|
"embedded"
|
||||||
} else {
|
} else {
|
||||||
"external"
|
"external"
|
||||||
@@ -154,6 +156,10 @@ impl ComfyUIProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(&self) -> anyhow::Result<()> {
|
pub async fn start(&self) -> anyhow::Result<()> {
|
||||||
|
if self.config.uses_docker_backend() {
|
||||||
|
return self.start_docker().await;
|
||||||
|
}
|
||||||
|
|
||||||
if !self.config.enabled {
|
if !self.config.enabled {
|
||||||
*self.status.write().await = ComfyUIStatus::Disabled;
|
*self.status.write().await = ComfyUIStatus::Disabled;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -223,6 +229,28 @@ impl ComfyUIProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> anyhow::Result<()> {
|
pub async fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
if self.config.uses_docker_backend() {
|
||||||
|
let name = self.config.container_name.clone();
|
||||||
|
let output = Command::new(docker_bin())
|
||||||
|
.args(["stop", "-t", "10", &name])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.contains("No such container") {
|
||||||
|
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||||
|
last_error: stderr.trim().to_string(),
|
||||||
|
};
|
||||||
|
anyhow::bail!("docker stop failed: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*self.started_at.lock().await = None;
|
||||||
|
*self.status.write().await = ComfyUIStatus::Stopped;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let mut child_slot = self.child.lock().await;
|
let mut child_slot = self.child.lock().await;
|
||||||
if let Some(child) = child_slot.as_mut() {
|
if let Some(child) = child_slot.as_mut() {
|
||||||
if let Err(err) = child.start_kill() {
|
if let Err(err) = child.start_kill() {
|
||||||
@@ -265,6 +293,89 @@ impl ComfyUIProcess {
|
|||||||
.map(|started| started.elapsed().as_secs())
|
.map(|started| started.elapsed().as_secs())
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn start_docker(&self) -> anyhow::Result<()> {
|
||||||
|
if self.health_check().await {
|
||||||
|
*self.started_at.lock().await = Some(Instant::now());
|
||||||
|
*self.status.write().await = ComfyUIStatus::Running {
|
||||||
|
pid: None,
|
||||||
|
uptime_secs: 0,
|
||||||
|
};
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.status.write().await = ComfyUIStatus::Starting;
|
||||||
|
let name = self.config.container_name.clone();
|
||||||
|
let _ = Command::new(docker_bin())
|
||||||
|
.args(["rm", "-f", &name])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut command = Command::new(docker_bin());
|
||||||
|
command
|
||||||
|
.arg("run")
|
||||||
|
.arg("-d")
|
||||||
|
.arg("--rm")
|
||||||
|
.arg("--name")
|
||||||
|
.arg(&name)
|
||||||
|
.arg("-p")
|
||||||
|
.arg(format!("127.0.0.1:{}:8188", self.config.port))
|
||||||
|
.arg("-e")
|
||||||
|
.arg(format!("BOOSAPI_BASE={}", self.model_backend_url()))
|
||||||
|
.arg("-e")
|
||||||
|
.arg(format!("BOOSAPI_BUILTIN_KEY={}", self.builtin_key))
|
||||||
|
.arg("-e")
|
||||||
|
.arg(format!("BOOSAPI_KEY={}", self.builtin_key));
|
||||||
|
|
||||||
|
let data_root = std::env::var("BOOSAPI_COMFY_DATA_ROOT")
|
||||||
|
.unwrap_or_else(|_| "/data/boosapi-comfyui".to_string());
|
||||||
|
for (host, container) in [
|
||||||
|
("input", "/workspace/ComfyUI/input"),
|
||||||
|
("output", "/workspace/ComfyUI/output"),
|
||||||
|
("temp", "/workspace/ComfyUI/temp"),
|
||||||
|
("user", "/workspace/ComfyUI/user"),
|
||||||
|
] {
|
||||||
|
let host_path = format!("{data_root}/{host}");
|
||||||
|
tokio::fs::create_dir_all(&host_path).await?;
|
||||||
|
command
|
||||||
|
.arg("--mount")
|
||||||
|
.arg(format!("type=bind,source={host_path},target={container}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
command.arg(&self.config.docker_image);
|
||||||
|
let output = command.output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||||
|
last_error: stderr.clone(),
|
||||||
|
};
|
||||||
|
anyhow::bail!("docker run failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + std::time::Duration::from_secs(45);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if self.health_check().await {
|
||||||
|
*self.started_at.lock().await = Some(Instant::now());
|
||||||
|
*self.status.write().await = ComfyUIStatus::Running {
|
||||||
|
pid: None,
|
||||||
|
uptime_secs: 0,
|
||||||
|
};
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||||
|
last_error: "Docker ComfyUI did not become ready in 45s".to_string(),
|
||||||
|
};
|
||||||
|
anyhow::bail!("Docker ComfyUI did not become ready in 45s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn docker_bin() -> String {
|
||||||
|
std::env::var("BOOSAPI_DOCKER_BIN").unwrap_or_else(|_| "docker".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_boosapi_config(
|
fn write_boosapi_config(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod comfyui_process;
|
|||||||
pub mod cron;
|
pub mod cron;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
pub mod register_captcha;
|
||||||
pub mod registration_limiter;
|
pub mod registration_limiter;
|
||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
net::IpAddr,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const CAPTCHA_TTL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
const CAPTCHA_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct RegisterCaptchaChallenge {
|
||||||
|
pub id: String,
|
||||||
|
pub image: String,
|
||||||
|
#[serde(rename = "expiresInSeconds")]
|
||||||
|
pub expires_in_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CaptchaVerifyError {
|
||||||
|
Missing,
|
||||||
|
Expired,
|
||||||
|
Mismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CaptchaEntry {
|
||||||
|
answer: String,
|
||||||
|
expires_at: Instant,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RegisterCaptchaStore {
|
||||||
|
inner: Mutex<HashMap<String, CaptchaEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterCaptchaStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue(&self, ip: Option<IpAddr>) -> RegisterCaptchaChallenge {
|
||||||
|
let now = Instant::now();
|
||||||
|
let id = Uuid::new_v4().simple().to_string();
|
||||||
|
let answer = generate_answer();
|
||||||
|
let expires_at = now + CAPTCHA_TTL;
|
||||||
|
|
||||||
|
let mut guard = self.inner.lock();
|
||||||
|
guard.retain(|_, entry| entry.expires_at > now);
|
||||||
|
guard.insert(
|
||||||
|
id.clone(),
|
||||||
|
CaptchaEntry {
|
||||||
|
answer: answer.clone(),
|
||||||
|
expires_at,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
RegisterCaptchaChallenge {
|
||||||
|
id,
|
||||||
|
image: render_svg_data_url(&answer),
|
||||||
|
expires_in_seconds: CAPTCHA_TTL.as_secs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
code: &str,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
) -> Result<(), CaptchaVerifyError> {
|
||||||
|
let captcha_id = id.trim();
|
||||||
|
let submitted = normalize_code(code);
|
||||||
|
if captcha_id.is_empty() || submitted.is_empty() {
|
||||||
|
return Err(CaptchaVerifyError::Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut guard = self.inner.lock();
|
||||||
|
guard.retain(|_, entry| entry.expires_at > now);
|
||||||
|
|
||||||
|
let Some(entry) = guard.remove(captcha_id) else {
|
||||||
|
return Err(CaptchaVerifyError::Expired);
|
||||||
|
};
|
||||||
|
|
||||||
|
if entry.expires_at <= now {
|
||||||
|
return Err(CaptchaVerifyError::Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.ip.is_some() && ip.is_some() && entry.ip != ip {
|
||||||
|
return Err(CaptchaVerifyError::Mismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.answer == submitted {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CaptchaVerifyError::Mismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_answer() -> String {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
bytes.copy_from_slice(Uuid::new_v4().as_bytes());
|
||||||
|
(0..5)
|
||||||
|
.map(|index| {
|
||||||
|
let offset = bytes[index] as usize % CAPTCHA_ALPHABET.len();
|
||||||
|
CAPTCHA_ALPHABET[offset] as char
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_code(code: &str) -> String {
|
||||||
|
code.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !ch.is_whitespace())
|
||||||
|
.flat_map(|ch| ch.to_uppercase())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_svg_data_url(answer: &str) -> String {
|
||||||
|
let seed = Uuid::new_v4();
|
||||||
|
let bytes = seed.as_bytes();
|
||||||
|
let mut chars = String::new();
|
||||||
|
for (index, ch) in answer.chars().enumerate() {
|
||||||
|
let x = 18 + index * 28;
|
||||||
|
let y = 42 + (bytes[index] % 9) as i32 - 4;
|
||||||
|
let rotate = (bytes[index + 5] % 25) as i32 - 12;
|
||||||
|
chars.push_str(&format!(
|
||||||
|
r#"<text x="{x}" y="{y}" transform="rotate({rotate} {x} {y})">{ch}</text>"#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = String::new();
|
||||||
|
for index in 0..5 {
|
||||||
|
let a = bytes[index] as u32;
|
||||||
|
let b = bytes[index + 5] as u32;
|
||||||
|
let c = bytes[index + 10] as u32;
|
||||||
|
lines.push_str(&format!(
|
||||||
|
r#"<path d="M{} {} C{} {},{} {},{} {}" />"#,
|
||||||
|
a % 24,
|
||||||
|
12 + b % 44,
|
||||||
|
34 + b % 28,
|
||||||
|
4 + c % 56,
|
||||||
|
76 + a % 28,
|
||||||
|
4 + b % 56,
|
||||||
|
130 + c % 28,
|
||||||
|
12 + a % 44
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = format!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-label="captcha">
|
||||||
|
<rect width="180" height="64" rx="8" fill="#f8fafc"/>
|
||||||
|
<g fill="none" stroke="#94a3b8" stroke-width="1.5" opacity=".55">{lines}</g>
|
||||||
|
<g fill="#0f172a" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="28" font-weight="800" letter-spacing="3">{chars}</g>
|
||||||
|
<g fill="#64748b" opacity=".45">
|
||||||
|
<circle cx="22" cy="14" r="2"/><circle cx="152" cy="18" r="2"/><circle cx="46" cy="54" r="1.7"/><circle cx="128" cy="49" r="1.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>"##
|
||||||
|
);
|
||||||
|
format!(
|
||||||
|
"data:image/svg+xml;base64,{}",
|
||||||
|
general_purpose::STANDARD.encode(svg.as_bytes())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captcha_is_one_time() {
|
||||||
|
let store = RegisterCaptchaStore::new();
|
||||||
|
let challenge = store.issue(None);
|
||||||
|
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
||||||
|
|
||||||
|
store.verify(&challenge.id, &answer, None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.verify(&challenge.id, &answer, None).unwrap_err(),
|
||||||
|
CaptchaVerifyError::Expired
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captcha_rejects_other_ip() {
|
||||||
|
let store = RegisterCaptchaStore::new();
|
||||||
|
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
|
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.verify(
|
||||||
|
&challenge.id,
|
||||||
|
&answer,
|
||||||
|
Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
CaptchaVerifyError::Mismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,6 +284,29 @@ pub async fn record_base64_asset(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn record_bytes_asset(
|
||||||
|
state: &AppState,
|
||||||
|
mut input: WorkspaceAssetInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
mime_type: &str,
|
||||||
|
) -> Result<StoredWorkspaceAsset, AppError> {
|
||||||
|
let public_id = format!("wa_{}", Uuid::new_v4().simple());
|
||||||
|
input.remote_url = None;
|
||||||
|
let ext = extension_for_mime(mime_type);
|
||||||
|
let path = write_asset_file(input.user_id, &input.source, &public_id, ext, bytes).await?;
|
||||||
|
insert_asset(
|
||||||
|
state,
|
||||||
|
&input,
|
||||||
|
&public_id,
|
||||||
|
"completed",
|
||||||
|
Some(mime_type),
|
||||||
|
Some(&path),
|
||||||
|
bytes.len() as i64,
|
||||||
|
input.metadata.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_image_assets_from_response(
|
pub async fn record_image_assets_from_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user_id: Option<i64>,
|
user_id: Option<i64>,
|
||||||
|
|||||||
+7
-1
@@ -2022,7 +2022,13 @@ export const api = {
|
|||||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||||
};
|
};
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
register: (data: { username: string; email: string; password: string }) =>
|
register: (data: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
captchaId: string;
|
||||||
|
captchaCode: string;
|
||||||
|
}) =>
|
||||||
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
|
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
|
||||||
login: (data: { email: string; password: string }) =>
|
login: (data: { email: string; password: string }) =>
|
||||||
userRequest("/api/users/login", { method: "POST", body: JSON.stringify(data) }),
|
userRequest("/api/users/login", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ const WELCOME_MESSAGE: AgentMessage = {
|
|||||||
'你好,我是视频助手。把脚本、分镜或角色想法发给我;确认后我会分步生成角色四视图、场景图、TTS 和 ComfyUI 工作流。',
|
'你好,我是视频助手。把脚本、分镜或角色想法发给我;确认后我会分步生成角色四视图、场景图、TTS 和 ComfyUI 工作流。',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_COUNT = 8;
|
||||||
|
const MAX_INLINE_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||||
|
const MAX_INLINE_TOTAL_BYTES = 36 * 1024 * 1024;
|
||||||
|
const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
const QUICK_PROMPTS = [
|
const QUICK_PROMPTS = [
|
||||||
'帮我把这个创意拆成角色、场景和镜头节奏',
|
'帮我把这个创意拆成角色、场景和镜头节奏',
|
||||||
'开始制作,先生成角色和场景,不要一次输出工作流',
|
'开始制作,先生成角色和场景,不要一次输出工作流',
|
||||||
@@ -308,6 +313,18 @@ function fileExtension(name: string): string {
|
|||||||
return name.split('.').pop()?.trim().toLowerCase() || '';
|
return name.split('.').pop()?.trim().toLowerCase() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isTextAttachment(file: File, mimeType: string): boolean {
|
function isTextAttachment(file: File, mimeType: string): boolean {
|
||||||
if (mimeType.startsWith('text/')) return true;
|
if (mimeType.startsWith('text/')) return true;
|
||||||
if (['application/json', 'application/x-ndjson', 'application/xml'].includes(mimeType)) {
|
if (['application/json', 'application/x-ndjson', 'application/xml'].includes(mimeType)) {
|
||||||
@@ -400,10 +417,35 @@ async function fileToAttachment(file: File): Promise<AgentAttachment> {
|
|||||||
mediaKind,
|
mediaKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
|
if (mimeType.startsWith('image/')) {
|
||||||
|
if (file.size > MAX_INLINE_IMAGE_BYTES) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
text: [
|
||||||
|
`[图片素材: ${file.name}]`,
|
||||||
|
`类型: ${mimeType}`,
|
||||||
|
`大小: ${formatBytes(file.size)}`,
|
||||||
|
`图片超过 ${formatBytes(MAX_INLINE_IMAGE_BYTES)},已作为引用记录,未内联发送原图数据。`,
|
||||||
|
'如需精确参考,请压缩图片或上传较小版本。',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
return { ...base, dataUrl: await fileToDataUrl(file) };
|
return { ...base, dataUrl: await fileToDataUrl(file) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
text: [
|
||||||
|
`[${mimeType.startsWith('video/') ? '视频' : '音频'}素材: ${file.name}]`,
|
||||||
|
`类型: ${mimeType}`,
|
||||||
|
`大小: ${formatBytes(file.size)}`,
|
||||||
|
'当前版本会记录该素材引用和用途说明,不会把完整二进制内容塞进对话请求。',
|
||||||
|
'请在输入框说明需要参考的镜头、节奏、音色或时间段;后续接入服务端解析后会自动提取媒体信息。',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isDocumentAttachment(file, mimeType) && !isTextAttachment(file, mimeType)) {
|
if (isDocumentAttachment(file, mimeType) && !isTextAttachment(file, mimeType)) {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -1236,8 +1278,29 @@ export default function ComfyUIAgent() {
|
|||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
setReadingAttachments(true);
|
setReadingAttachments(true);
|
||||||
try {
|
try {
|
||||||
const next = await Promise.all(Array.from(files).slice(0, 8).map(fileToAttachment));
|
const selectedFiles = Array.from(files).slice(0, MAX_ATTACHMENT_COUNT);
|
||||||
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, 8));
|
const oversized = selectedFiles.filter((file) => file.size > MAX_ATTACHMENT_BYTES);
|
||||||
|
if (oversized.length > 0) {
|
||||||
|
toast.error(`附件过大:${oversized[0].name} 超过 ${formatBytes(MAX_ATTACHMENT_BYTES)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingInlineBytes = attachments.reduce((sum, attachment) => (
|
||||||
|
sum + (attachment.dataUrl ? attachment.size : 0)
|
||||||
|
), 0);
|
||||||
|
const nextInlineBytes = selectedFiles.reduce((sum, file) => (
|
||||||
|
sum + (file.type.startsWith('image/') && file.size <= MAX_INLINE_IMAGE_BYTES ? file.size : 0)
|
||||||
|
), 0);
|
||||||
|
if (existingInlineBytes + nextInlineBytes > MAX_INLINE_TOTAL_BYTES) {
|
||||||
|
toast.error(`图片附件总量超过 ${formatBytes(MAX_INLINE_TOTAL_BYTES)},请减少数量或压缩后再上传`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > MAX_ATTACHMENT_COUNT || attachments.length + selectedFiles.length > MAX_ATTACHMENT_COUNT) {
|
||||||
|
toast.info(`最多保留 ${MAX_ATTACHMENT_COUNT} 个附件,超出的部分已忽略。`);
|
||||||
|
}
|
||||||
|
const next = await Promise.all(selectedFiles.map(fileToAttachment));
|
||||||
|
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, MAX_ATTACHMENT_COUNT));
|
||||||
setAttachments(withReferences);
|
setAttachments(withReferences);
|
||||||
if (next.some((attachment) => isDocumentAttachment(
|
if (next.some((attachment) => isDocumentAttachment(
|
||||||
new File([], attachment.name, { type: attachment.mimeType }),
|
new File([], attachment.name, { type: attachment.mimeType }),
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import UserManagement from './UserManagement.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getUsers: vi.fn(),
|
||||||
|
getUserUsage: vi.fn(),
|
||||||
|
getUserKeys: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
deleteUser: vi.fn(),
|
||||||
|
createUserKey: vi.fn(),
|
||||||
|
deleteUserKey: vi.fn(),
|
||||||
|
resetUserKey: vi.fn(),
|
||||||
|
updateUserKey: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => (
|
||||||
|
typeof child === 'string' ? child : collectText(child)
|
||||||
|
)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushLoad() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UserManagement', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
prompt: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getUsers.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
username: 'autouser',
|
||||||
|
email: 'auto@test.com',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
total_requests: 15,
|
||||||
|
total_cost: 0,
|
||||||
|
last_7_days_cost: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: { total: 1, active: 1, disabled: 0, admins: 0 },
|
||||||
|
meta: { page: 1, pageSize: 25, total: 1, totalPages: 1 },
|
||||||
|
});
|
||||||
|
apiMock.getUserUsage.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
totalRequests: 15,
|
||||||
|
totalCost: 0,
|
||||||
|
last7DaysCost: 0,
|
||||||
|
trend: [{ day: '2026-05-25', requests: 2, cost: 0 }],
|
||||||
|
recentLogs: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
cost: 0,
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 10,
|
||||||
|
createdAt: '2026-05-25 11:30:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands user usage with the camelCase API response without crashing', async () => {
|
||||||
|
let root!: ReturnType<typeof create>;
|
||||||
|
await act(async () => {
|
||||||
|
root = create(<UserManagement />);
|
||||||
|
});
|
||||||
|
await flushLoad();
|
||||||
|
|
||||||
|
const rows = root.root.findAll((node) => (
|
||||||
|
node.type === 'tr'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('autouser')
|
||||||
|
));
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rows[0].props.onClick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('用量详情');
|
||||||
|
expect(text).toContain('gpt-image-2');
|
||||||
|
expect(apiMock.getUserUsage).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,30 @@ interface UserListMeta {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserUsageTrendItem {
|
||||||
|
day: string;
|
||||||
|
requests: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserUsageLogItem {
|
||||||
|
id: number | string;
|
||||||
|
model: string;
|
||||||
|
cost: number;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserUsageData {
|
||||||
|
totalRequests: number;
|
||||||
|
totalCost: number;
|
||||||
|
last7DaysCost: number;
|
||||||
|
trend: UserUsageTrendItem[];
|
||||||
|
recentLogs: UserUsageLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
type UserFormMode = 'create' | 'edit';
|
type UserFormMode = 'create' | 'edit';
|
||||||
|
|
||||||
interface UserFormState {
|
interface UserFormState {
|
||||||
@@ -104,6 +128,35 @@ function normalizeMeta(result: any, users: User[], page: number, pageSize: numbe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUserUsage(raw: any): UserUsageData {
|
||||||
|
const data = raw?.data ?? raw ?? {};
|
||||||
|
const trend = Array.isArray(data.trend) ? data.trend : [];
|
||||||
|
const recentLogs = Array.isArray(data.recentLogs)
|
||||||
|
? data.recentLogs
|
||||||
|
: Array.isArray(data.recent_logs)
|
||||||
|
? data.recent_logs
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
totalRequests: Number(data.totalRequests ?? data.total_requests ?? 0),
|
||||||
|
totalCost: Number(data.totalCost ?? data.total_cost ?? 0),
|
||||||
|
last7DaysCost: Number(data.last7DaysCost ?? data.last_7_days_cost ?? 0),
|
||||||
|
trend: trend.map((item: any) => ({
|
||||||
|
day: String(item.day ?? ''),
|
||||||
|
requests: Number(item.requests ?? item.request_count ?? 0),
|
||||||
|
cost: Number(item.cost ?? 0),
|
||||||
|
})),
|
||||||
|
recentLogs: recentLogs.map((log: any, index: number) => ({
|
||||||
|
id: log.id ?? `${log.createdAt ?? log.created_at ?? 'log'}-${index}`,
|
||||||
|
model: String(log.model ?? '-'),
|
||||||
|
cost: Number(log.cost ?? 0),
|
||||||
|
promptTokens: Number(log.promptTokens ?? log.prompt_tokens ?? 0),
|
||||||
|
completionTokens: Number(log.completionTokens ?? log.completion_tokens ?? 0),
|
||||||
|
totalTokens: Number(log.totalTokens ?? log.total_tokens ?? 0),
|
||||||
|
createdAt: log.createdAt ?? log.created_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value?: string): string {
|
function formatDate(value?: string): string {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||||
@@ -145,7 +198,7 @@ export default function UserManagement() {
|
|||||||
const [form, setForm] = useState<UserFormState>(emptyForm);
|
const [form, setForm] = useState<UserFormState>(emptyForm);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
|
||||||
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
|
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
|
||||||
const [usageData, setUsageData] = useState<any>(null);
|
const [usageData, setUsageData] = useState<UserUsageData | null>(null);
|
||||||
const [usageLoading, setUsageLoading] = useState(false);
|
const [usageLoading, setUsageLoading] = useState(false);
|
||||||
const [keysData, setKeysData] = useState<any[]>([]);
|
const [keysData, setKeysData] = useState<any[]>([]);
|
||||||
const [keysLoading, setKeysLoading] = useState(false);
|
const [keysLoading, setKeysLoading] = useState(false);
|
||||||
@@ -156,9 +209,10 @@ export default function UserManagement() {
|
|||||||
setUsageLoading(true);
|
setUsageLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.getUserUsage(userId);
|
const data = await api.getUserUsage(userId);
|
||||||
setUsageData(data.data);
|
setUsageData(normalizeUserUsage(data));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load usage:', err);
|
console.error('Failed to load usage:', err);
|
||||||
|
setUsageData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setUsageLoading(false);
|
setUsageLoading(false);
|
||||||
}
|
}
|
||||||
@@ -630,15 +684,15 @@ export default function UserManagement() {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
|
||||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.total_requests.toLocaleString()}</div>
|
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.totalRequests.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.total_cost.toFixed(4)}</div>
|
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.totalCost.toFixed(4)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last_7_days_cost.toFixed(4)}</div>
|
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last7DaysCost.toFixed(4)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -678,15 +732,15 @@ export default function UserManagement() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{usageData.recent_logs.map((log: any) => (
|
{usageData.recentLogs.map((log) => (
|
||||||
<tr key={log.id}>
|
<tr key={log.id}>
|
||||||
<td>{formatDate(log.created_at)}</td>
|
<td>{formatDate(log.createdAt)}</td>
|
||||||
<td><code style={{ fontSize: 11 }}>{log.model}</code></td>
|
<td><code style={{ fontSize: 11 }}>{log.model}</code></td>
|
||||||
<td style={{ color: 'var(--color-primary)' }}>${log.cost.toFixed(5)}</td>
|
<td style={{ color: 'var(--color-primary)' }}>${log.cost.toFixed(5)}</td>
|
||||||
<td>{log.prompt_tokens}/{log.completion_tokens}/{log.total_tokens}</td>
|
<td>{log.promptTokens}/{log.completionTokens}/{log.totalTokens}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{usageData.recent_logs.length === 0 && (
|
{usageData.recentLogs.length === 0 && (
|
||||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
|
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { commitSession } from '../authSession.js';
|
import { commitSession } from '../authSession.js';
|
||||||
import LoginShell from './auth/LoginShell.js';
|
import LoginShell from './auth/LoginShell.js';
|
||||||
@@ -9,9 +9,42 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [captchaId, setCaptchaId] = useState('');
|
||||||
|
const [captchaImage, setCaptchaImage] = useState('');
|
||||||
|
const [captchaCode, setCaptchaCode] = useState('');
|
||||||
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const loadCaptcha = useCallback(async () => {
|
||||||
|
setCaptchaLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/register-captcha', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`验证码加载失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
const payload = await res.json();
|
||||||
|
const challenge = payload?.data || payload;
|
||||||
|
setCaptchaId(String(challenge?.id || ''));
|
||||||
|
setCaptchaImage(String(challenge?.image || ''));
|
||||||
|
setCaptchaCode('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '验证码加载失败');
|
||||||
|
setCaptchaId('');
|
||||||
|
setCaptchaImage('');
|
||||||
|
setCaptchaCode('');
|
||||||
|
} finally {
|
||||||
|
setCaptchaLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCaptcha();
|
||||||
|
}, [loadCaptcha]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username.trim() || !email.trim() || !password) return;
|
if (!username.trim() || !email.trim() || !password) return;
|
||||||
@@ -23,13 +56,23 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
setError('密码至少 6 个字符');
|
setError('密码至少 6 个字符');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!captchaId || !captchaCode.trim()) {
|
||||||
|
setError('请输入图像验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users/register', {
|
const res = await fetch('/api/users/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: username.trim(), email: email.trim(), password }),
|
body: JSON.stringify({
|
||||||
|
username: username.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
captchaId,
|
||||||
|
captchaCode: captchaCode.trim(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -41,6 +84,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
navigate('/user/dashboard');
|
navigate('/user/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '注册失败');
|
setError(err?.message || '注册失败');
|
||||||
|
void loadCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -113,9 +157,60 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
id="reg-captcha-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入右侧字符"
|
||||||
|
value={captchaCode}
|
||||||
|
onChange={(e) => { setCaptchaCode(e.target.value); setError(''); }}
|
||||||
|
className="login-auth-input"
|
||||||
|
autoComplete="off"
|
||||||
|
inputMode="text"
|
||||||
|
maxLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadCaptcha()}
|
||||||
|
disabled={captchaLoading || loading}
|
||||||
|
aria-label="刷新图像验证码"
|
||||||
|
title="点击刷新验证码"
|
||||||
|
style={{
|
||||||
|
height: 44,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 0,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
cursor: captchaLoading || loading ? 'not-allowed' : 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{captchaImage && !captchaLoading ? (
|
||||||
|
<img
|
||||||
|
src={captchaImage}
|
||||||
|
alt="图像验证码"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
|
disabled={
|
||||||
|
loading
|
||||||
|
|| captchaLoading
|
||||||
|
|| !username.trim()
|
||||||
|
|| !email.trim()
|
||||||
|
|| !password
|
||||||
|
|| !confirmPassword
|
||||||
|
|| !captchaId
|
||||||
|
|| !captchaCode.trim()
|
||||||
|
}
|
||||||
className="btn btn-primary login-auth-submit"
|
className="btn btn-primary login-auth-submit"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
+44
-14
@@ -1,34 +1,51 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
type WorkflowRuntime = {
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCAL_WORKFLOW_URL = '/workflow-ui/';
|
||||||
|
const DOCKER_WORKFLOW_URL = '/api/comfyui/proxy/';
|
||||||
|
|
||||||
export default function Workflows() {
|
export default function Workflows() {
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
||||||
|
const [workflowUrl, setWorkflowUrl] = React.useState(LOCAL_WORKFLOW_URL);
|
||||||
|
const [isCloud, setIsCloud] = React.useState(false);
|
||||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
const openFullscreen = () => {
|
const openFullscreen = () => {
|
||||||
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
|
window.open(workflowUrl, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const reloadFrame = () => {
|
const reloadFrame = () => {
|
||||||
if (iframeRef.current) {
|
if (iframeRef.current) {
|
||||||
iframeRef.current.src = '/workflow-ui/';
|
iframeRef.current.src = workflowUrl;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void fetch('/workflow-ui/', { signal: controller.signal })
|
const loadRuntime = async () => {
|
||||||
.then((response) => {
|
const runtimeResponse = await fetch('/api/workflow-ui/runtime', { signal: controller.signal });
|
||||||
if (cancelled) return;
|
const runtime = runtimeResponse.ok ? ((await runtimeResponse.json()) as WorkflowRuntime) : {};
|
||||||
setReady(response.ok);
|
const cloudMode = runtime.mode === 'cloud';
|
||||||
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
const dockerMode = runtime.mode === 'docker';
|
||||||
})
|
const targetUrl = dockerMode ? DOCKER_WORKFLOW_URL : LOCAL_WORKFLOW_URL;
|
||||||
.catch((error) => {
|
if (cancelled) return;
|
||||||
if (cancelled || error?.name === 'AbortError') return;
|
setIsCloud(cloudMode);
|
||||||
setReady(false);
|
setWorkflowUrl(targetUrl);
|
||||||
setMessage('Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
const response = await fetch(targetUrl, { signal: controller.signal });
|
||||||
});
|
if (cancelled) return;
|
||||||
|
setReady(response.ok);
|
||||||
|
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||||
|
};
|
||||||
|
void loadRuntime().catch((error) => {
|
||||||
|
if (cancelled || error?.name === 'AbortError') return;
|
||||||
|
setReady(false);
|
||||||
|
setMessage('工作流入口不可用,请检查服务配置');
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
@@ -39,6 +56,8 @@ export default function Workflows() {
|
|||||||
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={toolbarStyle}>
|
<div style={toolbarStyle}>
|
||||||
<div style={toolbarTitleStyle}>工作流</div>
|
<div style={toolbarTitleStyle}>工作流</div>
|
||||||
|
{!isCloud && workflowUrl === DOCKER_WORKFLOW_URL && <div style={cloudBadgeStyle}>Docker 完整版 ComfyUI</div>}
|
||||||
|
{isCloud && <div style={cloudBadgeStyle}>Comfy Cloud API 后端</div>}
|
||||||
<div style={toolbarActionsStyle}>
|
<div style={toolbarActionsStyle}>
|
||||||
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
||||||
刷新
|
刷新
|
||||||
@@ -51,9 +70,10 @@ export default function Workflows() {
|
|||||||
{ready ? (
|
{ready ? (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src="/workflow-ui/"
|
src={workflowUrl}
|
||||||
title="Workflow Studio"
|
title="Workflow Studio"
|
||||||
allow="clipboard-read; clipboard-write"
|
allow="clipboard-read; clipboard-write"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -79,6 +99,16 @@ const toolbarStyle: React.CSSProperties = {
|
|||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cloudBadgeStyle: React.CSSProperties = {
|
||||||
|
marginRight: 'auto',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '3px 8px',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
const toolbarTitleStyle: React.CSSProperties = {
|
const toolbarTitleStyle: React.CSSProperties = {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
|
|||||||
+148
-8
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WorkflowProgressEvent {
|
pub enum WorkflowProgressEvent {
|
||||||
@@ -20,16 +20,26 @@ pub enum WorkflowProgressEvent {
|
|||||||
NodeStarted {
|
NodeStarted {
|
||||||
prompt_id: String,
|
prompt_id: String,
|
||||||
node_id: String,
|
node_id: String,
|
||||||
|
node_type: String,
|
||||||
index: usize,
|
index: usize,
|
||||||
total: usize,
|
total: usize,
|
||||||
},
|
},
|
||||||
NodeCompleted {
|
NodeCompleted {
|
||||||
prompt_id: String,
|
prompt_id: String,
|
||||||
node_id: String,
|
node_id: String,
|
||||||
|
node_type: String,
|
||||||
index: usize,
|
index: usize,
|
||||||
total: usize,
|
total: usize,
|
||||||
output: Value,
|
output: Value,
|
||||||
},
|
},
|
||||||
|
NodeProgress {
|
||||||
|
prompt_id: String,
|
||||||
|
node_id: String,
|
||||||
|
node_type: String,
|
||||||
|
value: usize,
|
||||||
|
max: usize,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
Finished {
|
Finished {
|
||||||
prompt_id: String,
|
prompt_id: String,
|
||||||
success: bool,
|
success: bool,
|
||||||
@@ -86,6 +96,7 @@ pub async fn run_workflow_with_progress(
|
|||||||
WorkflowProgressEvent::NodeStarted {
|
WorkflowProgressEvent::NodeStarted {
|
||||||
prompt_id: run_public_id.clone(),
|
prompt_id: run_public_id.clone(),
|
||||||
node_id: node.id.clone(),
|
node_id: node.id.clone(),
|
||||||
|
node_type: node.node_type.clone(),
|
||||||
index,
|
index,
|
||||||
total,
|
total,
|
||||||
},
|
},
|
||||||
@@ -94,7 +105,18 @@ pub async fn run_workflow_with_progress(
|
|||||||
let node_run_id =
|
let node_run_id =
|
||||||
storage::insert_node_run_started(state, run_db_id, &node.id, &node.node_type, &inputs)
|
storage::insert_node_run_started(state, run_db_id, &node.id, &node.node_type, &inputs)
|
||||||
.await?;
|
.await?;
|
||||||
match execute_node(state, &ctx, node, inputs.clone()).await {
|
match execute_node(
|
||||||
|
state,
|
||||||
|
&ctx,
|
||||||
|
node,
|
||||||
|
inputs.clone(),
|
||||||
|
progress.clone(),
|
||||||
|
run_public_id.clone(),
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
storage::complete_node_run(state, node_run_id, &result.outputs).await?;
|
storage::complete_node_run(state, node_run_id, &result.outputs).await?;
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -102,6 +124,7 @@ pub async fn run_workflow_with_progress(
|
|||||||
WorkflowProgressEvent::NodeCompleted {
|
WorkflowProgressEvent::NodeCompleted {
|
||||||
prompt_id: run_public_id.clone(),
|
prompt_id: run_public_id.clone(),
|
||||||
node_id: node.id.clone(),
|
node_id: node.id.clone(),
|
||||||
|
node_type: node.node_type.clone(),
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
total,
|
total,
|
||||||
output: result.outputs.clone(),
|
output: result.outputs.clone(),
|
||||||
@@ -183,6 +206,10 @@ async fn execute_node(
|
|||||||
ctx: &RunContext,
|
ctx: &RunContext,
|
||||||
node: &WorkflowNode,
|
node: &WorkflowNode,
|
||||||
inputs: Value,
|
inputs: Value,
|
||||||
|
progress: Option<WorkflowProgressCallback>,
|
||||||
|
prompt_id: String,
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
) -> AppResult<NodeExecution> {
|
) -> AppResult<NodeExecution> {
|
||||||
match node.node_type.as_str() {
|
match node.node_type.as_str() {
|
||||||
"input.text" => {
|
"input.text" => {
|
||||||
@@ -202,8 +229,12 @@ async fn execute_node(
|
|||||||
"comfyui.submit_workflow" => submit_comfyui_workflow(state, node, inputs).await,
|
"comfyui.submit_workflow" => submit_comfyui_workflow(state, node, inputs).await,
|
||||||
"boosapi.prompt_rewrite" => chat_completion(state, ctx, node, inputs, true).await,
|
"boosapi.prompt_rewrite" => chat_completion(state, ctx, node, inputs, true).await,
|
||||||
"boosapi.text_to_image" => text_to_image(state, ctx, node, inputs).await,
|
"boosapi.text_to_image" => text_to_image(state, ctx, node, inputs).await,
|
||||||
"boosapi.text_to_video" => text_to_video(ctx, node, inputs).await,
|
"boosapi.text_to_video" => {
|
||||||
"boosapi.image_to_video" => image_to_video(ctx, node, inputs).await,
|
text_to_video(ctx, node, inputs, progress, prompt_id, index, total).await
|
||||||
|
}
|
||||||
|
"boosapi.image_to_video" => {
|
||||||
|
image_to_video(ctx, node, inputs, progress, prompt_id, index, total).await
|
||||||
|
}
|
||||||
"boosapi.video_status" => video_status(ctx, node, inputs).await,
|
"boosapi.video_status" => video_status(ctx, node, inputs).await,
|
||||||
"CheckpointLoaderSimple" => {
|
"CheckpointLoaderSimple" => {
|
||||||
let model = param_str(node, "ckpt_name", "gpt-image-2");
|
let model = param_str(node, "ckpt_name", "gpt-image-2");
|
||||||
@@ -542,6 +573,10 @@ async fn text_to_video(
|
|||||||
ctx: &RunContext,
|
ctx: &RunContext,
|
||||||
node: &WorkflowNode,
|
node: &WorkflowNode,
|
||||||
inputs: Value,
|
inputs: Value,
|
||||||
|
progress: Option<WorkflowProgressCallback>,
|
||||||
|
prompt_id: String,
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
) -> AppResult<NodeExecution> {
|
) -> AppResult<NodeExecution> {
|
||||||
let prompt = value_to_string(inputs.get("prompt"));
|
let prompt = value_to_string(inputs.get("prompt"));
|
||||||
if prompt.trim().is_empty() {
|
if prompt.trim().is_empty() {
|
||||||
@@ -551,13 +586,17 @@ async fn text_to_video(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let body = video_request_body(node, prompt, None);
|
let body = video_request_body(node, prompt, None);
|
||||||
execute_video_generation(ctx, inputs, body).await
|
execute_video_generation(ctx, node, inputs, body, progress, prompt_id, index, total).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn image_to_video(
|
async fn image_to_video(
|
||||||
ctx: &RunContext,
|
ctx: &RunContext,
|
||||||
node: &WorkflowNode,
|
node: &WorkflowNode,
|
||||||
inputs: Value,
|
inputs: Value,
|
||||||
|
progress: Option<WorkflowProgressCallback>,
|
||||||
|
prompt_id: String,
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
) -> AppResult<NodeExecution> {
|
) -> AppResult<NodeExecution> {
|
||||||
let prompt = value_to_string(inputs.get("prompt"));
|
let prompt = value_to_string(inputs.get("prompt"));
|
||||||
if prompt.trim().is_empty() {
|
if prompt.trim().is_empty() {
|
||||||
@@ -573,7 +612,7 @@ async fn image_to_video(
|
|||||||
Some(image_url)
|
Some(image_url)
|
||||||
};
|
};
|
||||||
let body = video_request_body(node, prompt, image_url);
|
let body = video_request_body(node, prompt, image_url);
|
||||||
execute_video_generation(ctx, inputs, body).await
|
execute_video_generation(ctx, node, inputs, body, progress, prompt_id, index, total).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn video_status(
|
async fn video_status(
|
||||||
@@ -627,22 +666,94 @@ fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<Str
|
|||||||
|
|
||||||
async fn execute_video_generation(
|
async fn execute_video_generation(
|
||||||
ctx: &RunContext,
|
ctx: &RunContext,
|
||||||
|
node: &WorkflowNode,
|
||||||
inputs: Value,
|
inputs: Value,
|
||||||
body: Value,
|
body: Value,
|
||||||
|
progress: Option<WorkflowProgressCallback>,
|
||||||
|
prompt_id: String,
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
) -> AppResult<NodeExecution> {
|
) -> AppResult<NodeExecution> {
|
||||||
|
emit_node_progress(
|
||||||
|
&progress,
|
||||||
|
&prompt_id,
|
||||||
|
node,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
"正在创建视频任务...",
|
||||||
|
);
|
||||||
let raw = post_json(ctx, "/videos", &body).await?;
|
let raw = post_json(ctx, "/videos", &body).await?;
|
||||||
let task_id = extract_task_id(&raw);
|
let task_id = extract_task_id(&raw);
|
||||||
let video = video_output_from_response(&raw);
|
let mut final_raw = raw.clone();
|
||||||
|
let mut video = video_output_from_response(&raw);
|
||||||
|
if video.is_null() && !task_id.is_null() && param_bool(node, "wait_for_result", true) {
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("seedance-1-0-lite-t2v");
|
||||||
|
let interval = param_i64(node, "poll_interval_secs", 5).clamp(1, 30) as u64;
|
||||||
|
let timeout = param_i64(node, "timeout_secs", 180).clamp(10, 1800) as u64;
|
||||||
|
let task = task_id.as_str().unwrap_or_default();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let mut attempt = 0_usize;
|
||||||
|
let max_attempts = ((timeout + interval - 1) / interval).max(1) as usize;
|
||||||
|
while started.elapsed() < Duration::from_secs(timeout) {
|
||||||
|
attempt += 1;
|
||||||
|
emit_node_progress(
|
||||||
|
&progress,
|
||||||
|
&prompt_id,
|
||||||
|
node,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
&format!("视频任务已创建,正在查询结果 {attempt}/{max_attempts}..."),
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||||
|
let status_path = format!(
|
||||||
|
"/videos/{}?model={}",
|
||||||
|
simple_urlencode(task),
|
||||||
|
simple_urlencode(model)
|
||||||
|
);
|
||||||
|
final_raw = get_json(ctx, &status_path).await?;
|
||||||
|
video = video_output_from_response(&final_raw);
|
||||||
|
if !video.is_null() || video_task_is_terminal_failure(&final_raw) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(NodeExecution {
|
Ok(NodeExecution {
|
||||||
inputs,
|
inputs,
|
||||||
outputs: json!({
|
outputs: json!({
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"video": video,
|
"video": video,
|
||||||
"raw": raw
|
"raw": final_raw,
|
||||||
|
"create_response": raw
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_node_progress(
|
||||||
|
progress: &Option<WorkflowProgressCallback>,
|
||||||
|
prompt_id: &str,
|
||||||
|
node: &WorkflowNode,
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
|
message: &str,
|
||||||
|
) {
|
||||||
|
let max = total.saturating_mul(10).max(1);
|
||||||
|
let value = index.saturating_mul(10).saturating_add(4).min(max);
|
||||||
|
emit_progress(
|
||||||
|
progress,
|
||||||
|
WorkflowProgressEvent::NodeProgress {
|
||||||
|
prompt_id: prompt_id.to_string(),
|
||||||
|
node_id: node.id.clone(),
|
||||||
|
node_type: node.node_type.clone(),
|
||||||
|
value,
|
||||||
|
max,
|
||||||
|
message: message.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fn vae_decode_to_image(
|
async fn vae_decode_to_image(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
ctx: &RunContext,
|
ctx: &RunContext,
|
||||||
@@ -804,11 +915,40 @@ fn video_output_from_response(value: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn video_task_is_terminal_failure(value: &Value) -> bool {
|
||||||
|
let status = find_string_key(value, &["status", "state", "task_status", "taskStatus"])
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
matches!(
|
||||||
|
status.as_str(),
|
||||||
|
"failed" | "fail" | "error" | "cancelled" | "canceled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
||||||
let value = value?;
|
let value = value?;
|
||||||
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
for key in keys {
|
||||||
|
if let Some(text) = map
|
||||||
|
.get(*key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Some(text.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.values().find_map(|child| find_string_key(child, keys))
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter().find_map(|child| find_string_key(child, keys)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn find_url_key(value: &Value, keys: &[&str]) -> Option<String> {
|
fn find_url_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
match value {
|
match value {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ pub struct WorkflowRunSummary {
|
|||||||
pub public_id: String,
|
pub public_id: String,
|
||||||
pub workflow_id: i64,
|
pub workflow_id: i64,
|
||||||
pub workflow_public_id: Option<String>,
|
pub workflow_public_id: Option<String>,
|
||||||
|
pub workflow_name: Option<String>,
|
||||||
pub user_id: Option<i64>,
|
pub user_id: Option<i64>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub input_overrides: Value,
|
pub input_overrides: Value,
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ pub fn node_types() -> Vec<NodeTypeDefinition> {
|
|||||||
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
||||||
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||||
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
||||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
|
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 },
|
||||||
|
"wait_for_result": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||||
|
"poll_interval_secs": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||||
|
"timeout_secs": { "ui": "number", "type": "NUMBER", "default": 180, "min": 10, "max": 1800 }
|
||||||
}),
|
}),
|
||||||
outputs: json!({
|
outputs: json!({
|
||||||
"task_id": { "type": "STRING" },
|
"task_id": { "type": "STRING" },
|
||||||
@@ -209,7 +212,10 @@ pub fn node_types() -> Vec<NodeTypeDefinition> {
|
|||||||
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
||||||
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||||
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
||||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
|
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 },
|
||||||
|
"wait_for_result": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||||
|
"poll_interval_secs": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||||
|
"timeout_secs": { "ui": "number", "type": "NUMBER", "default": 180, "min": 10, "max": 1800 }
|
||||||
}),
|
}),
|
||||||
outputs: json!({
|
outputs: json!({
|
||||||
"task_id": { "type": "STRING" },
|
"task_id": { "type": "STRING" },
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ fn row_to_run(
|
|||||||
public_id: row.try_get("public_id").unwrap_or_default(),
|
public_id: row.try_get("public_id").unwrap_or_default(),
|
||||||
workflow_id: row.try_get("workflow_id").unwrap_or_default(),
|
workflow_id: row.try_get("workflow_id").unwrap_or_default(),
|
||||||
workflow_public_id: row.try_get("workflow_public_id").ok(),
|
workflow_public_id: row.try_get("workflow_public_id").ok(),
|
||||||
|
workflow_name: row.try_get("workflow_name").ok(),
|
||||||
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
|
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
|
||||||
status: row.try_get("status").unwrap_or_default(),
|
status: row.try_get("status").unwrap_or_default(),
|
||||||
input_overrides: parse_json_text(row.try_get("input_overrides").ok(), json!({})),
|
input_overrides: parse_json_text(row.try_get("input_overrides").ok(), json!({})),
|
||||||
@@ -149,7 +150,7 @@ pub async fn get_run(
|
|||||||
allow_admin: bool,
|
allow_admin: bool,
|
||||||
) -> AppResult<WorkflowRunSummary> {
|
) -> AppResult<WorkflowRunSummary> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id, workflows.name AS workflow_name
|
||||||
FROM workflow_runs
|
FROM workflow_runs
|
||||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||||
WHERE workflow_runs.public_id = ?
|
WHERE workflow_runs.public_id = ?
|
||||||
@@ -183,7 +184,7 @@ pub async fn list_runs(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> AppResult<Vec<WorkflowRunSummary>> {
|
) -> AppResult<Vec<WorkflowRunSummary>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id, workflows.name AS workflow_name
|
||||||
FROM workflow_runs
|
FROM workflow_runs
|
||||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||||
WHERE (? = 1 OR workflow_runs.user_id = ?)
|
WHERE (? = 1 OR workflow_runs.user_id = ?)
|
||||||
|
|||||||
Reference in New Issue
Block a user