feat: add workflow studio and postgres support
This commit is contained in:
+100
-1
@@ -1,12 +1,111 @@
|
||||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||
use anyhow::{bail, Context};
|
||||
use serde::Serialize;
|
||||
use sqlx::{
|
||||
any::{install_default_drivers, AnyPoolOptions},
|
||||
postgres::{PgPool, PgPoolOptions},
|
||||
sqlite::{SqlitePool, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
pub mod sqlite_to_postgres;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DbKind {
|
||||
Sqlite,
|
||||
Postgres,
|
||||
}
|
||||
|
||||
impl DbKind {
|
||||
pub fn from_url(database_url: &str) -> anyhow::Result<Self> {
|
||||
let normalized = database_url.trim().to_ascii_lowercase();
|
||||
if normalized.starts_with("sqlite:") {
|
||||
Ok(Self::Sqlite)
|
||||
} else if normalized.starts_with("postgres://") || normalized.starts_with("postgresql://") {
|
||||
Ok(Self::Postgres)
|
||||
} else {
|
||||
bail!("unsupported database URL; expected sqlite:, postgres:// or postgresql://")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sqlite => "sqlite",
|
||||
Self::Postgres => "postgres",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result<SqlitePool> {
|
||||
if DbKind::from_url(database_url)? != DbKind::Sqlite {
|
||||
bail!(
|
||||
"runtime PostgreSQL query support is not enabled yet; use migrate-sqlite-to-postgres to prepare PostgreSQL data"
|
||||
);
|
||||
}
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn create_pg_pool(database_url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.context("connect PostgreSQL")?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn test_connection(database_url: &str, max_connections: u32) -> anyhow::Result<DbKind> {
|
||||
match DbKind::from_url(database_url)? {
|
||||
DbKind::Sqlite => {
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.context("connect SQLite")?
|
||||
.close()
|
||||
.await;
|
||||
Ok(DbKind::Sqlite)
|
||||
}
|
||||
DbKind::Postgres => {
|
||||
create_pg_pool(database_url, max_connections)
|
||||
.await?
|
||||
.close()
|
||||
.await;
|
||||
Ok(DbKind::Postgres)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_migrations(
|
||||
kind: DbKind,
|
||||
sqlite_pool: Option<&SqlitePool>,
|
||||
pg_pool: Option<&PgPool>,
|
||||
) -> anyhow::Result<()> {
|
||||
match kind {
|
||||
DbKind::Sqlite => {
|
||||
let pool = sqlite_pool.context("SQLite pool required for SQLite migrations")?;
|
||||
sqlx::migrate!("./migrations").run(pool).await?;
|
||||
}
|
||||
DbKind::Postgres => {
|
||||
let pool = pg_pool.context("PostgreSQL pool required for PostgreSQL migrations")?;
|
||||
sqlx::migrate!("./migrations_pg").run(pool).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_any_pool(
|
||||
database_url: &str,
|
||||
max_connections: u32,
|
||||
) -> anyhow::Result<sqlx::AnyPool> {
|
||||
install_default_drivers();
|
||||
Ok(AnyPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
use anyhow::{bail, Context};
|
||||
use serde::Serialize;
|
||||
use sqlx::{
|
||||
postgres::PgPool,
|
||||
sqlite::{SqlitePool, SqliteRow},
|
||||
Row,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MigrationSummary {
|
||||
pub tables: Vec<TableMigrationSummary>,
|
||||
pub total_rows: i64,
|
||||
pub force_truncate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableMigrationSummary {
|
||||
pub table: &'static str,
|
||||
pub rows: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ColumnKind {
|
||||
I64,
|
||||
F64,
|
||||
Bool,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ColumnSpec {
|
||||
name: &'static str,
|
||||
kind: ColumnKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TableSpec {
|
||||
name: &'static str,
|
||||
columns: &'static [ColumnSpec],
|
||||
}
|
||||
|
||||
macro_rules! col {
|
||||
($name:literal, i64) => {
|
||||
ColumnSpec {
|
||||
name: $name,
|
||||
kind: ColumnKind::I64,
|
||||
}
|
||||
};
|
||||
($name:literal, f64) => {
|
||||
ColumnSpec {
|
||||
name: $name,
|
||||
kind: ColumnKind::F64,
|
||||
}
|
||||
};
|
||||
($name:literal, bool) => {
|
||||
ColumnSpec {
|
||||
name: $name,
|
||||
kind: ColumnKind::Bool,
|
||||
}
|
||||
};
|
||||
($name:literal, text) => {
|
||||
ColumnSpec {
|
||||
name: $name,
|
||||
kind: ColumnKind::Text,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const TABLES: &[TableSpec] = &[
|
||||
TableSpec {
|
||||
name: "users",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("username", text),
|
||||
col!("email", text),
|
||||
col!("password_hash", text),
|
||||
col!("role", text),
|
||||
col!("status", text),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "sites",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("name", text),
|
||||
col!("url", text),
|
||||
col!("platform", text),
|
||||
col!("status", text),
|
||||
col!("api_key", text),
|
||||
col!("proxy_url", text),
|
||||
col!("use_system_proxy", bool),
|
||||
col!("custom_headers", text),
|
||||
col!("external_checkin_url", text),
|
||||
col!("global_weight", f64),
|
||||
col!("post_refresh_probe_enabled", bool),
|
||||
col!("post_refresh_probe_model", text),
|
||||
col!("post_refresh_probe_scope", text),
|
||||
col!("post_refresh_probe_latency_threshold_ms", i64),
|
||||
col!("is_pinned", bool),
|
||||
col!("sort_order", i64),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "accounts",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("site_id", i64),
|
||||
col!("username", text),
|
||||
col!("access_token", text),
|
||||
col!("api_token", text),
|
||||
col!("balance", f64),
|
||||
col!("balance_used", f64),
|
||||
col!("quota", f64),
|
||||
col!("unit_cost", f64),
|
||||
col!("value_score", f64),
|
||||
col!("status", text),
|
||||
col!("checkin_enabled", bool),
|
||||
col!("last_checkin_at", text),
|
||||
col!("last_balance_refresh", text),
|
||||
col!("extra_config", text),
|
||||
col!("is_pinned", bool),
|
||||
col!("sort_order", i64),
|
||||
col!("oauth_provider", text),
|
||||
col!("oauth_account_key", text),
|
||||
col!("oauth_project_id", text),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "account_tokens",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("account_id", i64),
|
||||
col!("name", text),
|
||||
col!("token", text),
|
||||
col!("source", text),
|
||||
col!("token_group", text),
|
||||
col!("value_status", text),
|
||||
col!("enabled", bool),
|
||||
col!("is_default", bool),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "downstream_api_keys",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("name", text),
|
||||
col!("key", text),
|
||||
col!("description", text),
|
||||
col!("group_name", text),
|
||||
col!("tags", text),
|
||||
col!("enabled", bool),
|
||||
col!("expires_at", text),
|
||||
col!("max_cost", f64),
|
||||
col!("used_cost", f64),
|
||||
col!("max_requests", i64),
|
||||
col!("used_requests", i64),
|
||||
col!("supported_models", text),
|
||||
col!("allowed_route_ids", text),
|
||||
col!("site_weight_multipliers", text),
|
||||
col!("excluded_site_ids", text),
|
||||
col!("excluded_credential_refs", text),
|
||||
col!("last_used_at", text),
|
||||
col!("user_id", i64),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "token_routes",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("model_pattern", text),
|
||||
col!("model_mapping", text),
|
||||
col!("display_name", text),
|
||||
col!("display_icon", text),
|
||||
col!("decision_snapshot", text),
|
||||
col!("decision_refreshed_at", text),
|
||||
col!("routing_strategy", text),
|
||||
col!("route_mode", text),
|
||||
col!("enabled", bool),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "route_channels",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("route_id", i64),
|
||||
col!("account_id", i64),
|
||||
col!("token_id", i64),
|
||||
col!("oauth_route_unit_id", i64),
|
||||
col!("source_model", text),
|
||||
col!("priority", i64),
|
||||
col!("weight", i64),
|
||||
col!("enabled", bool),
|
||||
col!("manual_override", bool),
|
||||
col!("success_count", i64),
|
||||
col!("fail_count", i64),
|
||||
col!("total_latency_ms", i64),
|
||||
col!("total_cost", f64),
|
||||
col!("last_used_at", text),
|
||||
col!("last_selected_at", text),
|
||||
col!("last_fail_at", text),
|
||||
col!("consecutive_fail_count", i64),
|
||||
col!("cooldown_level", i64),
|
||||
col!("cooldown_until", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "proxy_logs",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("route_id", i64),
|
||||
col!("channel_id", i64),
|
||||
col!("account_id", i64),
|
||||
col!("downstream_api_key_id", i64),
|
||||
col!("model_requested", text),
|
||||
col!("model_actual", text),
|
||||
col!("status", text),
|
||||
col!("http_status", i64),
|
||||
col!("latency_ms", i64),
|
||||
col!("first_byte_latency_ms", i64),
|
||||
col!("prompt_tokens", i64),
|
||||
col!("completion_tokens", i64),
|
||||
col!("total_tokens", i64),
|
||||
col!("estimated_cost", f64),
|
||||
col!("error_message", text),
|
||||
col!("retry_count", i64),
|
||||
col!("is_stream", bool),
|
||||
col!("client_family", text),
|
||||
col!("client_app_id", text),
|
||||
col!("client_app_name", text),
|
||||
col!("client_confidence", text),
|
||||
col!("billing_details", text),
|
||||
col!("created_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "settings",
|
||||
columns: &[col!("key", text), col!("value", text)],
|
||||
},
|
||||
TableSpec {
|
||||
name: "workspace_assets",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("public_id", text),
|
||||
col!("user_id", i64),
|
||||
col!("source", text),
|
||||
col!("kind", text),
|
||||
col!("title", text),
|
||||
col!("prompt", text),
|
||||
col!("model", text),
|
||||
col!("status", text),
|
||||
col!("mime_type", text),
|
||||
col!("file_name", text),
|
||||
col!("file_path", text),
|
||||
col!("remote_url", text),
|
||||
col!("thumbnail_path", text),
|
||||
col!("size_bytes", i64),
|
||||
col!("metadata", text),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
col!("deleted_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "user_announcements",
|
||||
columns: &[
|
||||
col!("id", i64),
|
||||
col!("title", text),
|
||||
col!("content", text),
|
||||
col!("level", text),
|
||||
col!("status", text),
|
||||
col!("audience", text),
|
||||
col!("starts_at", text),
|
||||
col!("ends_at", text),
|
||||
col!("created_at", text),
|
||||
col!("updated_at", text),
|
||||
col!("published_at", text),
|
||||
],
|
||||
},
|
||||
TableSpec {
|
||||
name: "user_announcement_reads",
|
||||
columns: &[
|
||||
col!("announcement_id", i64),
|
||||
col!("user_id", i64),
|
||||
col!("read_at", text),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
pub async fn migrate(
|
||||
sqlite: &SqlitePool,
|
||||
pg: &PgPool,
|
||||
force_truncate: bool,
|
||||
) -> anyhow::Result<MigrationSummary> {
|
||||
ensure_target_is_empty(pg, force_truncate).await?;
|
||||
if force_truncate {
|
||||
truncate_target(pg).await?;
|
||||
}
|
||||
|
||||
let mut summaries = Vec::new();
|
||||
let mut total_rows = 0;
|
||||
for table in TABLES {
|
||||
let rows = copy_table(sqlite, pg, table).await?;
|
||||
summaries.push(TableMigrationSummary {
|
||||
table: table.name,
|
||||
rows,
|
||||
});
|
||||
total_rows += rows;
|
||||
}
|
||||
sync_sequences(pg).await?;
|
||||
Ok(MigrationSummary {
|
||||
tables: summaries,
|
||||
total_rows,
|
||||
force_truncate,
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_target_is_empty(pg: &PgPool, force_truncate: bool) -> anyhow::Result<()> {
|
||||
if force_truncate {
|
||||
return Ok(());
|
||||
}
|
||||
let mut non_empty = Vec::new();
|
||||
for table in TABLES {
|
||||
let sql = format!("SELECT COUNT(*) FROM {}", table.name);
|
||||
let count: i64 = sqlx::query_scalar(&sql).fetch_one(pg).await.unwrap_or(0);
|
||||
if count > 0 {
|
||||
non_empty.push(format!("{} ({})", table.name, count));
|
||||
}
|
||||
}
|
||||
if !non_empty.is_empty() {
|
||||
bail!(
|
||||
"target PostgreSQL database is not empty: {}; rerun with --force-truncate to overwrite",
|
||||
non_empty.join(", ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn truncate_target(pg: &PgPool) -> anyhow::Result<()> {
|
||||
let table_names = TABLES
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|table| table.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let sql = format!("TRUNCATE TABLE {} RESTART IDENTITY CASCADE", table_names);
|
||||
sqlx::query(&sql).execute(pg).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_table(sqlite: &SqlitePool, pg: &PgPool, table: &TableSpec) -> anyhow::Result<i64> {
|
||||
let available = sqlite_columns(sqlite, table.name).await?;
|
||||
let columns = table
|
||||
.columns
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|column| available.contains_key(column.name))
|
||||
.collect::<Vec<_>>();
|
||||
if columns.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let select_sql = format!(
|
||||
"SELECT {} FROM {} ORDER BY {}",
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| column.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
table.name,
|
||||
if table.name == "settings" {
|
||||
"key"
|
||||
} else {
|
||||
columns[0].name
|
||||
}
|
||||
);
|
||||
let rows = sqlx::query(&select_sql).fetch_all(sqlite).await?;
|
||||
let insert_sql = format!(
|
||||
"INSERT INTO {} ({}) VALUES ({})",
|
||||
table.name,
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| quote_ident(column.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
(1..=columns.len())
|
||||
.map(|index| format!("${index}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
for row in &rows {
|
||||
let mut query = sqlx::query(&insert_sql);
|
||||
for column in &columns {
|
||||
query = bind_column(query, row, column)?;
|
||||
}
|
||||
query
|
||||
.execute(pg)
|
||||
.await
|
||||
.with_context(|| format!("insert into {}", table.name))?;
|
||||
}
|
||||
Ok(rows.len() as i64)
|
||||
}
|
||||
|
||||
fn bind_column<'q>(
|
||||
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||
row: &SqliteRow,
|
||||
column: &ColumnSpec,
|
||||
) -> anyhow::Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>> {
|
||||
Ok(match column.kind {
|
||||
ColumnKind::I64 => query.bind(row.try_get::<Option<i64>, _>(column.name)?),
|
||||
ColumnKind::F64 => query.bind(row.try_get::<Option<f64>, _>(column.name)?),
|
||||
ColumnKind::Bool => query.bind(row.try_get::<Option<bool>, _>(column.name)?),
|
||||
ColumnKind::Text => query.bind(row.try_get::<Option<String>, _>(column.name)?),
|
||||
})
|
||||
}
|
||||
|
||||
async fn sqlite_columns(sqlite: &SqlitePool, table: &str) -> anyhow::Result<HashMap<String, ()>> {
|
||||
let rows = sqlx::query(&format!("PRAGMA table_info({table})"))
|
||||
.fetch_all(sqlite)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("name").ok())
|
||||
.map(|name| (name, ()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn sync_sequences(pg: &PgPool) -> anyhow::Result<()> {
|
||||
for table in TABLES
|
||||
.iter()
|
||||
.filter(|table| table.columns.iter().any(|column| column.name == "id"))
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE((SELECT MAX(id) FROM {table}), 1), (SELECT COUNT(*) FROM {table}) > 0)",
|
||||
table = table.name
|
||||
);
|
||||
let _ = sqlx::query(&sql).execute(pg).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quote_ident(name: &str) -> String {
|
||||
if name == "read" {
|
||||
"\"read\"".to_string()
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -24,6 +24,12 @@ pub enum AppError {
|
||||
#[error("Rate limited")]
|
||||
RateLimited,
|
||||
|
||||
#[error("Rate limited: {message}")]
|
||||
RateLimitedWithRetry {
|
||||
message: String,
|
||||
retry_after_secs: u64,
|
||||
},
|
||||
|
||||
#[error("Service unavailable: {0}")]
|
||||
ServiceUnavailable(String),
|
||||
|
||||
@@ -46,18 +52,28 @@ impl IntoResponse for AppError {
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
|
||||
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
|
||||
AppError::RateLimitedWithRetry { message, .. } => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, message.clone())
|
||||
}
|
||||
AppError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
|
||||
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
|
||||
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
};
|
||||
let retry_after_secs = match &self {
|
||||
AppError::RateLimitedWithRetry {
|
||||
retry_after_secs, ..
|
||||
} => Some(*retry_after_secs),
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": status.canonical_reason(),
|
||||
}
|
||||
},
|
||||
"retry_after_secs": retry_after_secs,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
+5
-11
@@ -7,6 +7,7 @@ pub mod routes;
|
||||
pub mod services;
|
||||
pub mod shared;
|
||||
pub mod transformers;
|
||||
pub mod workflow;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
@@ -16,8 +17,8 @@ pub struct AppState {
|
||||
pub config: config::AppConfig,
|
||||
pub db: Arc<SqlitePool>,
|
||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||
pub comfyui_pool: Arc<services::comfyui_pool::ComfyUIPool>,
|
||||
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
||||
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -40,21 +41,14 @@ impl AppState {
|
||||
comfyui_agent_secret.clone(),
|
||||
);
|
||||
|
||||
// Per-user multi-tenant ComfyUI pool. Backed by Docker today; the
|
||||
// ContainerRuntime trait makes K3S a drop-in v2.
|
||||
let docker = Arc::new(services::docker_runtime::DockerRuntime::new());
|
||||
let pool_cfg = services::comfyui_pool::PoolConfig::from_env(
|
||||
format!("{}/v1", boosapi_url.trim_end_matches('/')),
|
||||
comfyui_agent_secret,
|
||||
);
|
||||
let comfyui_pool = services::comfyui_pool::ComfyUIPool::new(docker, pool_cfg);
|
||||
|
||||
Self {
|
||||
config,
|
||||
db: Arc::new(db),
|
||||
comfyui,
|
||||
comfyui_pool,
|
||||
builtin_keys: Arc::new(builtin_keys),
|
||||
registration_limiter: Arc::new(
|
||||
services::registration_limiter::RegistrationLimiter::new(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-24
@@ -1,7 +1,7 @@
|
||||
use anyhow::Context;
|
||||
use axum::serve;
|
||||
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::ffi::OsString;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::signal;
|
||||
@@ -15,6 +15,9 @@ use tracing_subscriber::EnvFilter;
|
||||
about = "AI API Gateway — Rust rewrite of BoosAPI"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Config file path
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
config: String,
|
||||
@@ -40,10 +43,42 @@ struct Cli {
|
||||
check_config: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Copy core data from SQLite into PostgreSQL and exit
|
||||
MigrateSqliteToPostgres {
|
||||
/// Source SQLite URL, e.g. sqlite:boosapi.db?mode=rwc
|
||||
#[arg(long)]
|
||||
sqlite_url: String,
|
||||
|
||||
/// Target PostgreSQL URL, e.g. postgres://user:pass@host:5432/boosapi
|
||||
#[arg(long)]
|
||||
postgres_url: String,
|
||||
|
||||
/// Truncate target business tables before importing
|
||||
#[arg(long)]
|
||||
force_truncate: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Cli::parse_from(normalize_legacy_args());
|
||||
|
||||
if let Some(Commands::MigrateSqliteToPostgres {
|
||||
sqlite_url,
|
||||
postgres_url,
|
||||
force_truncate,
|
||||
}) = &args.command
|
||||
{
|
||||
let sqlite = db::create_pool(sqlite_url, 1).await?;
|
||||
let pg = db::create_pg_pool(postgres_url, 2).await?;
|
||||
db::run_migrations(db::DbKind::Postgres, None, Some(&pg)).await?;
|
||||
let summary = db::sqlite_to_postgres::migrate(&sqlite, &pg, *force_truncate).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&summary)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load config
|
||||
let mut config = AppConfig::load_from_file(&args.config)?;
|
||||
|
||||
@@ -73,12 +108,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!("Loaded configuration from {}", args.config);
|
||||
|
||||
let db_kind = db::DbKind::from_url(&config.db.url)?;
|
||||
|
||||
// Create database pool
|
||||
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
|
||||
tracing::info!("Database connected");
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
db::run_migrations(db_kind, Some(&pool), None).await?;
|
||||
tracing::info!("Database migrations applied");
|
||||
|
||||
if args.migrate_only {
|
||||
@@ -105,20 +142,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
let comfyui_process = state.comfyui.clone();
|
||||
|
||||
// Multi-tenant ComfyUI pool: adopt any containers that survived a
|
||||
// previous API restart, then start the idle-reaper task. Only
|
||||
// wired in when the `multi_tenant_comfy` feature is on; under the
|
||||
// legacy singleton path the pool is constructed but unused, and
|
||||
// `comfyui_pool` is `None` so we skip shutdown_all on exit.
|
||||
#[cfg(feature = "multi_tenant_comfy")]
|
||||
let comfyui_pool = {
|
||||
state.comfyui_pool.reconcile_on_startup().await;
|
||||
boosapi::services::comfyui_pool::spawn_reaper(state.comfyui_pool.clone());
|
||||
Some(state.comfyui_pool.clone())
|
||||
};
|
||||
#[cfg(not(feature = "multi_tenant_comfy"))]
|
||||
let comfyui_pool: Option<std::sync::Arc<boosapi::services::comfyui_pool::ComfyUIPool>> = None;
|
||||
|
||||
// Build router
|
||||
let app = boosapi::routes::build_router(state);
|
||||
|
||||
@@ -128,7 +151,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
serve(
|
||||
tokio::net::TcpListener::bind(addr).await?,
|
||||
app.into_make_service(),
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
@@ -137,13 +160,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
|
||||
}
|
||||
|
||||
// Tear down any per-user ComfyUI containers we spawned. This is
|
||||
// best-effort — a hard kill of the API leaves them running and the
|
||||
// next startup will adopt them via reconcile_on_startup().
|
||||
if let Some(pool) = comfyui_pool {
|
||||
pool.shutdown_all().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ mod tests {
|
||||
route_id: Some(10),
|
||||
site_id: 20,
|
||||
account_id: Some(30),
|
||||
account_unit_cost: None,
|
||||
base_url: "https://example.test/v1".into(),
|
||||
api_key: "key".into(),
|
||||
model: "gpt-5.5".into(),
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct SelectedChannel {
|
||||
pub route_id: Option<i64>,
|
||||
pub site_id: i64,
|
||||
pub account_id: Option<i64>,
|
||||
pub account_unit_cost: Option<f64>,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
|
||||
+28
-24
@@ -76,6 +76,23 @@ fn looks_like_sse(text: &str) -> bool {
|
||||
text.trim_start().starts_with("data:")
|
||||
}
|
||||
|
||||
fn openai_compatible_base_url(base_url: &str) -> String {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
if trimmed.ends_with("/v1") || trimmed.contains("/v1/") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed}/v1")
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_url(base_url: &str, path_suffix: &str) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
openai_compatible_base_url(base_url),
|
||||
path_suffix.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
|
||||
let mut id: Option<String> = None;
|
||||
let mut created: Option<i64> = None;
|
||||
@@ -162,7 +179,7 @@ pub async fn execute_chat_completions(
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!("{}/chat/completions", route.base_url.trim_end_matches('/'));
|
||||
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
@@ -279,8 +296,8 @@ pub async fn execute_anthropic_messages_stream(
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures::StreamExt;
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let bytes = chunk_result
|
||||
.map_err(|e| AppError::UpstreamError(format!("Stream error: {e}")))?;
|
||||
let bytes =
|
||||
chunk_result.map_err(|e| AppError::UpstreamError(format!("Stream error: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
frames.extend(normalize_anthropic_sse_frames(&text));
|
||||
}
|
||||
@@ -328,7 +345,9 @@ fn normalize_anthropic_sse_frame(frame: &str) -> Option<String> {
|
||||
|
||||
let choice = &event.choices[0];
|
||||
if choice.delta.role.is_some() {
|
||||
let id = event.id.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
|
||||
let id = event
|
||||
.id
|
||||
.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
|
||||
return Some(format!(
|
||||
"event: message_start\ndata: {}\n\n",
|
||||
serde_json::json!({
|
||||
@@ -392,11 +411,7 @@ pub async fn execute_streaming_request(
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!(
|
||||
"{}/{}",
|
||||
route.base_url.trim_end_matches('/'),
|
||||
path_suffix.trim_start_matches('/')
|
||||
);
|
||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
@@ -448,11 +463,7 @@ pub async fn execute_proxy_request(
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!(
|
||||
"{}/{}",
|
||||
route.base_url.trim_end_matches('/'),
|
||||
path_suffix.trim_start_matches('/')
|
||||
);
|
||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
@@ -483,11 +494,7 @@ pub async fn execute_proxy_request(
|
||||
/// Generic GET proxy request to an upstream endpoint path.
|
||||
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!(
|
||||
"{}/{}",
|
||||
route.base_url.trim_end_matches('/'),
|
||||
path_suffix.trim_start_matches('/')
|
||||
);
|
||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||
|
||||
let resp = client
|
||||
.get(&upstream_url)
|
||||
@@ -602,10 +609,7 @@ pub async fn execute_image_generation(
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!(
|
||||
"{}/images/generations",
|
||||
route.base_url.trim_end_matches('/')
|
||||
);
|
||||
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
@@ -664,7 +668,7 @@ pub async fn execute_image_edit(
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
let client = http_client();
|
||||
let upstream_url = format!("{}/images/edits", route.base_url.trim_end_matches('/'));
|
||||
let upstream_url = upstream_url(&route.base_url, "/images/edits");
|
||||
|
||||
let mut form = Form::new();
|
||||
for field in fields {
|
||||
|
||||
@@ -29,6 +29,7 @@ struct ChannelRow {
|
||||
site_api_key: Option<String>,
|
||||
account_access_token: String,
|
||||
account_api_token: Option<String>,
|
||||
account_unit_cost: Option<f64>,
|
||||
token_name: Option<String>,
|
||||
token_value: Option<String>,
|
||||
source_model: Option<String>,
|
||||
@@ -148,6 +149,7 @@ impl TokenRouter {
|
||||
s.api_key AS site_api_key,
|
||||
a.access_token AS account_access_token,
|
||||
a.api_token AS account_api_token,
|
||||
a.unit_cost AS account_unit_cost,
|
||||
at.name AS token_name,
|
||||
at.token AS token_value,
|
||||
rc.source_model AS source_model
|
||||
@@ -198,6 +200,7 @@ impl TokenRouter {
|
||||
route_id: Some(row.route_id),
|
||||
site_id: row.site_id,
|
||||
account_id: Some(row.account_id),
|
||||
account_unit_cost: row.account_unit_cost,
|
||||
base_url: row.base_url,
|
||||
api_key: api_key
|
||||
.trim()
|
||||
|
||||
@@ -100,6 +100,14 @@ struct IdPath {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DatabaseConnectionRequest {
|
||||
dialect: Option<String>,
|
||||
connection_string: Option<String>,
|
||||
ssl: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RouteChannelPath {
|
||||
route_id: i64,
|
||||
@@ -599,23 +607,102 @@ async fn test_system_proxy() -> Json<Value> {
|
||||
}
|
||||
|
||||
async fn database_runtime(State(state): State<AppState>) -> Json<Value> {
|
||||
let dialect = crate::db::DbKind::from_url(&state.config.db.url)
|
||||
.map(|kind| kind.as_str().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
Json(json!({
|
||||
"dialect": "sqlite",
|
||||
"connectionString": state.config.db.url,
|
||||
"dialect": dialect,
|
||||
"connectionString": mask_connection_string(&state.config.db.url),
|
||||
"ssl": state.config.db.ssl,
|
||||
"active": {
|
||||
"dialect": dialect,
|
||||
"connection": mask_connection_string(&state.config.db.url),
|
||||
"ssl": state.config.db.ssl,
|
||||
},
|
||||
"saved": state.config.db.runtime_db_url.as_ref().map(|url| json!({
|
||||
"dialect": state.config.db.runtime_db_dialect.as_deref().unwrap_or("postgres"),
|
||||
"connection": mask_connection_string(url),
|
||||
"ssl": state.config.db.runtime_db_ssl.unwrap_or(false),
|
||||
})),
|
||||
"restartRequired": false,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_database_runtime() -> Json<Value> {
|
||||
Json(json!({ "success": true, "message": "database config is managed by config.toml" }))
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": "数据库运行配置由 config.toml / BOOSAPI_DB__URL 管理;修改后需要重启服务",
|
||||
"restartRequired": true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn test_database_connection() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
async fn test_database_connection(
|
||||
Json(req): Json<DatabaseConnectionRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let connection = req
|
||||
.connection_string
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("connectionString is required".into()))?;
|
||||
let detected = crate::db::test_connection(connection, 2)
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if let Some(expected) = req
|
||||
.dialect
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if expected != detected.as_str() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"dialect mismatch: selected {}, detected {}",
|
||||
expected,
|
||||
detected.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"dialect": detected.as_str(),
|
||||
"connection": mask_connection_string(connection),
|
||||
"ssl": req.ssl.unwrap_or(false),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn database_migrate() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": "Web 迁移入口已禁用。请在服务器执行 boosapi migrate-sqlite-to-postgres --sqlite-url <sqlite-url> --postgres-url <postgres-url>,需要覆盖目标库时追加 --force-truncate。",
|
||||
"cliOnly": true,
|
||||
}))
|
||||
}
|
||||
|
||||
fn mask_connection_string(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.starts_with("sqlite:") {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let Some(scheme_end) = trimmed.find("://") else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let after_scheme = scheme_end + 3;
|
||||
let Some(at_rel) = trimmed[after_scheme..].find('@') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let at = after_scheme + at_rel;
|
||||
let credentials = &trimmed[after_scheme..at];
|
||||
let masked_credentials = if let Some(colon) = credentials.find(':') {
|
||||
format!("{}:****", &credentials[..colon])
|
||||
} else {
|
||||
"****".to_string()
|
||||
};
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&trimmed[..after_scheme],
|
||||
masked_credentials,
|
||||
&trimmed[at..]
|
||||
)
|
||||
}
|
||||
|
||||
async fn export_backup(
|
||||
|
||||
@@ -19,7 +19,7 @@ mod accounts;
|
||||
mod compat;
|
||||
mod downstream_keys;
|
||||
mod model_candidates;
|
||||
mod models_marketplace;
|
||||
pub(crate) mod models_marketplace;
|
||||
mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
@@ -63,6 +63,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
"/api/sites",
|
||||
get(sites::list_sites).post(sites::create_site),
|
||||
)
|
||||
.route("/api/sites/detect", post(sites::detect_site))
|
||||
.route(
|
||||
"/api/sites/{id}",
|
||||
get(sites::get_site)
|
||||
|
||||
@@ -12,6 +12,11 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DetectSiteRequest {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSiteRequest {
|
||||
pub name: String,
|
||||
@@ -90,6 +95,18 @@ struct SiteApiEndpointRow {
|
||||
last_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /api/sites/detect
|
||||
pub async fn detect_site(Json(payload): Json<DetectSiteRequest>) -> AppResult<Json<Value>> {
|
||||
let url = normalize_site_url(&payload.url)?;
|
||||
let (platform, initialization_preset_id) = detect_site_platform(&url);
|
||||
|
||||
Ok(Json(json!({
|
||||
"url": url,
|
||||
"platform": platform,
|
||||
"initializationPresetId": initialization_preset_id,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/sites
|
||||
pub async fn list_sites(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing all sites");
|
||||
@@ -435,6 +452,102 @@ pub async fn update_site_disabled_models(
|
||||
Ok(Json(json!({ "models": normalised })))
|
||||
}
|
||||
|
||||
fn normalize_site_url(raw: &str) -> AppResult<String> {
|
||||
let trimmed = raw.trim().trim_end_matches('/').to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::BadRequest("url is required".into()));
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
|
||||
return Err(AppError::BadRequest(
|
||||
"url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
|
||||
if lower.starts_with("https://coding.dashscope.aliyuncs.com/apps/anthropic") {
|
||||
return ("claude", Some("codingplan-claude"));
|
||||
}
|
||||
if lower.starts_with("https://coding.dashscope.aliyuncs.com/v1") {
|
||||
return ("openai", Some("codingplan-openai"));
|
||||
}
|
||||
if lower.starts_with("https://open.bigmodel.cn/api/anthropic") {
|
||||
return ("claude", Some("zhipu-coding-plan-claude"));
|
||||
}
|
||||
if lower.starts_with("https://open.bigmodel.cn/api/coding/paas/v4") {
|
||||
return ("openai", Some("zhipu-coding-plan-openai"));
|
||||
}
|
||||
if lower.starts_with("https://api.deepseek.com/anthropic") {
|
||||
return ("claude", Some("deepseek-claude"));
|
||||
}
|
||||
if lower.starts_with("https://api.deepseek.com/v1") || lower == "https://api.deepseek.com" {
|
||||
return ("openai", Some("deepseek-openai"));
|
||||
}
|
||||
if lower.starts_with("https://api.moonshot.cn/anthropic") {
|
||||
return ("claude", Some("moonshot-claude"));
|
||||
}
|
||||
if lower.starts_with("https://api.moonshot.cn/v1") {
|
||||
return ("openai", Some("moonshot-openai"));
|
||||
}
|
||||
if lower.starts_with("https://api.minimaxi.com/anthropic") {
|
||||
return ("claude", Some("minimax-claude"));
|
||||
}
|
||||
if lower.starts_with("https://api.minimaxi.com/v1") {
|
||||
return ("openai", Some("minimax-openai"));
|
||||
}
|
||||
if lower.starts_with("https://api-inference.modelscope.cn/v1") {
|
||||
return ("openai", Some("modelscope-openai"));
|
||||
}
|
||||
if lower.starts_with("https://api-inference.modelscope.cn") {
|
||||
return ("claude", Some("modelscope-claude"));
|
||||
}
|
||||
if lower.starts_with("https://ark.cn-beijing.volces.com/api/coding/v3") {
|
||||
return ("openai", Some("doubao-coding-openai"));
|
||||
}
|
||||
if lower.starts_with("https://ark.cn-beijing.volces.com/api/v3")
|
||||
|| lower.starts_with("https://ark.cn-beijing.volces.com")
|
||||
{
|
||||
return ("openai", Some("volcengine-ark-openai"));
|
||||
}
|
||||
|
||||
if lower.contains("anthropic") || lower.contains("claude") {
|
||||
return ("claude", None);
|
||||
}
|
||||
if lower.contains("gemini") || lower.contains("googleapis.com") {
|
||||
return ("gemini", None);
|
||||
}
|
||||
if lower.contains("new-api") {
|
||||
return ("new-api", None);
|
||||
}
|
||||
if lower.contains("one-api") {
|
||||
return ("one-api", None);
|
||||
}
|
||||
if lower.contains("anyrouter") {
|
||||
return ("anyrouter", None);
|
||||
}
|
||||
if lower.contains("veloera") {
|
||||
return ("veloera", None);
|
||||
}
|
||||
if lower.contains("one-hub") {
|
||||
return ("one-hub", None);
|
||||
}
|
||||
if lower.contains("done-hub") {
|
||||
return ("done-hub", None);
|
||||
}
|
||||
if lower.contains("sub2api") {
|
||||
return ("sub2api", None);
|
||||
}
|
||||
if lower.contains("cliproxy") || lower.contains("cpa") {
|
||||
return ("cliproxyapi", None);
|
||||
}
|
||||
|
||||
("openai", None)
|
||||
}
|
||||
|
||||
async fn replace_site_api_endpoints(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
site_id: i64,
|
||||
|
||||
+40
-4
@@ -8,13 +8,14 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
@@ -75,6 +76,7 @@ pub fn routes() -> Router<AppState> {
|
||||
/// POST /api/auth/register
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Registering user: {}", payload.email);
|
||||
@@ -90,6 +92,23 @@ async fn register(
|
||||
));
|
||||
}
|
||||
|
||||
let client_ip = Some(addr.ip());
|
||||
if let Err(limit) = state
|
||||
.registration_limiter
|
||||
.check_and_record_attempt(client_ip, &payload.email)
|
||||
{
|
||||
tracing::warn!(
|
||||
email = %payload.email,
|
||||
client_ip = ?client_ip,
|
||||
retry_after_secs = limit.retry_after_secs,
|
||||
"registration rate limited",
|
||||
);
|
||||
return Err(AppError::RateLimitedWithRetry {
|
||||
message: limit.message.to_string(),
|
||||
retry_after_secs: limit.retry_after_secs,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
let existing: Option<User> = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
@@ -103,17 +122,33 @@ async fn register(
|
||||
let password_hash = crate::auth::password::hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.execute(&*state.db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let user_id = result.last_insert_rowid();
|
||||
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
||||
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, 1)",
|
||||
)
|
||||
.bind(&default_key_name)
|
||||
.bind(&default_key)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
state.registration_limiter.record_success(client_ip);
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
@@ -144,7 +179,8 @@ async fn register(
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
}
|
||||
},
|
||||
"defaultApiKey": default_key
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
//! `/comfy/*` reverse proxy.
|
||||
//!
|
||||
//! Authenticates the caller via JWT/admin token (Authorization header, cookie,
|
||||
//! or `?token=` query param where browsers can't set headers),
|
||||
//! looks up the per-user container in [`ComfyUIPool`], and forwards
|
||||
//! both HTTP and WS traffic to that container's loopback port.
|
||||
//!
|
||||
//! The proxy strips `/comfy` on requests so ComfyUI can keep serving from
|
||||
//! its normal root while browsers see a scoped iframe URL.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
OriginalUri, RawQuery, State,
|
||||
},
|
||||
http::{
|
||||
header::{
|
||||
CONNECTION, HOST, PROXY_AUTHENTICATE, PROXY_AUTHORIZATION, TE, TRAILER,
|
||||
TRANSFER_ENCODING, UPGRADE,
|
||||
},
|
||||
HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::{any, get},
|
||||
Router,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as TMessage;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::services::comfyui_pool::{ComfyUIPool, ContainerState, PoolError};
|
||||
use crate::AppState;
|
||||
|
||||
const COLD_START_HOLD: Duration = Duration::from_secs(30);
|
||||
const COLD_START_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const COMFY_SESSION_COOKIE: &str = "boosapi_comfy_token";
|
||||
const ADMIN_WORKSPACE_USER_ID: i64 = 0;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// WS upgrade endpoints — must be matched before the catch-all.
|
||||
.route("/comfy/ws", get(ws_handler))
|
||||
.route("/comfy/ws/", get(ws_handler))
|
||||
// Catch-all for HTTP. `{*path}` is axum 0.8 wildcard syntax.
|
||||
.route("/comfy", any(http_handler))
|
||||
.route("/comfy/", any(http_handler))
|
||||
.route("/comfy/{*path}", any(http_handler))
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn user_id_from_token(state: &AppState, token: &str) -> Option<i64> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Some(ADMIN_WORKSPACE_USER_ID);
|
||||
}
|
||||
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, token).ok()?;
|
||||
claims.sub.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
/// Resolve the calling user from `Authorization: Bearer <token>`.
|
||||
fn user_id_from_headers(state: &AppState, headers: &HeaderMap) -> Option<i64> {
|
||||
let raw = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())?;
|
||||
let token = raw.strip_prefix("Bearer ").unwrap_or(raw).trim();
|
||||
user_id_from_token(state, token)
|
||||
}
|
||||
|
||||
fn user_id_from_cookie(state: &AppState, headers: &HeaderMap) -> Option<i64> {
|
||||
let raw = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())?;
|
||||
for part in raw.split(';') {
|
||||
let mut it = part.trim().splitn(2, '=');
|
||||
let name = it.next()?.trim();
|
||||
let value = it.next().unwrap_or("").trim();
|
||||
if name == COMFY_SESSION_COOKIE {
|
||||
let token = urlencoding::decode(value).ok()?;
|
||||
return user_id_from_token(state, token.as_ref());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn token_from_query(raw_query: Option<&str>) -> Option<String> {
|
||||
let q = raw_query?;
|
||||
for pair in q.split('&') {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
let k = it.next()?;
|
||||
let v = it.next().unwrap_or("");
|
||||
if k == "token" {
|
||||
return Some(urlencoding::decode(v).ok()?.into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn query_without_token(raw_query: Option<&str>) -> Option<String> {
|
||||
let query = raw_query?;
|
||||
let kept: Vec<&str> = query
|
||||
.split('&')
|
||||
.filter(|pair| pair.splitn(2, '=').next() != Some("token"))
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(kept.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
fn comfy_session_cookie(token: &str) -> String {
|
||||
format!(
|
||||
"{}={}; Path=/comfy; HttpOnly; SameSite=Lax",
|
||||
COMFY_SESSION_COOKIE,
|
||||
urlencoding::encode(token)
|
||||
)
|
||||
}
|
||||
|
||||
// ── HTTP forwarder ─────────────────────────────────────────────────────
|
||||
|
||||
async fn http_handler(State(state): State<AppState>, req: Request<Body>) -> Response<Body> {
|
||||
let query_token = token_from_query(req.uri().query());
|
||||
let user_id = user_id_from_headers(&state, req.headers())
|
||||
.or_else(|| user_id_from_cookie(&state, req.headers()))
|
||||
.or_else(|| {
|
||||
query_token
|
||||
.as_deref()
|
||||
.and_then(|token| user_id_from_token(&state, token))
|
||||
});
|
||||
let Some(user_id) = user_id else {
|
||||
return unauthorized();
|
||||
};
|
||||
|
||||
let entry = match state.comfyui_pool.get_or_start(user_id).await {
|
||||
Ok(e) => e,
|
||||
Err(PoolError::AtCapacity(_)) => return at_capacity(),
|
||||
Err(e) => return upstream_error(format!("pool: {}", e)),
|
||||
};
|
||||
|
||||
if entry.state != ContainerState::Ready {
|
||||
if let Err(e) = wait_for_ready(&state.comfyui_pool, user_id).await {
|
||||
return upstream_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
let path = req.uri().path();
|
||||
let upstream_path = path.strip_prefix("/comfy").unwrap_or(path);
|
||||
let upstream_path = if upstream_path.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
upstream_path
|
||||
};
|
||||
let query = query_without_token(req.uri().query());
|
||||
|
||||
let port = match state.comfyui_pool.entry(user_id).await {
|
||||
Some(e) => e.host_port,
|
||||
None => return upstream_error("container vanished".into()),
|
||||
};
|
||||
|
||||
let upstream = match build_upstream_uri("http", port, upstream_path, query.as_deref()) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return upstream_error(e),
|
||||
};
|
||||
|
||||
let mut response = forward_http(state.comfyui_pool.clone(), user_id, req, upstream).await;
|
||||
if let Some(token) = query_token {
|
||||
if let Ok(value) = HeaderValue::from_str(&comfy_session_cookie(&token)) {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(axum::http::header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn build_upstream_uri(
|
||||
scheme: &str,
|
||||
port: u16,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<Uri, String> {
|
||||
let mut s = format!("{}://127.0.0.1:{}{}", scheme, port, path);
|
||||
if let Some(q) = query {
|
||||
s.push('?');
|
||||
s.push_str(q);
|
||||
}
|
||||
Uri::try_from(s).map_err(|e| format!("bad upstream uri: {}", e))
|
||||
}
|
||||
|
||||
async fn forward_http(
|
||||
pool: Arc<ComfyUIPool>,
|
||||
user_id: i64,
|
||||
req: Request<Body>,
|
||||
upstream: Uri,
|
||||
) -> Response<Body> {
|
||||
let (parts, body) = req.into_parts();
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return upstream_error(format!("client: {}", e)),
|
||||
};
|
||||
|
||||
let body_bytes = match axum::body::to_bytes(body, 64 * 1024 * 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return upstream_error(format!("read body: {}", e)),
|
||||
};
|
||||
|
||||
let mut rb = client.request(reqwest_method(&parts.method), upstream.to_string());
|
||||
for (name, value) in parts.headers.iter() {
|
||||
if is_hop_by_hop(name) || is_browser_context_header(name) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = value.to_str() {
|
||||
rb = rb.header(name.as_str(), v);
|
||||
}
|
||||
}
|
||||
rb = rb.body(body_bytes);
|
||||
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return upstream_error(format!("upstream send: {}", e)),
|
||||
};
|
||||
|
||||
pool.touch(user_id).await;
|
||||
|
||||
let status = resp.status();
|
||||
let mut builder = Response::builder().status(status);
|
||||
for (name, value) in resp.headers().iter() {
|
||||
if is_hop_by_hop(name) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
|
||||
builder = builder.header(name.as_str(), v);
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return upstream_error(format!("upstream read: {}", e)),
|
||||
};
|
||||
|
||||
builder.body(Body::from(bytes)).unwrap_or_else(|_| {
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn reqwest_method(m: &Method) -> reqwest::Method {
|
||||
reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET)
|
||||
}
|
||||
|
||||
fn is_hop_by_hop(name: &HeaderName) -> bool {
|
||||
name == &CONNECTION
|
||||
|| name == &PROXY_AUTHENTICATE
|
||||
|| name == &PROXY_AUTHORIZATION
|
||||
|| name == &TE
|
||||
|| name == &TRAILER
|
||||
|| name == &TRANSFER_ENCODING
|
||||
|| name == &UPGRADE
|
||||
|| name == &HOST
|
||||
|| name.as_str().eq_ignore_ascii_case("keep-alive")
|
||||
}
|
||||
|
||||
fn is_browser_context_header(name: &HeaderName) -> bool {
|
||||
let name = name.as_str();
|
||||
name.eq_ignore_ascii_case("origin")
|
||||
|| name.eq_ignore_ascii_case("referer")
|
||||
|| name.eq_ignore_ascii_case("sec-fetch-site")
|
||||
|| name.eq_ignore_ascii_case("sec-fetch-mode")
|
||||
|| name.eq_ignore_ascii_case("sec-fetch-dest")
|
||||
|| name.eq_ignore_ascii_case("sec-fetch-user")
|
||||
}
|
||||
|
||||
// ── WebSocket forwarder ────────────────────────────────────────────────
|
||||
|
||||
async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
OriginalUri(orig_uri): OriginalUri,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response<Body> {
|
||||
// Resolve user from header (preferred) or `?token=` (browser fallback).
|
||||
let user_id = user_id_from_headers(&state, &headers)
|
||||
.or_else(|| user_id_from_cookie(&state, &headers))
|
||||
.or_else(|| {
|
||||
token_from_query(raw_query.as_deref()).and_then(|t| user_id_from_token(&state, &t))
|
||||
});
|
||||
let Some(user_id) = user_id else {
|
||||
return unauthorized();
|
||||
};
|
||||
|
||||
let entry = match state.comfyui_pool.get_or_start(user_id).await {
|
||||
Ok(e) => e,
|
||||
Err(PoolError::AtCapacity(_)) => return at_capacity(),
|
||||
Err(e) => return upstream_error(format!("pool: {}", e)),
|
||||
};
|
||||
if entry.state != ContainerState::Ready {
|
||||
if let Err(e) = wait_for_ready(&state.comfyui_pool, user_id).await {
|
||||
return upstream_error(e);
|
||||
}
|
||||
}
|
||||
let port = match state.comfyui_pool.entry(user_id).await {
|
||||
Some(e) => e.host_port,
|
||||
None => return upstream_error("container vanished".into()),
|
||||
};
|
||||
|
||||
// Forward whatever query the browser sent (clientId etc).
|
||||
let upstream = match build_upstream_uri("ws", port, "/ws", orig_uri.query()) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return upstream_error(e),
|
||||
};
|
||||
|
||||
let pool = state.comfyui_pool.clone();
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
pool.ws_open(user_id).await;
|
||||
if let Err(e) = pump_ws(socket, upstream.to_string(), pool.clone(), user_id).await {
|
||||
warn!(user_id, error = %e, "ws proxy ended with error");
|
||||
}
|
||||
pool.ws_close(user_id).await;
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn pump_ws(
|
||||
client: WebSocket,
|
||||
upstream_url: String,
|
||||
pool: Arc<ComfyUIPool>,
|
||||
user_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let (upstream_ws, _) = tokio_tungstenite::connect_async(&upstream_url)
|
||||
.await
|
||||
.map_err(|e| format!("ws connect: {}", e))?;
|
||||
let (mut up_tx, mut up_rx) = upstream_ws.split();
|
||||
let (mut cl_tx, mut cl_rx) = client.split();
|
||||
|
||||
let pool_a = pool.clone();
|
||||
let pool_b = pool;
|
||||
|
||||
let to_upstream = async move {
|
||||
while let Some(frame) = cl_rx.next().await {
|
||||
let frame = match frame {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Err::<(), String>(format!("client recv: {}", e)),
|
||||
};
|
||||
pool_a.touch(user_id).await;
|
||||
let out = match frame {
|
||||
Message::Text(t) => TMessage::Text(t.to_string().into()),
|
||||
Message::Binary(b) => TMessage::Binary(b.to_vec()),
|
||||
Message::Ping(p) => TMessage::Ping(p.to_vec()),
|
||||
Message::Pong(p) => TMessage::Pong(p.to_vec()),
|
||||
Message::Close(_) => TMessage::Close(None),
|
||||
};
|
||||
if up_tx.send(out).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let to_client = async move {
|
||||
while let Some(frame) = up_rx.next().await {
|
||||
let frame = match frame {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Err::<(), String>(format!("upstream recv: {}", e)),
|
||||
};
|
||||
pool_b.touch(user_id).await;
|
||||
let out = match frame {
|
||||
TMessage::Text(t) => Message::Text(t.to_string().into()),
|
||||
TMessage::Binary(b) => Message::Binary(b.into()),
|
||||
TMessage::Ping(p) => Message::Ping(p.into()),
|
||||
TMessage::Pong(p) => Message::Pong(p.into()),
|
||||
TMessage::Close(_) => Message::Close(None),
|
||||
TMessage::Frame(_) => continue,
|
||||
};
|
||||
if cl_tx.send(out).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
r = to_upstream => r,
|
||||
r = to_client => r,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cold-start hold ────────────────────────────────────────────────────
|
||||
|
||||
async fn wait_for_ready(pool: &ComfyUIPool, user_id: i64) -> Result<(), String> {
|
||||
let deadline = Instant::now() + COLD_START_HOLD;
|
||||
loop {
|
||||
match pool.state(user_id).await {
|
||||
ContainerState::Ready => return Ok(()),
|
||||
ContainerState::Failed | ContainerState::Stopped => {
|
||||
return Err("container failed to start".into());
|
||||
}
|
||||
ContainerState::Starting => {
|
||||
if Instant::now() >= deadline {
|
||||
return Err("cold start timeout".into());
|
||||
}
|
||||
tokio::time::sleep(COLD_START_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error responses ────────────────────────────────────────────────────
|
||||
|
||||
fn unauthorized() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("WWW-Authenticate", "Bearer")
|
||||
.body(Body::from(r#"{"error":"unauthorized"}"#))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn at_capacity() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Retry-After", "60")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"error":"comfyui pool at capacity","retry_after":60}"#,
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn upstream_error(msg: String) -> Response<Body> {
|
||||
debug!(%msg, "comfy proxy upstream error");
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"error":"comfy proxy: {}"}}"#,
|
||||
msg.replace('"', "\\\"")
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
+152
-8
@@ -63,6 +63,7 @@ pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/comfyui-agent/status", get(status_handler))
|
||||
.route("/api/comfyui-agent/models", get(models_handler))
|
||||
.route("/api/comfyui-agent/image-models", get(image_models_handler))
|
||||
.route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
@@ -70,6 +71,8 @@ pub fn routes() -> Router<AppState> {
|
||||
struct AgentChatRequest {
|
||||
messages: Vec<AgentMessage>,
|
||||
model: Option<String>,
|
||||
#[serde(rename = "imageModel")]
|
||||
image_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -85,6 +88,10 @@ struct AgentMessage {
|
||||
struct AgentAttachment {
|
||||
name: String,
|
||||
mime_type: Option<String>,
|
||||
#[serde(default, rename = "mediaKind")]
|
||||
media_kind: Option<String>,
|
||||
#[serde(default, rename = "referenceLabel")]
|
||||
reference_label: Option<String>,
|
||||
text: Option<String>,
|
||||
data_url: Option<String>,
|
||||
}
|
||||
@@ -290,6 +297,52 @@ fn is_exact_model_pattern(value: &str) -> bool {
|
||||
&& !normalized.contains('?')
|
||||
}
|
||||
|
||||
fn is_image_model_pattern(value: &str) -> bool {
|
||||
if !is_exact_model_pattern(value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
let image_keywords = [
|
||||
"gpt-image",
|
||||
"dall-e",
|
||||
"dalle",
|
||||
"midjourney",
|
||||
"recraft",
|
||||
"kolors",
|
||||
"cogview",
|
||||
"seedream",
|
||||
"sdxl",
|
||||
"stable-diffusion",
|
||||
"stablediffusion",
|
||||
"pixart",
|
||||
"playground",
|
||||
"firefly",
|
||||
"imagen",
|
||||
"flux",
|
||||
"image",
|
||||
"img",
|
||||
"canvas",
|
||||
"painting",
|
||||
"draw",
|
||||
];
|
||||
if image_keywords
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let chat_keywords = [
|
||||
"claude", "chatgpt", "gpt-4", "gpt-3", "gpt-5", "o1", "o3", "o4", "gemini", "deepseek",
|
||||
"qwen", "llama", "mistral", "command", "kimi", "doubao", "glm", "hunyuan", "spark",
|
||||
"baichuan", "ernie", "wenxin", "grok",
|
||||
];
|
||||
!chat_keywords
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
async fn status_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -353,6 +406,47 @@ async fn models_handler(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn image_models_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
|
||||
let _auth = resolve_agent_auth(&headers, &state).await?;
|
||||
|
||||
let rows: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT model_pattern
|
||||
FROM token_routes
|
||||
WHERE COALESCE(enabled, 1) = 1
|
||||
ORDER BY model_pattern COLLATE NOCASE",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": err.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let models: Vec<String> = rows
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| is_image_model_pattern(value))
|
||||
.filter(|value| seen.insert(value.clone()))
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"models": models,
|
||||
"defaultModel": models
|
||||
.iter()
|
||||
.find(|model| model.to_ascii_lowercase().contains("image"))
|
||||
.or_else(|| models.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "gpt-image-2".to_string()),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedSections {
|
||||
characters: Vec<CharacterSection>,
|
||||
@@ -501,7 +595,12 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
sections
|
||||
}
|
||||
|
||||
async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<String, String> {
|
||||
async fn call_image_gen(
|
||||
api_base: &str,
|
||||
api_key: &str,
|
||||
image_model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/images/generations", api_base.trim_end_matches('/'));
|
||||
|
||||
@@ -509,7 +608,7 @@ async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<S
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"model": image_model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
@@ -587,6 +686,19 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
||||
.unwrap_or("application/octet-stream");
|
||||
let name = attachment.name.trim();
|
||||
let name = if name.is_empty() { "untitled" } else { name };
|
||||
let reference = attachment
|
||||
.reference_label
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("@素材");
|
||||
let media_kind = attachment
|
||||
.media_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("file");
|
||||
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||
|
||||
if let Some(text) = attachment
|
||||
.text
|
||||
@@ -594,9 +706,9 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
format!("\n\n[附件: {name}, 类型: {mime}]\n{text}")
|
||||
format!("{header}\n{text}")
|
||||
} else {
|
||||
format!("\n\n[附件: {name}, 类型: {mime}]")
|
||||
header
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +796,12 @@ async fn chat_handler(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let requested_image_model = body
|
||||
.image_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let messages: Vec<Value> = body
|
||||
.messages
|
||||
@@ -699,6 +817,7 @@ async fn chat_handler(
|
||||
"comfyui-agent: resolved upstream",
|
||||
);
|
||||
let agent_model = requested_model.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
|
||||
let image_model = requested_image_model.unwrap_or_else(|| "gpt-image-2".to_string());
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -813,12 +932,35 @@ async fn chat_handler(
|
||||
continue;
|
||||
};
|
||||
|
||||
let content = parsed
|
||||
let delta = parsed
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("delta"))
|
||||
.and_then(|delta| delta.get("content"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
let reasoning = delta
|
||||
.get("reasoning_content")
|
||||
.or_else(|| delta.get("reasoning"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if !reasoning.is_empty() {
|
||||
if !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "reasoning",
|
||||
"content": reasoning,
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let content = delta
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
@@ -893,7 +1035,7 @@ async fn chat_handler(
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match call_image_gen(&api_base, &api_key, prompt).await {
|
||||
match call_image_gen(&api_base, &api_key, &image_model, prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
@@ -927,7 +1069,7 @@ async fn chat_handler(
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
match call_image_gen(&api_base, &api_key, &scene.prompt).await {
|
||||
match call_image_gen(&api_base, &api_key, &image_model, &scene.prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
@@ -1191,8 +1333,10 @@ mod tests {
|
||||
messages: vec![AgentMessage {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
attachments: Vec::new(),
|
||||
}],
|
||||
model: None,
|
||||
image_model: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, HeaderMap},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DigitalHumanAuth {
|
||||
outbound_key: String,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/digital-human/status", get(status))
|
||||
.route("/api/digital-human/generate", post(generate))
|
||||
}
|
||||
|
||||
fn local_v1_base(state: &AppState) -> String {
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
state.config.server.host.clone()
|
||||
};
|
||||
format!("http://{}:{}/v1", host, state.config.server.port)
|
||||
}
|
||||
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
}
|
||||
|
||||
async fn user_downstream_key(state: &AppState, user_id: i64) -> Result<DownstreamApiKey, String> {
|
||||
sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT *
|
||||
FROM downstream_api_keys
|
||||
WHERE user_id = ?
|
||||
AND COALESCE(enabled, 1) = 1
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at = ''
|
||||
OR datetime(expires_at) > datetime('now')
|
||||
)
|
||||
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
.ok_or_else(|| "当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".to_string())
|
||||
}
|
||||
|
||||
async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHumanAuth, AppError> {
|
||||
let token = extract_bearer_token(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(DigitalHumanAuth {
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
if claims.role != "admin" && claims.role != "user" {
|
||||
return Err(AppError::Forbidden("Forbidden".into()));
|
||||
}
|
||||
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<i64>()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
match user_downstream_key(state, user_id).await {
|
||||
Ok(key) => Ok(DigitalHumanAuth {
|
||||
outbound_key: key.key,
|
||||
}),
|
||||
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let _ = resolve_auth(&headers, &state).await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"mode": "boosapi-video-route",
|
||||
"capabilities": [
|
||||
"text_to_avatar_video",
|
||||
"image_to_avatar_video",
|
||||
"audio_driven_avatar",
|
||||
"multi_person_prompt",
|
||||
"segmented_long_video"
|
||||
]
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(mut body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
if body.get("model").and_then(Value::as_str).unwrap_or("").trim().is_empty() {
|
||||
return Err(AppError::BadRequest("model is required".into()));
|
||||
}
|
||||
if body
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(AppError::BadRequest("prompt is required".into()));
|
||||
}
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
object.insert("task_type".to_string(), json!("digital_human"));
|
||||
}
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
.post(format!("{}/videos", local_v1_base(&state)))
|
||||
.bearer_auth(auth.outbound_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(value.to_string()));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"request": body,
|
||||
"response": value
|
||||
})))
|
||||
}
|
||||
+138
-66
@@ -1,22 +1,25 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router,
|
||||
http::{header, HeaderValue, Request, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, get},
|
||||
Json, Router,
|
||||
};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tower::service_fn;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod checkin;
|
||||
#[cfg(feature = "multi_tenant_comfy")]
|
||||
mod comfy_proxy;
|
||||
mod comfyui;
|
||||
mod comfyui_agent;
|
||||
mod digital_human;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
@@ -31,19 +34,12 @@ mod tasks;
|
||||
mod update_center;
|
||||
mod user;
|
||||
mod user_api_keys;
|
||||
mod workspace;
|
||||
// Two implementations of /api/video-agent/*: legacy singleton (default)
|
||||
// vs per-user pool. Exactly one is mounted, picked at compile time by
|
||||
// the `multi_tenant_comfy` feature flag.
|
||||
#[cfg(feature = "multi_tenant_comfy")]
|
||||
mod video_agent;
|
||||
#[cfg(not(feature = "multi_tenant_comfy"))]
|
||||
#[path = "video_agent_legacy.rs"]
|
||||
mod video_agent;
|
||||
mod workflow_ui;
|
||||
mod workspace;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR").unwrap_or_else(|_| "dist/web".to_string());
|
||||
|
||||
// Build the API router
|
||||
let api_routes = Router::new()
|
||||
.route("/healthz", get(health::health_check))
|
||||
@@ -51,12 +47,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(proxy::routes())
|
||||
.merge(picture_agent::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(comfyui::routes());
|
||||
|
||||
// /comfy/* reverse proxy is only mounted when the per-user pool is
|
||||
// active; without it there's no per-user container to forward to.
|
||||
#[cfg(feature = "multi_tenant_comfy")]
|
||||
let api_routes = api_routes.merge(comfy_proxy::routes());
|
||||
.merge(digital_human::routes())
|
||||
.merge(comfyui::routes())
|
||||
.merge(crate::workflow::routes::routes())
|
||||
.route("/comfy", any(removed_comfy_workbench))
|
||||
.route("/comfy/", any(removed_comfy_workbench))
|
||||
.route("/comfy/{*path}", any(removed_comfy_workbench));
|
||||
|
||||
let api_routes = api_routes
|
||||
.merge(auth::routes())
|
||||
@@ -67,6 +63,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(organizations::routes())
|
||||
.merge(search::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.merge(workflow_ui::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(tasks::routes())
|
||||
.merge(update_center::routes())
|
||||
@@ -76,50 +73,125 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(video_agent::routes())
|
||||
.with_state(state);
|
||||
|
||||
// Static file serving with SPA fallback
|
||||
let web_dir_owned = web_dir.clone();
|
||||
let static_service = ServeDir::new(&web_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(service_fn(move |req: Request<Body>| {
|
||||
let web_dir = web_dir_owned.clone();
|
||||
async move {
|
||||
let path = req.uri().path();
|
||||
let router = Router::new().merge(api_routes);
|
||||
match std::env::var("BOOSAPI_WEB_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(web_dir) => {
|
||||
let web_dir_owned = web_dir.clone();
|
||||
let static_service = ServeDir::new(&web_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(service_fn(move |req: Request<Body>| {
|
||||
let web_dir = web_dir_owned.clone();
|
||||
async move {
|
||||
let path = req.uri().path();
|
||||
if is_api_path(path) {
|
||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||
}
|
||||
|
||||
// API routes that don't match → return JSON error, not index.html
|
||||
if path.starts_with("/api/") || path.starts_with("/v1/") {
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "Not found", "path": path}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for client-side routing
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap(),
|
||||
),
|
||||
Err(_) => Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", path)))
|
||||
.unwrap(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.merge(api_routes)
|
||||
.fallback_service(static_service)
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => Ok::<_, Infallible>(html_response(html)),
|
||||
Err(_) => Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", path)))
|
||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}));
|
||||
router.fallback_service(static_service)
|
||||
}
|
||||
None => router.fallback(embedded_static_handler),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_not_found_response(path: &str) -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "Not found", "path": path}).to_string(),
|
||||
))
|
||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
|
||||
async fn embedded_static_handler(req: Request<Body>) -> Response {
|
||||
let path = req.uri().path();
|
||||
if is_api_path(path) {
|
||||
return api_not_found_response(path);
|
||||
}
|
||||
|
||||
let asset_path = embedded_asset_path(path);
|
||||
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) {
|
||||
return embedded_file_response(&asset_path, file.contents());
|
||||
}
|
||||
|
||||
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
||||
return embedded_file_response("index.html", file.contents());
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from("embedded dist/web/index.html not found"))
|
||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
|
||||
fn is_api_path(path: &str) -> bool {
|
||||
path.starts_with("/api/") || path.starts_with("/v1/")
|
||||
}
|
||||
|
||||
fn embedded_asset_path(path: &str) -> String {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
if trimmed.is_empty() || trimmed.ends_with('/') {
|
||||
format!("{trimmed}index.html")
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn html_response(html: String) -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
|
||||
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
|
||||
let content_type = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string();
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.body(Body::from(bytes.to_vec()))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
|
||||
if path.starts_with("assets/") || path.contains(".") && !path.ends_with("index.html") {
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::GONE,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": "内置 Docker ComfyUI 工作台已移除。请配置外部 comfyui.base_url 后使用 /api/comfyui/proxy,或打开 /workflows 使用 ComfyUI 风格工作流画布。",
|
||||
"type": "ComfyUIWorkbenchRemoved"
|
||||
},
|
||||
"redirect": "/workflows"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
+584
-29
@@ -1,12 +1,13 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::background_task::BackgroundTask,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Multipart, Query, State},
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
@@ -19,6 +20,11 @@ use std::time::Duration;
|
||||
|
||||
const TEMPLATE_GALLERY_URL: &str =
|
||||
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
|
||||
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
|
||||
const TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
|
||||
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
||||
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
||||
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PictureAgentAuth {
|
||||
@@ -33,6 +39,13 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/picture-agent/status", get(status_handler))
|
||||
.route("/api/picture-agent/models", get(models_handler))
|
||||
.route("/api/picture-agent/templates", get(templates_handler))
|
||||
.route(
|
||||
"/api/picture-agent/jobs",
|
||||
get(list_jobs).post(create_generation_job),
|
||||
)
|
||||
.route("/api/picture-agent/jobs/clear", post(clear_jobs))
|
||||
.route("/api/picture-agent/jobs/{id}", get(get_job))
|
||||
.route("/api/picture-agent/jobs/{id}/cancel", post(cancel_job))
|
||||
.route(
|
||||
"/api/picture-agent/images/generations",
|
||||
post(images_generations),
|
||||
@@ -54,6 +67,11 @@ struct GalleryItem {
|
||||
image_host_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JobListQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
fn local_v1_base(state: &AppState) -> String {
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
@@ -149,6 +167,52 @@ fn is_exact_model_pattern(value: &str) -> bool {
|
||||
&& !normalized.contains('?')
|
||||
}
|
||||
|
||||
fn is_picture_model_pattern(value: &str) -> bool {
|
||||
if !is_exact_model_pattern(value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
let image_keywords = [
|
||||
"gpt-image",
|
||||
"dall-e",
|
||||
"dalle",
|
||||
"midjourney",
|
||||
"recraft",
|
||||
"kolors",
|
||||
"cogview",
|
||||
"seedream",
|
||||
"sdxl",
|
||||
"stable-diffusion",
|
||||
"stablediffusion",
|
||||
"pixart",
|
||||
"playground",
|
||||
"firefly",
|
||||
"imagen",
|
||||
"flux",
|
||||
"image",
|
||||
"img",
|
||||
"canvas",
|
||||
"painting",
|
||||
"draw",
|
||||
];
|
||||
if image_keywords
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let chat_keywords = [
|
||||
"claude", "chatgpt", "gpt-4", "gpt-3", "gpt-5", "o1", "o3", "o4", "gemini", "deepseek",
|
||||
"qwen", "llama", "mistral", "command", "kimi", "doubao", "glm", "hunyuan", "spark",
|
||||
"baichuan", "ernie", "wenxin", "grok",
|
||||
];
|
||||
!chat_keywords
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
async fn status_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -183,7 +247,7 @@ async fn models_handler(
|
||||
let models: Vec<String> = rows
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| is_exact_model_pattern(value))
|
||||
.filter(|value| is_picture_model_pattern(value))
|
||||
.filter(|value| seen.insert(value.clone()))
|
||||
.collect();
|
||||
|
||||
@@ -199,6 +263,30 @@ async fn models_handler(
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn picture_model_filter_keeps_image_models() {
|
||||
assert!(is_picture_model_pattern("gpt-image-2"));
|
||||
assert!(is_picture_model_pattern("dall-e-3"));
|
||||
assert!(is_picture_model_pattern("flux-schnell"));
|
||||
assert!(is_picture_model_pattern("replicate/recraft-v3"));
|
||||
assert!(is_picture_model_pattern("google/imagen-3"));
|
||||
assert!(is_picture_model_pattern("midjourney"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_model_filter_rejects_chat_models() {
|
||||
assert!(!is_picture_model_pattern("gpt-4o"));
|
||||
assert!(!is_picture_model_pattern("claude-3.7-sonnet"));
|
||||
assert!(!is_picture_model_pattern("gemini-2.5-pro"));
|
||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||
let clean = prompt
|
||||
.split_whitespace()
|
||||
@@ -255,8 +343,8 @@ async fn templates_handler(
|
||||
Query(query): Query<TemplateQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let limit = query.limit.unwrap_or(48).clamp(1, 96);
|
||||
let items = proxy_client()
|
||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||
let items = template_client()
|
||||
.get(TEMPLATE_GALLERY_URL)
|
||||
.send()
|
||||
.await
|
||||
@@ -311,9 +399,20 @@ async fn templates_handler(
|
||||
})))
|
||||
}
|
||||
|
||||
fn proxy_client() -> Client {
|
||||
fn template_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(180))
|
||||
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new())
|
||||
}
|
||||
|
||||
fn picture_generation_client() -> Client {
|
||||
picture_generation_client_with_timeout(PICTURE_GENERATION_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
fn picture_generation_client_with_timeout(timeout_secs: u64) -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new())
|
||||
}
|
||||
@@ -336,6 +435,473 @@ async fn archive_picture_agent_response(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn execute_generation_payload(
|
||||
state: &AppState,
|
||||
auth: &PictureAgentAuth,
|
||||
body: Value,
|
||||
) -> AppResult<(Value, Option<String>, Option<String>)> {
|
||||
execute_generation_payload_with_timeout(state, auth, body, PICTURE_GENERATION_TIMEOUT_SECS)
|
||||
.await
|
||||
}
|
||||
|
||||
fn classify_upstream_failure(status: Option<StatusCode>, text: &str) -> String {
|
||||
let status_text = status
|
||||
.map(|s| format!("{s}"))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if lower.contains("524") || lower.contains("timeout") {
|
||||
"图片上游超时,请稍后重试或缩短提示词".to_string()
|
||||
} else if lower.contains("502") || lower.contains("bad gateway") {
|
||||
"图片上游网关异常,请稍后重试".to_string()
|
||||
} else if lower.contains("503") || lower.contains("service unavailable") {
|
||||
"图片上游暂时不可用,请稍后重试".to_string()
|
||||
} else if status.is_some_and(|s| s == StatusCode::GATEWAY_TIMEOUT) {
|
||||
"图片上游请求超时,请稍后重试".to_string()
|
||||
} else {
|
||||
format!("Upstream returned {status_text}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_generation_payload_with_timeout(
|
||||
state: &AppState,
|
||||
auth: &PictureAgentAuth,
|
||||
body: Value,
|
||||
timeout_secs: u64,
|
||||
) -> AppResult<(Value, Option<String>, Option<String>)> {
|
||||
let prompt = body
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let requested_model = model
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
|
||||
let response = picture_generation_client_with_timeout(timeout_secs)
|
||||
.post(format!("{}/images/generations", local_v1_base(state)))
|
||||
.bearer_auth(auth.outbound_key.clone())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
let hint = if err.is_timeout() {
|
||||
format!(
|
||||
"图片上游在 {} 分钟内没有返回,请稍后重试、缩短提示词或切换图片模型通道",
|
||||
PICTURE_GENERATION_TIMEOUT_SECS / 60
|
||||
)
|
||||
} else if err.is_connect() {
|
||||
"无法连接本地 BoosAPI 图片路由,请确认后端服务仍在运行".to_string()
|
||||
} else {
|
||||
err.to_string()
|
||||
};
|
||||
AppError::UpstreamError(hint)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(classify_upstream_failure(
|
||||
Some(status),
|
||||
&text,
|
||||
)));
|
||||
}
|
||||
let payload: Value = serde_json::from_str(&text).map_err(|err| {
|
||||
AppError::UpstreamError(format!(
|
||||
"Invalid JSON from upstream: {err}; body: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
})?;
|
||||
Ok((payload, prompt, Some(requested_model)))
|
||||
}
|
||||
|
||||
async fn mark_stale_picture_jobs_failed(state: &AppState) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed',
|
||||
message = '生图任务已中断',
|
||||
error = '后台任务长时间没有进展,可能是后端重启、上游超时或图片通道无响应。请重新提交。',
|
||||
updated_at = datetime('now'),
|
||||
finished_at = datetime('now')
|
||||
WHERE task_type = ?
|
||||
AND status IN ('pending', 'running')
|
||||
AND datetime(COALESCE(updated_at, created_at)) < datetime('now', ?)",
|
||||
)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.bind(format!("-{STALE_RUNNING_JOB_MINUTES} minutes"))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn job_visible_to_auth(task: &BackgroundTask, auth: &PictureAgentAuth) -> bool {
|
||||
if auth.role == "admin" && auth.user_id.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some(raw) = task.result_json.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(raw) else {
|
||||
return false;
|
||||
};
|
||||
value.get("userId").and_then(Value::as_i64) == auth.user_id
|
||||
}
|
||||
|
||||
fn task_to_json(task: BackgroundTask) -> Value {
|
||||
let result = task
|
||||
.result_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
json!({
|
||||
"id": task.id,
|
||||
"type": task.task_type,
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"message": task.message,
|
||||
"error": task.error,
|
||||
"progress": task.progress.unwrap_or(0),
|
||||
"progressTotal": task.progress_total.unwrap_or(4),
|
||||
"result": result,
|
||||
"createdAt": task.created_at,
|
||||
"updatedAt": task.updated_at,
|
||||
"startedAt": task.started_at,
|
||||
"finishedAt": task.finished_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_terminal_job_status(status: &str) -> bool {
|
||||
matches!(status, "succeeded" | "failed" | "cancelled")
|
||||
}
|
||||
|
||||
async fn load_picture_job(state: &AppState, id: &str, auth: &PictureAgentAuth) -> AppResult<Value> {
|
||||
mark_stale_picture_jobs_failed(state).await?;
|
||||
let task: BackgroundTask =
|
||||
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
|
||||
.bind(id)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Picture job not found".into()))?;
|
||||
if !job_visible_to_auth(&task, auth) {
|
||||
return Err(AppError::NotFound("Picture job not found".into()));
|
||||
}
|
||||
Ok(task_to_json(task))
|
||||
}
|
||||
|
||||
async fn update_job_progress(
|
||||
state: &AppState,
|
||||
id: &str,
|
||||
progress: i64,
|
||||
message: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET progress = ?, message = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND status IN ('pending', 'running')",
|
||||
)
|
||||
.bind(progress)
|
||||
.bind(message)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_job_cancelled(state: &AppState, id: &str) -> AppResult<bool> {
|
||||
let status: Option<String> =
|
||||
sqlx::query_scalar("SELECT status FROM background_tasks WHERE id = ? LIMIT 1")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
Ok(status.as_deref() == Some("cancelled"))
|
||||
}
|
||||
|
||||
async fn create_generation_job(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let prompt = body
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gpt-image-2")
|
||||
.trim()
|
||||
.to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(AppError::BadRequest("prompt is required".into()));
|
||||
}
|
||||
if model.is_empty() {
|
||||
return Err(AppError::BadRequest("model is required".into()));
|
||||
}
|
||||
|
||||
let task_id = format!("pic_{}", uuid::Uuid::new_v4().simple());
|
||||
let title = prompt.chars().take(30).collect::<String>();
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyName": auth.key_name,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"mode": "generate",
|
||||
"request": body,
|
||||
});
|
||||
sqlx::query(
|
||||
"INSERT INTO background_tasks
|
||||
(id, task_type, title, status, message, dedupe_key, result_json, progress, progress_total, started_at)
|
||||
VALUES (?, ?, ?, 'running', '任务已提交,等待路由', ?, ?, 1, 4, datetime('now'))",
|
||||
)
|
||||
.bind(&task_id)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.bind(if title.trim().is_empty() { "生图任务" } else { &title })
|
||||
.bind(format!("picture-agent:{}", auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())))
|
||||
.bind(initial_result.to_string())
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let state_for_task = state.clone();
|
||||
let task_id_for_spawn = task_id.clone();
|
||||
let auth_for_task = auth.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = async {
|
||||
update_job_progress(
|
||||
&state_for_task,
|
||||
&task_id_for_spawn,
|
||||
2,
|
||||
"本地图片路由已确认,正在请求上游",
|
||||
)
|
||||
.await?;
|
||||
let generation_result =
|
||||
execute_generation_payload(&state_for_task, &auth_for_task, body.clone()).await;
|
||||
let (payload, prompt, model) = match generation_result {
|
||||
Ok(value) => value,
|
||||
Err(AppError::UpstreamError(message))
|
||||
if message.contains("超时") || message.contains("timeout") =>
|
||||
{
|
||||
update_job_progress(
|
||||
&state_for_task,
|
||||
&task_id_for_spawn,
|
||||
2,
|
||||
"上游超时,正在自动重试一次",
|
||||
)
|
||||
.await?;
|
||||
execute_generation_payload_with_timeout(
|
||||
&state_for_task,
|
||||
&auth_for_task,
|
||||
body,
|
||||
PICTURE_GENERATION_RETRY_SECS,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if is_job_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
||||
return Ok(());
|
||||
}
|
||||
update_job_progress(
|
||||
&state_for_task,
|
||||
&task_id_for_spawn,
|
||||
3,
|
||||
"图片已返回,正在归档到工作空间",
|
||||
)
|
||||
.await?;
|
||||
archive_picture_agent_response(
|
||||
&state_for_task,
|
||||
&auth_for_task,
|
||||
prompt.clone(),
|
||||
model.clone(),
|
||||
&payload,
|
||||
)
|
||||
.await;
|
||||
let urls = payload
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let final_result = json!({
|
||||
"userId": auth_for_task.user_id,
|
||||
"role": auth_for_task.role,
|
||||
"keyName": auth_for_task.key_name,
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"mode": "generate",
|
||||
"response": payload,
|
||||
"images": urls,
|
||||
});
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'succeeded', message = '生图完成', result_json = ?, progress = 4,
|
||||
updated_at = datetime('now'), finished_at = datetime('now')
|
||||
WHERE id = ? AND status IN ('pending', 'running')",
|
||||
)
|
||||
.bind(final_result.to_string())
|
||||
.bind(&task_id_for_spawn)
|
||||
.execute(&*state_for_task.db)
|
||||
.await?;
|
||||
Ok::<(), AppError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', message = '生图失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
|
||||
WHERE id = ? AND status IN ('pending', 'running')",
|
||||
)
|
||||
.bind(err.to_string())
|
||||
.bind(&task_id_for_spawn)
|
||||
.execute(&*state_for_task.db)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let task = load_picture_job(&state, &task_id, &auth).await?;
|
||||
Ok(Json(json!({ "success": true, "job": task })))
|
||||
}
|
||||
|
||||
async fn cancel_job(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let task: BackgroundTask =
|
||||
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
|
||||
.bind(&id)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Picture job not found".into()))?;
|
||||
if !job_visible_to_auth(&task, &auth) {
|
||||
return Err(AppError::NotFound("Picture job not found".into()));
|
||||
}
|
||||
if task.status != "pending" && task.status != "running" {
|
||||
return Ok(Json(json!({
|
||||
"success": true,
|
||||
"job": task_to_json(task),
|
||||
})));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'cancelled',
|
||||
message = '已取消',
|
||||
error = NULL,
|
||||
updated_at = datetime('now'),
|
||||
finished_at = datetime('now')
|
||||
WHERE id = ? AND task_type = ? AND status IN ('pending', 'running')",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let job = load_picture_job(&state, &id, &auth).await?;
|
||||
Ok(Json(json!({ "success": true, "job": job })))
|
||||
}
|
||||
|
||||
async fn list_jobs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<JobListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
mark_stale_picture_jobs_failed(&state).await?;
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
||||
"SELECT * FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.bind(limit * 3)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let jobs: Vec<Value> = tasks
|
||||
.into_iter()
|
||||
.filter(|task| job_visible_to_auth(task, &auth))
|
||||
.take(limit as usize)
|
||||
.map(task_to_json)
|
||||
.collect();
|
||||
Ok(Json(json!({ "success": true, "jobs": jobs })))
|
||||
}
|
||||
|
||||
async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
mark_stale_picture_jobs_failed(&state).await?;
|
||||
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
||||
"SELECT * FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut cleared = 0_i64;
|
||||
for task in tasks {
|
||||
if !job_visible_to_auth(&task, &auth) || !is_terminal_job_status(&task.status) {
|
||||
continue;
|
||||
}
|
||||
let affected = sqlx::query(
|
||||
"DELETE FROM background_tasks
|
||||
WHERE id = ? AND task_type = ?",
|
||||
)
|
||||
.bind(&task.id)
|
||||
.bind(TASK_TYPE_PICTURE_GENERATION)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
cleared += i64::try_from(affected).unwrap_or(0);
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"cleared": cleared,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_job(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let job = load_picture_job(&state, &id, &auth).await?;
|
||||
Ok(Json(json!({ "success": true, "job": job })))
|
||||
}
|
||||
|
||||
async fn json_response_and_archive(
|
||||
state: &AppState,
|
||||
auth: &PictureAgentAuth,
|
||||
payload: Value,
|
||||
prompt: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Response<Body> {
|
||||
archive_picture_agent_response(state, auth, prompt, model, &payload).await;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(Body::from(payload.to_string()))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
|
||||
async fn upstream_json_response_to_axum_and_archive(
|
||||
state: &AppState,
|
||||
auth: &PictureAgentAuth,
|
||||
@@ -375,27 +941,8 @@ async fn images_generations(
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Response<Body>> {
|
||||
let auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let prompt = body
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let url = format!("{}/images/generations", local_v1_base(&state));
|
||||
let response = proxy_client()
|
||||
.post(url)
|
||||
.bearer_auth(auth.outbound_key.clone())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
upstream_json_response_to_axum_and_archive(&state, &auth, response, prompt, model).await
|
||||
let (payload, prompt, model) = execute_generation_payload(&state, &auth, body).await?;
|
||||
Ok(json_response_and_archive(&state, &auth, payload, prompt, model).await)
|
||||
}
|
||||
|
||||
async fn images_edits(
|
||||
@@ -455,12 +1002,20 @@ async fn images_edits(
|
||||
}
|
||||
|
||||
let url = format!("{}/images/edits", local_v1_base(&state));
|
||||
let response = proxy_client()
|
||||
let response = picture_generation_client()
|
||||
.post(url)
|
||||
.bearer_auth(auth.outbound_key.clone())
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
upstream_json_response_to_axum_and_archive(&state, &auth, response, prompt, model).await
|
||||
let response = upstream_json_response_to_axum_and_archive(
|
||||
&state,
|
||||
&auth,
|
||||
response,
|
||||
prompt.clone(),
|
||||
model.clone(),
|
||||
)
|
||||
.await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
+162
-25
@@ -103,10 +103,12 @@ async fn messages_handler(
|
||||
let model = canonical.model.clone();
|
||||
|
||||
if canonical.stream {
|
||||
let chunks = execute_messages_stream_with_conductor(state, key_id, model, canonical).await?;
|
||||
let sse = Sse::new(stream::iter(chunks).map(|chunk| {
|
||||
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
|
||||
}));
|
||||
let chunks =
|
||||
execute_messages_stream_with_conductor(state, key_id, model, canonical).await?;
|
||||
let sse =
|
||||
Sse::new(stream::iter(chunks).map(|chunk| {
|
||||
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
|
||||
}));
|
||||
Ok(sse.into_response())
|
||||
} else {
|
||||
let result = execute_messages_with_conductor(state, key_id, model, canonical).await?;
|
||||
@@ -371,10 +373,53 @@ async fn images_generations(
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_image_generation(state.clone(), route, body).await?;
|
||||
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
|
||||
Ok(Json(result))
|
||||
let started_at = Instant::now();
|
||||
let selected = TokenRouter::new(state.db.clone())
|
||||
.resolve_selected(&model)
|
||||
.await?;
|
||||
let body = body_with_selected_model(body, &selected);
|
||||
let result = executors::execute_image_generation(
|
||||
state.clone(),
|
||||
selected_channel_to_route(selected.clone()),
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(payload) => {
|
||||
let cost = estimate_request_cost(&selected, 0, 0);
|
||||
record_proxy_log_success(
|
||||
&state,
|
||||
key_id,
|
||||
&selected,
|
||||
&model,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
0,
|
||||
0,
|
||||
cost,
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
record_billing(&state, key_id, &model, 0, 0, cost).await;
|
||||
Ok(Json(payload))
|
||||
}
|
||||
Err(err) => {
|
||||
record_proxy_log_failure(
|
||||
&state,
|
||||
key_id,
|
||||
Some(&selected),
|
||||
&model,
|
||||
upstream_status_from_error(&err),
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
Some(&err.to_string()),
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/images/edits
|
||||
@@ -452,21 +497,63 @@ async fn images_edits(
|
||||
}
|
||||
|
||||
let model = model.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let started_at = Instant::now();
|
||||
let selected = TokenRouter::new(state.db.clone())
|
||||
.resolve_selected(&model)
|
||||
.await?;
|
||||
|
||||
// Override the multipart `model` field with the conductor-resolved
|
||||
// upstream model name (route may map "dall-e-3" → upstream-specific id).
|
||||
if route.model != model {
|
||||
if selected.actual_model != model {
|
||||
for field in fields.iter_mut() {
|
||||
if field.name == "model" && field.bytes.is_none() {
|
||||
field.text = Some(route.model.clone());
|
||||
field.text = Some(selected.actual_model.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = executors::execute_image_edit(state.clone(), route, fields).await?;
|
||||
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
|
||||
Ok(Json(result))
|
||||
let result = executors::execute_image_edit(
|
||||
state.clone(),
|
||||
selected_channel_to_route(selected.clone()),
|
||||
fields,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(payload) => {
|
||||
let cost = estimate_request_cost(&selected, 0, 0);
|
||||
record_proxy_log_success(
|
||||
&state,
|
||||
key_id,
|
||||
&selected,
|
||||
&model,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
0,
|
||||
0,
|
||||
cost,
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
record_billing(&state, key_id, &model, 0, 0, cost).await;
|
||||
Ok(Json(payload))
|
||||
}
|
||||
Err(err) => {
|
||||
record_proxy_log_failure(
|
||||
&state,
|
||||
key_id,
|
||||
Some(&selected),
|
||||
&model,
|
||||
upstream_status_from_error(&err),
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
Some(&err.to_string()),
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /v1/models
|
||||
@@ -564,6 +651,23 @@ async fn record_billing(
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_request_cost(
|
||||
selected: &SelectedChannel,
|
||||
prompt_tokens: i64,
|
||||
completion_tokens: i64,
|
||||
) -> f64 {
|
||||
let unit_cost = selected.account_unit_cost.unwrap_or(0.0).max(0.0);
|
||||
if unit_cost <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let total_tokens = (prompt_tokens.max(0) + completion_tokens.max(0)) as f64;
|
||||
if total_tokens <= 0.0 {
|
||||
unit_cost
|
||||
} else {
|
||||
total_tokens / 1_000_000.0 * unit_cost
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_proxy_log_success(
|
||||
state: &AppState,
|
||||
key_id: Option<i64>,
|
||||
@@ -699,6 +803,31 @@ fn body_with_selected_model(mut body: Value, selected: &SelectedChannel) -> Valu
|
||||
body
|
||||
}
|
||||
|
||||
fn ensure_stream_usage(mut body: Value) -> Value {
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
match object.get_mut("stream_options") {
|
||||
Some(options) => {
|
||||
if let Some(map) = options.as_object_mut() {
|
||||
map.entry("include_usage".to_string())
|
||||
.or_insert_with(|| Value::Bool(true));
|
||||
} else {
|
||||
object.insert(
|
||||
"stream_options".to_string(),
|
||||
serde_json::json!({ "include_usage": true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
object.insert(
|
||||
"stream_options".to_string(),
|
||||
serde_json::json!({ "include_usage": true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn build_conductor(state: &AppState) -> ProxyConductor {
|
||||
let db = state.db.clone();
|
||||
let select_db = db.clone();
|
||||
@@ -797,6 +926,7 @@ async fn execute_messages_with_conductor(
|
||||
attempts,
|
||||
} => {
|
||||
let (prompt, completion) = extract_usage(&response);
|
||||
let cost = estimate_request_cost(&selected, prompt, completion);
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
@@ -805,7 +935,7 @@ async fn execute_messages_with_conductor(
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
cost,
|
||||
attempts,
|
||||
false,
|
||||
)
|
||||
@@ -816,7 +946,7 @@ async fn execute_messages_with_conductor(
|
||||
&billing_model,
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
cost,
|
||||
)
|
||||
.await;
|
||||
Ok(response)
|
||||
@@ -938,6 +1068,7 @@ async fn execute_messages_stream_with_conductor(
|
||||
}
|
||||
};
|
||||
|
||||
let cost = estimate_request_cost(&selected, 0, 0);
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
@@ -946,12 +1077,12 @@ async fn execute_messages_stream_with_conductor(
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
0,
|
||||
0,
|
||||
0.0,
|
||||
cost,
|
||||
attempts,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
record_billing(&billing_state, key_id, &billing_model, 0, 0, 0.0).await;
|
||||
record_billing(&billing_state, key_id, &billing_model, 0, 0, cost).await;
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
@@ -1005,6 +1136,7 @@ async fn execute_chat_completions_with_conductor(
|
||||
attempts,
|
||||
} => {
|
||||
let (prompt, completion) = extract_usage(&response);
|
||||
let cost = estimate_request_cost(&selected, prompt, completion);
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
@@ -1013,7 +1145,7 @@ async fn execute_chat_completions_with_conductor(
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
cost,
|
||||
attempts,
|
||||
false,
|
||||
)
|
||||
@@ -1024,7 +1156,7 @@ async fn execute_chat_completions_with_conductor(
|
||||
&billing_model,
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
cost,
|
||||
)
|
||||
.await;
|
||||
Ok(response)
|
||||
@@ -1073,7 +1205,8 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new(move |ctx| {
|
||||
let state = state.clone();
|
||||
let body = body_with_selected_model(body.clone(), &ctx.selected);
|
||||
let body =
|
||||
ensure_stream_usage(body_with_selected_model(body.clone(), &ctx.selected));
|
||||
async move {
|
||||
match executors::execute_chat_completions_stream(
|
||||
state,
|
||||
@@ -1160,8 +1293,11 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|chunk| {
|
||||
let payload = chunk.strip_prefix("data: ")?;
|
||||
if payload.trim() == "[DONE]" {
|
||||
let payload = chunk
|
||||
.strip_prefix("data: ")
|
||||
.or_else(|| chunk.strip_prefix("data:"))?
|
||||
.trim();
|
||||
if payload == "[DONE]" {
|
||||
return None;
|
||||
}
|
||||
let value: Value = serde_json::from_str(payload).ok()?;
|
||||
@@ -1173,6 +1309,7 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
}
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
let cost = estimate_request_cost(&selected, prompt, completion);
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
@@ -1181,7 +1318,7 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
cost,
|
||||
attempts,
|
||||
true,
|
||||
)
|
||||
@@ -1192,7 +1329,7 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
&billing_model,
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
cost,
|
||||
)
|
||||
.await;
|
||||
Ok(chunks)
|
||||
|
||||
@@ -49,13 +49,13 @@ pub fn routes() -> Router<AppState> {
|
||||
put(update_announcement).delete(delete_announcement),
|
||||
)
|
||||
.route("/api/site-announcements/{id}/read", post(mark_read_admin))
|
||||
.route("/api/site-announcements/read-all", post(mark_all_read_admin))
|
||||
.route(
|
||||
"/api/site-announcements/read-all",
|
||||
post(mark_all_read_admin),
|
||||
)
|
||||
.route("/api/user/announcements", get(list_user_announcements))
|
||||
.route("/api/user/announcements/{id}/read", post(mark_read_user))
|
||||
.route(
|
||||
"/api/user/announcements/read-all",
|
||||
post(mark_all_read_user),
|
||||
)
|
||||
.route("/api/user/announcements/read-all", post(mark_all_read_user))
|
||||
}
|
||||
|
||||
async fn list_admin_announcements(
|
||||
@@ -95,11 +95,7 @@ async fn list_admin_announcements(
|
||||
if let Some(status) = status {
|
||||
query = query.bind(status);
|
||||
}
|
||||
let rows = query
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let rows = query.bind(limit).bind(offset).fetch_all(&*state.db).await?;
|
||||
|
||||
Ok(Json(Value::Array(
|
||||
rows.into_iter().map(|row| row.into_admin_json()).collect(),
|
||||
@@ -135,7 +131,9 @@ async fn list_user_announcements(
|
||||
sql.push_str(" AND r.read_at IS NULL");
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC LIMIT ? OFFSET ?");
|
||||
sql.push_str(
|
||||
" ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC LIMIT ? OFFSET ?",
|
||||
);
|
||||
|
||||
let rows: Vec<UserAnnouncementRow> = sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
@@ -412,10 +410,14 @@ fn normalize_payload(payload: AnnouncementPayload) -> AppResult<NormalizedAnnoun
|
||||
let title = payload.title.trim().to_string();
|
||||
let content = payload.content.trim().to_string();
|
||||
if title.is_empty() {
|
||||
return Err(crate::error::AppError::BadRequest("title is required".into()));
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"title is required".into(),
|
||||
));
|
||||
}
|
||||
if content.is_empty() {
|
||||
return Err(crate::error::AppError::BadRequest("content is required".into()));
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"content is required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let level = payload
|
||||
|
||||
+371
-93
@@ -1,5 +1,6 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::background_task::BackgroundTask,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
@@ -12,6 +13,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -20,6 +22,305 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
||||
.route("/api/user/logs", get(get_user_logs))
|
||||
.route("/api/user/summary", get(get_user_summary))
|
||||
.route(
|
||||
"/api/user/models/marketplace",
|
||||
get(get_user_models_marketplace),
|
||||
)
|
||||
}
|
||||
|
||||
async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
||||
"SELECT *
|
||||
FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
AND json_extract(result_json, '$.userId') = ?
|
||||
ORDER BY created_at DESC, id DESC",
|
||||
)
|
||||
.bind("picture-agent-generation")
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(tasks
|
||||
.into_iter()
|
||||
.map(|task| {
|
||||
let result = task
|
||||
.result_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let status = match task.status.as_str() {
|
||||
"succeeded" => "success",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "cancelled",
|
||||
"pending" => "pending",
|
||||
_ => "running",
|
||||
};
|
||||
json!({
|
||||
"id": task.id,
|
||||
"route_id": Value::Null,
|
||||
"channel_id": Value::Null,
|
||||
"account_id": Value::Null,
|
||||
"downstream_api_key_id": Value::Null,
|
||||
"site_id": Value::Null,
|
||||
"username": Value::Null,
|
||||
"site_name": Value::Null,
|
||||
"site_url": Value::Null,
|
||||
"downstream_key_name": result.get("keyName").cloned().unwrap_or(Value::Null),
|
||||
"downstream_key_group_name": Value::Null,
|
||||
"downstream_key_tags": Value::Null,
|
||||
"model_requested": result.get("model").cloned().unwrap_or(Value::Null),
|
||||
"model_actual": result.get("model").cloned().unwrap_or(Value::Null),
|
||||
"status": status,
|
||||
"http_status": if status == "success" { 200 } else if status == "running" || status == "pending" { 202 } else { 500 },
|
||||
"latency_ms": Value::Null,
|
||||
"first_byte_latency_ms": Value::Null,
|
||||
"prompt_tokens": Value::Null,
|
||||
"completion_tokens": Value::Null,
|
||||
"total_tokens": Value::Null,
|
||||
"estimated_cost": 0.0,
|
||||
"error_message": task.error.clone(),
|
||||
"retry_count": 0,
|
||||
"is_stream": false,
|
||||
"client_family": "picture-agent",
|
||||
"client_app_id": "picture-agent",
|
||||
"client_app_name": "Picture Agent",
|
||||
"client_confidence": "high",
|
||||
"created_at": task.created_at,
|
||||
"source": "picture-agent",
|
||||
"task_type": task.task_type,
|
||||
"title": task.title,
|
||||
"message": task.message,
|
||||
"error": task.error,
|
||||
"progress": task.progress,
|
||||
"progress_total": task.progress_total,
|
||||
"status_label": status,
|
||||
"result": result,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.id,
|
||||
l.model,
|
||||
l.cost,
|
||||
l.prompt_tokens,
|
||||
l.completion_tokens,
|
||||
l.total_tokens,
|
||||
l.created_at,
|
||||
k.name AS downstream_key_name
|
||||
FROM usage_logs l
|
||||
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
LIMIT 5000
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let model = row
|
||||
.try_get::<Option<String>, _>("model")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let model_trimmed = model.trim().to_string();
|
||||
let source_model = model_trimmed
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&model_trimmed)
|
||||
.trim()
|
||||
.to_string();
|
||||
json!({
|
||||
"id": format!("usage-{}", row.try_get::<i64, _>("id").unwrap_or_default()),
|
||||
"route_id": Value::Null,
|
||||
"channel_id": Value::Null,
|
||||
"account_id": Value::Null,
|
||||
"downstream_api_key_id": Value::Null,
|
||||
"site_id": Value::Null,
|
||||
"username": Value::Null,
|
||||
"site_name": Value::Null,
|
||||
"site_url": Value::Null,
|
||||
"downstream_key_name": row.try_get::<Option<String>, _>("downstream_key_name").ok().flatten(),
|
||||
"downstream_key_group_name": Value::Null,
|
||||
"downstream_key_tags": Value::Null,
|
||||
"model_requested": source_model,
|
||||
"model_actual": source_model,
|
||||
"status": "success",
|
||||
"http_status": 200,
|
||||
"latency_ms": Value::Null,
|
||||
"first_byte_latency_ms": Value::Null,
|
||||
"prompt_tokens": row.try_get::<Option<i64>, _>("prompt_tokens").ok().flatten(),
|
||||
"completion_tokens": row.try_get::<Option<i64>, _>("completion_tokens").ok().flatten(),
|
||||
"total_tokens": row.try_get::<Option<i64>, _>("total_tokens").ok().flatten(),
|
||||
"estimated_cost": row.try_get::<Option<f64>, _>("cost").ok().flatten().unwrap_or(0.0),
|
||||
"error_message": Value::Null,
|
||||
"retry_count": 0,
|
||||
"is_stream": false,
|
||||
"client_family": "picture-agent",
|
||||
"client_app_id": "picture-agent",
|
||||
"client_app_name": "Picture Agent",
|
||||
"client_confidence": "high",
|
||||
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
|
||||
"source": "usage-logs",
|
||||
"task_type": "usage-log",
|
||||
"title": "生图助手",
|
||||
"message": model_trimmed,
|
||||
"error": Value::Null,
|
||||
"progress": Value::Null,
|
||||
"progress_total": Value::Null,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn user_log_text(item: &Value) -> String {
|
||||
[
|
||||
item.get("model_requested").and_then(Value::as_str),
|
||||
item.get("model_actual").and_then(Value::as_str),
|
||||
item.get("downstream_key_name").and_then(Value::as_str),
|
||||
item.get("site_name").and_then(Value::as_str),
|
||||
item.get("site_url").and_then(Value::as_str),
|
||||
item.get("error_message").and_then(Value::as_str),
|
||||
item.get("message").and_then(Value::as_str),
|
||||
item.get("title").and_then(Value::as_str),
|
||||
item.get("source").and_then(Value::as_str),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn user_log_status(item: &Value) -> String {
|
||||
item.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn user_log_created_at(item: &Value) -> String {
|
||||
item.get("created_at")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn user_log_cost(item: &Value) -> f64 {
|
||||
item.get("estimated_cost")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn user_log_tokens(item: &Value) -> i64 {
|
||||
item.get("total_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn matches_user_log_filters(
|
||||
item: &Value,
|
||||
q: Option<&str>,
|
||||
status: Option<&str>,
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(q) = q {
|
||||
let q = q.to_ascii_lowercase();
|
||||
if !user_log_text(item).contains(&q) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(status) = status {
|
||||
let item_status = user_log_status(item);
|
||||
let normalized = status.to_ascii_lowercase();
|
||||
if normalized == "success" {
|
||||
if item_status != "success" {
|
||||
return false;
|
||||
}
|
||||
} else if normalized == "failed" {
|
||||
if !matches!(item_status.as_str(), "failed" | "cancelled") {
|
||||
return false;
|
||||
}
|
||||
} else if item_status != normalized {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let created_at = user_log_created_at(item);
|
||||
if let Some(date_from) = date_from {
|
||||
if !created_at.is_empty() && created_at.as_str() < date_from {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(date_to) = date_to {
|
||||
if !created_at.is_empty() && created_at.as_str() > date_to {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn sort_user_logs_desc(items: &mut [Value]) {
|
||||
items.sort_by(|a, b| {
|
||||
let created_cmp = user_log_created_at(b).cmp(&user_log_created_at(a));
|
||||
if created_cmp != std::cmp::Ordering::Equal {
|
||||
return created_cmp;
|
||||
}
|
||||
let id_a = a.get("id").map(Value::to_string).unwrap_or_default();
|
||||
let id_b = b.get("id").map(Value::to_string).unwrap_or_default();
|
||||
id_b.cmp(&id_a)
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||
r#"
|
||||
SELECT
|
||||
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
|
||||
l.model_requested, l.model_actual, l.status, l.http_status,
|
||||
l.latency_ms, l.first_byte_latency_ms,
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
s.name AS site_name,
|
||||
s.url AS site_url,
|
||||
k.name AS downstream_key_name,
|
||||
k.group_name AS downstream_key_group_name,
|
||||
k.tags AS downstream_key_tags
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 5000
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
items.extend(user_usage_log_items(state, user_id).await?);
|
||||
items.extend(user_picture_log_items(state, user_id).await?);
|
||||
sort_user_logs_desc(&mut items);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -190,98 +491,40 @@ async fn get_user_logs(
|
||||
let offset = params.offset.unwrap_or((page - 1) * page_size).max(0);
|
||||
|
||||
let q = params.q.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let q_like = q.map(|s| format!("%{s}%"));
|
||||
let status = params
|
||||
.status
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "all");
|
||||
let date_from = params.date_from.as_deref().filter(|s| !s.is_empty());
|
||||
let date_to = params.date_to.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||
r#"
|
||||
SELECT
|
||||
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
|
||||
l.model_requested, l.model_actual, l.status, l.http_status,
|
||||
l.latency_ms, l.first_byte_latency_ms,
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
s.name AS site_name,
|
||||
s.url AS site_url,
|
||||
k.name AS downstream_key_name,
|
||||
k.group_name AS downstream_key_group_name,
|
||||
k.tags AS downstream_key_tags
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
let (total, success_count, total_cost, total_tokens): (i64, i64, f64, i64) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||
COALESCE(SUM(l.total_tokens), 0)
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let items = load_user_log_items(&state, user_id).await?;
|
||||
let filtered_items: Vec<Value> = items
|
||||
.into_iter()
|
||||
.filter(|item| matches_user_log_filters(item, q, status, date_from, date_to))
|
||||
.collect();
|
||||
let total = filtered_items.len() as i64;
|
||||
let success_count = filtered_items
|
||||
.iter()
|
||||
.filter(|item| user_log_status(item).as_str() == "success")
|
||||
.count() as i64;
|
||||
let failed_count = total - success_count;
|
||||
let total_cost = filtered_items.iter().map(user_log_cost).sum::<f64>();
|
||||
let total_tokens = filtered_items.iter().map(user_log_tokens).sum::<i64>();
|
||||
let paged_items = filtered_items
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.take(page_size as usize)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"items": paged_items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success_count,
|
||||
"failed": total - success_count,
|
||||
"failed": failed_count,
|
||||
"total_cost": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
@@ -293,22 +536,57 @@ async fn get_user_summary(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
|
||||
let stats: (i64, i64, f64) = sqlx::query_as(
|
||||
"SELECT COUNT(*),
|
||||
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0)
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let items = load_user_log_items(&state, user_id).await?;
|
||||
let total = items.len() as i64;
|
||||
let success_count = items
|
||||
.iter()
|
||||
.filter(|item| user_log_status(item).as_str() == "success")
|
||||
.count() as i64;
|
||||
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"totalRequests": stats.0,
|
||||
"successRequests": stats.1,
|
||||
"totalCost": stats.2,
|
||||
"totalRequests": total,
|
||||
"successRequests": success_count,
|
||||
"totalCost": total_cost,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_user_models_marketplace(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<crate::routes::admin::models_marketplace::MarketplaceQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _user_id = current_user_id(&headers, &state)?;
|
||||
let value = crate::routes::admin::models_marketplace::marketplace_inner(&state.db).await?;
|
||||
let models = value
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let brand = model.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
json!({
|
||||
"name": brand,
|
||||
"accountCount": 0,
|
||||
"tokenCount": 0,
|
||||
"avgLatency": model.get("avgLatency").cloned().unwrap_or(Value::Null),
|
||||
"successRate": model.get("successRate").cloned().unwrap_or(Value::Null),
|
||||
"description": model.get("description").cloned().unwrap_or(Value::Null),
|
||||
"tags": model.get("tags").cloned().unwrap_or_else(|| json!([])),
|
||||
"supportedEndpointTypes": model.get("supportedEndpointTypes").cloned().unwrap_or_else(|| json!([])),
|
||||
"pricingSources": [],
|
||||
"accounts": [],
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(json!({
|
||||
"models": models,
|
||||
"meta": value.get("meta").cloned().unwrap_or_else(|| json!({})),
|
||||
"userView": true,
|
||||
"includePricing": query.include_pricing.is_some(),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
//! `/api/video-agent/*` — per-user multi-tenant refactor.
|
||||
//!
|
||||
//! Every handler resolves the caller from the JWT in the `Authorization`
|
||||
//! header (no anonymous access) and, where it makes sense, lazily starts
|
||||
//! that user's ComfyUI container via [`ComfyUIPool`]. The LLM/image/TTS
|
||||
//! helpers continue to call BoosAPI's own `/v1/*` loopback — they are
|
||||
//! independent of ComfyUI and stay shared.
|
||||
//!
|
||||
//! Status responses preserve every existing JSON key the frontend reads
|
||||
//! and add `containerState` so the new full-screen loading overlay can
|
||||
//! poll until the container is ready.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
error::{AppError, AppResult},
|
||||
services::comfyui_pool::{ContainerState, PoolError},
|
||||
AppState,
|
||||
};
|
||||
|
||||
const COLD_START_HOLD: Duration = Duration::from_secs(30);
|
||||
const COLD_START_POLL: Duration = Duration::from_millis(500);
|
||||
const ADMIN_WORKSPACE_USER_ID: i64 = 0;
|
||||
|
||||
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
|
||||
|
||||
{
|
||||
"title": "视频标题",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"gender": "男/女",
|
||||
"age": "年龄描述",
|
||||
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
|
||||
"personality": "性格特征"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": 1,
|
||||
"description": "场景描述",
|
||||
"environment": "环境详细描述(时间、地点、天气、光线等)",
|
||||
"characters": ["出现的角色名"],
|
||||
"action": "场景中发生的动作",
|
||||
"dialogue": [
|
||||
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"style": "整体视觉风格(动画/写实/像素等)"
|
||||
}
|
||||
|
||||
只返回 JSON,不要其他文字。"#;
|
||||
|
||||
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
|
||||
每组提示词应包含:人物姿态、服装、光照、背景。
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"front": "正面提示词",
|
||||
"back": "背面提示词",
|
||||
"left": "左侧面提示词",
|
||||
"right": "右侧面提示词"
|
||||
}
|
||||
只返回 JSON。"#;
|
||||
|
||||
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
|
||||
包含:环境、光照、色调、氛围。
|
||||
返回 JSON:
|
||||
{
|
||||
"prompt": "场景生成提示词",
|
||||
"negative_prompt": "负面提示词"
|
||||
}
|
||||
只返回 JSON。"#;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/video-agent/status", get(status))
|
||||
.route("/api/video-agent/start", post(start_comfyui))
|
||||
.route("/api/video-agent/stop", post(stop_comfyui))
|
||||
.route("/api/video-agent/restart", post(restart_comfyui))
|
||||
.route("/api/video-agent/chat", post(chat))
|
||||
.route("/api/video-agent/analyze", post(analyze))
|
||||
.route(
|
||||
"/api/video-agent/character-prompts",
|
||||
post(character_prompts),
|
||||
)
|
||||
.route(
|
||||
"/api/video-agent/generate-character",
|
||||
post(generate_character),
|
||||
)
|
||||
.route("/api/video-agent/scene-prompt", post(scene_prompt))
|
||||
.route("/api/video-agent/generate-scene", post(generate_scene))
|
||||
.route("/api/video-agent/generate-tts", post(generate_tts))
|
||||
.route("/api/video-agent/full-workflow", post(full_workflow))
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the caller workspace from `Authorization: Bearer <token>`.
|
||||
/// User JWTs map to their real user id; admin/global proxy tokens map to
|
||||
/// workspace 0 so the admin console can use the same Docker-backed ComfyUI.
|
||||
fn resolve_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
|
||||
let auth = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
if auth == state.config.auth.auth_token || auth == state.config.auth.proxy_token {
|
||||
return Ok(ADMIN_WORKSPACE_USER_ID);
|
||||
}
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
claims
|
||||
.sub
|
||||
.parse::<i64>()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
|
||||
}
|
||||
|
||||
// ── Pool helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Map a [`PoolError`] to the appropriate [`AppError`].
|
||||
fn pool_err_to_app(e: PoolError) -> AppError {
|
||||
match e {
|
||||
PoolError::AtCapacity(_) => AppError::ServiceUnavailable("comfyui pool at capacity".into()),
|
||||
PoolError::StartTimeout(_) => {
|
||||
AppError::GatewayTimeout("comfyui container start timeout".into())
|
||||
}
|
||||
other => AppError::Internal(anyhow::anyhow!(other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily start the caller's container and wait for readiness up to 30 s.
|
||||
/// Returns the host port for the loopback HTTP API.
|
||||
async fn ensure_container_ready(state: &AppState, user_id: i64) -> AppResult<u16> {
|
||||
state
|
||||
.comfyui_pool
|
||||
.get_or_start(user_id)
|
||||
.await
|
||||
.map_err(pool_err_to_app)?;
|
||||
|
||||
let deadline = Instant::now() + COLD_START_HOLD;
|
||||
loop {
|
||||
match state.comfyui_pool.state(user_id).await {
|
||||
ContainerState::Ready => break,
|
||||
ContainerState::Failed | ContainerState::Stopped => {
|
||||
return Err(AppError::UpstreamError(
|
||||
"comfyui container failed to start".into(),
|
||||
));
|
||||
}
|
||||
ContainerState::Starting => {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(AppError::GatewayTimeout(
|
||||
"comfyui cold-start timeout".into(),
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(COLD_START_POLL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
state
|
||||
.comfyui_pool
|
||||
.entry(user_id)
|
||||
.await
|
||||
.map(|e| e.host_port)
|
||||
.ok_or_else(|| AppError::UpstreamError("container vanished after ready".into()))
|
||||
}
|
||||
|
||||
/// JSON status response. Preserves the keys the frontend already reads
|
||||
/// (`status`, `pid`, `uptime_secs`, `mode`, `model_backend`, `base_url`,
|
||||
/// `version`, `last_error`) and adds `containerState` (task 5.2).
|
||||
async fn pool_status_json(state: &AppState, user_id: i64) -> Value {
|
||||
let cs = state.comfyui_pool.state(user_id).await;
|
||||
let entry = state.comfyui_pool.entry(user_id).await;
|
||||
let port = entry.as_ref().map(|e| e.host_port);
|
||||
let last_error = entry.as_ref().and_then(|e| e.last_error.clone());
|
||||
|
||||
let status = match cs {
|
||||
ContainerState::Starting => "starting",
|
||||
ContainerState::Ready => "running",
|
||||
ContainerState::Stopped => "stopped",
|
||||
ContainerState::Failed => "unhealthy",
|
||||
};
|
||||
|
||||
let base_url = port
|
||||
.map(|p| format!("http://127.0.0.1:{}", p))
|
||||
.unwrap_or_default();
|
||||
|
||||
let model_backend = format!(
|
||||
"{}/v1",
|
||||
state
|
||||
.comfyui_pool
|
||||
.config()
|
||||
.boosapi_base
|
||||
.trim_end_matches("/v1")
|
||||
.trim_end_matches('/')
|
||||
);
|
||||
|
||||
json!({
|
||||
"status": status,
|
||||
"pid": Value::Null,
|
||||
"uptime_secs": 0,
|
||||
"mode": "container",
|
||||
"model_backend": model_backend,
|
||||
"base_url": base_url,
|
||||
"version": Value::Null,
|
||||
"last_error": last_error,
|
||||
"containerState": cs.as_str(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Lifecycle handlers ─────────────────────────────────────────────────
|
||||
|
||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
Ok(Json(pool_status_json(&state, user_id).await))
|
||||
}
|
||||
|
||||
async fn start_comfyui(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
// Idempotent: get_or_start returns the existing entry if already up.
|
||||
state
|
||||
.comfyui_pool
|
||||
.get_or_start(user_id)
|
||||
.await
|
||||
.map_err(pool_err_to_app)?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": pool_status_json(&state, user_id).await,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
state
|
||||
.comfyui_pool
|
||||
.stop(user_id)
|
||||
.await
|
||||
.map_err(pool_err_to_app)?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": pool_status_json(&state, user_id).await,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn restart_comfyui(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
// Stop is best-effort: if the container is already gone, that's fine.
|
||||
let _ = state.comfyui_pool.stop(user_id).await;
|
||||
state
|
||||
.comfyui_pool
|
||||
.get_or_start(user_id)
|
||||
.await
|
||||
.map_err(pool_err_to_app)?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": pool_status_json(&state, user_id).await,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── BoosAPI loopback helpers ───────────────────────────────────────────
|
||||
//
|
||||
// LLM, image, and TTS calls go to BoosAPI's own `/v1/*` endpoints — they
|
||||
// are not ComfyUI traffic. We pull the base URL from the same config the
|
||||
// pool uses so there's exactly one source of truth.
|
||||
|
||||
fn boosapi_v1_base(state: &AppState) -> String {
|
||||
let raw = state
|
||||
.comfyui_pool
|
||||
.config()
|
||||
.boosapi_base
|
||||
.trim_end_matches('/');
|
||||
if raw.ends_with("/v1") {
|
||||
raw.to_string()
|
||||
} else {
|
||||
format!("{}/v1", raw)
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
// Lazily warm the container so a subsequent generate-* call doesn't
|
||||
// pay the full cold start.
|
||||
let _ = state.comfyui_pool.get_or_start(user_id).await;
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
let prompt = body
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
body.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|messages| messages.last())
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or("生成一个短视频工作流");
|
||||
|
||||
let workflow = build_basic_workflow(prompt);
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "已收到需求,正在生成 ComfyUI workflow..."
|
||||
}),
|
||||
json!({
|
||||
"type": "workflow_generated",
|
||||
"workflow": workflow
|
||||
}),
|
||||
json!({
|
||||
"type": "done"
|
||||
}),
|
||||
];
|
||||
|
||||
Ok(Sse::new(stream::iter(events.into_iter().map(|event| {
|
||||
Ok(Event::default().data(event.to_string()))
|
||||
}))))
|
||||
}
|
||||
|
||||
fn build_basic_workflow(prompt: &str) -> Value {
|
||||
json!({
|
||||
"boosapi": {
|
||||
"kind": "video-agent-workflow",
|
||||
"version": 1,
|
||||
"prompt": prompt,
|
||||
},
|
||||
"nodes": {},
|
||||
})
|
||||
}
|
||||
|
||||
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
|
||||
body.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
|
||||
body.get(key)
|
||||
.filter(|v| !v.is_null())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn strip_json_markers(s: &str) -> String {
|
||||
let mut text = s.trim().to_string();
|
||||
|
||||
if text.starts_with("```json") {
|
||||
text = text.trim_start_matches("```json").to_string();
|
||||
} else if text.starts_with("```") {
|
||||
text = text.trim_start_matches("```").to_string();
|
||||
}
|
||||
|
||||
if text.ends_with("```") {
|
||||
text = text.trim_end_matches("```").to_string();
|
||||
}
|
||||
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
fn parse_llm_json(raw: &str) -> AppResult<Value> {
|
||||
let cleaned = strip_json_markers(raw);
|
||||
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"failed to parse LLM JSON response: {}; response: {}",
|
||||
e,
|
||||
cleaned
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", boosapi_v1_base(state));
|
||||
|
||||
info!("video-agent calling BoosAPI LLM endpoint: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": &state.config.comfyui.agent_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": prompt }
|
||||
],
|
||||
"temperature": 0.7
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"LLM upstream returned {}: {}",
|
||||
status,
|
||||
data
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("message"))
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/images/generations", boosapi_v1_base(state));
|
||||
|
||||
info!("video-agent calling BoosAPI image endpoint: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"image upstream returned {}: {}",
|
||||
status,
|
||||
data
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/audio/speech", boosapi_v1_base(state));
|
||||
|
||||
info!("video-agent calling BoosAPI TTS endpoint: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"TTS upstream returned {}: {}",
|
||||
status,
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
// ── Per-request handlers (5.5) ─────────────────────────────────────────
|
||||
|
||||
async fn analyze(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
|
||||
let result = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let parsed = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": parsed
|
||||
})))
|
||||
}
|
||||
|
||||
async fn character_prompts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
let character = required_value(&body, "character")?;
|
||||
|
||||
let name = character
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let appearance = character
|
||||
.get("appearance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let gender = character
|
||||
.get("gender")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let age = character
|
||||
.get("age")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let result = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": prompts
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_character(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
// Image generation can take a while; ensure the container is warm so
|
||||
// downstream ComfyUI workflow reuse doesn't pay another cold start.
|
||||
let _ = ensure_container_ready(&state, user_id).await?;
|
||||
|
||||
let prompts = required_value(&body, "prompts")?;
|
||||
let prompt_obj = prompts
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
|
||||
|
||||
let mut results = serde_json::Map::new();
|
||||
|
||||
for (angle, prompt_value) in prompt_obj {
|
||||
let prompt = prompt_value.as_str().ok_or_else(|| {
|
||||
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
|
||||
})?;
|
||||
|
||||
let url = call_image_gen(&state, prompt).await?;
|
||||
results.insert(angle.clone(), Value::String(url));
|
||||
}
|
||||
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": Value::Object(results)
|
||||
})))
|
||||
}
|
||||
|
||||
async fn scene_prompt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
let scene = required_value(&body, "scene")?;
|
||||
|
||||
let environment = scene
|
||||
.get("environment")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = scene
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let result = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
|
||||
let prompt = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": prompt
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_scene(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
let _ = ensure_container_ready(&state, user_id).await?;
|
||||
|
||||
let prompt = required_str(&body, "prompt")?;
|
||||
let url = call_image_gen(&state, prompt).await?;
|
||||
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"url": url
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_tts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Response<Body>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
let text = required_str(&body, "text")?;
|
||||
let voice = body
|
||||
.get("voice")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("alloy");
|
||||
|
||||
let audio = call_tts(&state, text, voice).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "audio/mpeg")],
|
||||
audio,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn full_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = resolve_user_id(&headers, &state)?;
|
||||
let _ = ensure_container_ready(&state, user_id).await?;
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
let requested_style = body.get("style").and_then(Value::as_str);
|
||||
|
||||
let analysis_raw = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let analysis = parse_llm_json(&analysis_raw)?;
|
||||
|
||||
let mut characters = Vec::new();
|
||||
|
||||
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
|
||||
for character in chars {
|
||||
let name = character
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let appearance = character
|
||||
.get("appearance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let gender = character
|
||||
.get("gender")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let age = character
|
||||
.get("age")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let prompts_raw = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&prompts_raw)?;
|
||||
|
||||
let mut item = character.clone();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("imagePrompts".to_string(), prompts);
|
||||
}
|
||||
|
||||
characters.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let mut scenes = Vec::new();
|
||||
|
||||
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
|
||||
for scene in scene_items {
|
||||
let environment = scene
|
||||
.get("environment")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = scene
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let scene_prompt_raw = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
|
||||
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
|
||||
|
||||
let mut item = scene.clone();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("imagePrompt".to_string(), image_prompt);
|
||||
}
|
||||
|
||||
scenes.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let style = analysis
|
||||
.get("style")
|
||||
.cloned()
|
||||
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
state.comfyui_pool.touch(user_id).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
|
||||
"style": style,
|
||||
"characters": characters,
|
||||
"scenes": scenes
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -93,10 +93,7 @@ pub fn routes() -> Router<AppState> {
|
||||
|
||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(state.comfyui.status_response().await)
|
||||
.unwrap_or_else(|_| json!({ "status": "unknown" })),
|
||||
))
|
||||
Ok(Json(external_comfyui_status(&state).await))
|
||||
}
|
||||
|
||||
async fn start_comfyui(
|
||||
@@ -104,27 +101,19 @@ async fn start_comfyui(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
state
|
||||
.comfyui
|
||||
.start()
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": state.comfyui.status_response().await
|
||||
"message": "内置 Docker ComfyUI 工作台已移除;请配置外部 ComfyUI 或使用 /workflows 工作流画布。",
|
||||
"comfyui": external_comfyui_status_value(&state).await
|
||||
})))
|
||||
}
|
||||
|
||||
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
state
|
||||
.comfyui
|
||||
.stop()
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": state.comfyui.status_response().await
|
||||
"message": "内置 Docker ComfyUI 工作台已移除;BoosAPI 不再管理 ComfyUI 进程生命周期。",
|
||||
"comfyui": external_comfyui_status_value(&state).await
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -133,14 +122,10 @@ async fn restart_comfyui(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
state
|
||||
.comfyui
|
||||
.restart()
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": state.comfyui.status_response().await
|
||||
"message": "内置 Docker ComfyUI 工作台已移除;请在外部 ComfyUI 服务侧重启。",
|
||||
"comfyui": external_comfyui_status_value(&state).await
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -187,6 +172,33 @@ fn local_api_base(state: &AppState) -> String {
|
||||
state.comfyui.model_backend_url()
|
||||
}
|
||||
|
||||
async fn external_comfyui_status(state: &AppState) -> Value {
|
||||
json!({
|
||||
"success": true,
|
||||
"comfyui": external_comfyui_status_value(state).await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn external_comfyui_status_value(state: &AppState) -> Value {
|
||||
let status = state.comfyui.status_response().await;
|
||||
let reachable = state.comfyui.health_check().await;
|
||||
json!({
|
||||
"status": if reachable { "running" } else { "unavailable" },
|
||||
"pid": null,
|
||||
"uptime_secs": 0,
|
||||
"mode": "external",
|
||||
"model_backend": status.model_backend,
|
||||
"base_url": status.base_url,
|
||||
"version": status.version,
|
||||
"last_error": if reachable {
|
||||
Value::Null
|
||||
} else {
|
||||
json!("外部 ComfyUI 不可达;内置 Docker ComfyUI 工作台已移除,请配置 comfyui.base_url 或使用 /workflows 工作流画布。")
|
||||
},
|
||||
"dockerManaged": false,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let expected = format!("Bearer {}", state.config.auth.auth_token);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -36,11 +36,23 @@ struct AssetQuery {
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/workspace/assets", get(list_admin_assets))
|
||||
.route("/api/workspace/assets/{public_id}", get(get_admin_asset).delete(delete_admin_asset))
|
||||
.route("/api/workspace/assets/{public_id}/file", get(get_admin_asset_file))
|
||||
.route(
|
||||
"/api/workspace/assets/{public_id}",
|
||||
get(get_admin_asset).delete(delete_admin_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/workspace/assets/{public_id}/file",
|
||||
get(get_admin_asset_file),
|
||||
)
|
||||
.route("/api/user/workspace/assets", get(list_user_assets))
|
||||
.route("/api/user/workspace/assets/{public_id}", get(get_user_asset).delete(delete_user_asset))
|
||||
.route("/api/user/workspace/assets/{public_id}/file", get(get_user_asset_file))
|
||||
.route(
|
||||
"/api/user/workspace/assets/{public_id}",
|
||||
get(get_user_asset).delete(delete_user_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/user/workspace/assets/{public_id}/file",
|
||||
get(get_user_asset_file),
|
||||
)
|
||||
}
|
||||
|
||||
fn bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
@@ -56,7 +68,11 @@ fn bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn resolve_caller(headers: &HeaderMap, state: &AppState, admin_required: bool) -> AppResult<WorkspaceCaller> {
|
||||
fn resolve_caller(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
admin_required: bool,
|
||||
) -> AppResult<WorkspaceCaller> {
|
||||
let token = bearer(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
@@ -79,7 +95,16 @@ fn resolve_caller(headers: &HeaderMap, state: &AppState, admin_required: bool) -
|
||||
Err(AppError::Forbidden("Workspace access denied".into()))
|
||||
}
|
||||
|
||||
fn normalize_query(query: &AssetQuery) -> (i64, i64, Option<String>, Option<String>, Option<String>, Option<i64>) {
|
||||
fn normalize_query(
|
||||
query: &AssetQuery,
|
||||
) -> (
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
) {
|
||||
let page_size = query.page_size.unwrap_or(36).clamp(1, 120);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
//! Per-user ComfyUI container pool.
|
||||
//!
|
||||
//! Owns the in-memory map `user_id -> PoolEntry`, allocates ports from the
|
||||
//! 8200..=8299 range, enforces the 30-container ceiling, runs the readiness
|
||||
//! probe, and reaps idle containers.
|
||||
//!
|
||||
//! All Docker/K3S specifics are encapsulated by the [`ContainerRuntime`]
|
||||
//! trait; this file MUST stay backend-agnostic.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::{Mutex as AsyncMutex, RwLock};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::container_runtime::{
|
||||
BindMount, ContainerRuntime, ContainerSpec, ResourceLimits, RuntimeError, TmpfsMount,
|
||||
};
|
||||
|
||||
/// Maximum concurrent containers (§design Decision 2: 30 × 2 GB ≈ 60 GB).
|
||||
pub const DEFAULT_MAX_CONCURRENT: usize = 30;
|
||||
/// Idle timeout before the reaper stops a container.
|
||||
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 60);
|
||||
/// Host port range for per-user containers.
|
||||
pub const PORT_RANGE_START: u16 = 8200;
|
||||
pub const PORT_RANGE_END: u16 = 8299;
|
||||
/// Time we wait for a freshly started container to answer `/system_stats`.
|
||||
pub const READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How often the reaper task wakes up.
|
||||
pub const REAPER_TICK: Duration = Duration::from_secs(60);
|
||||
/// Image tag we run for per-user tenants.
|
||||
pub const DEFAULT_IMAGE: &str = "boosapi-comfyui:multi-tenant";
|
||||
/// Host directory under which per-user volumes live.
|
||||
pub const DEFAULT_DATA_ROOT: &str = "/data/comfyui-users";
|
||||
|
||||
/// Lifecycle state visible to callers (frontend renders loading vs ready).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContainerState {
|
||||
Starting,
|
||||
Ready,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ContainerState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ContainerState::Starting => "starting",
|
||||
ContainerState::Ready => "ready",
|
||||
ContainerState::Stopped => "stopped",
|
||||
ContainerState::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One running (or starting) per-user container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolEntry {
|
||||
pub user_id: i64,
|
||||
pub container_name: String,
|
||||
pub host_port: u16,
|
||||
pub state: ContainerState,
|
||||
pub last_used_at: Instant,
|
||||
pub active_ws: u32,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PoolError {
|
||||
#[error("pool at capacity ({0} containers running)")]
|
||||
AtCapacity(usize),
|
||||
#[error("no free port in pool range")]
|
||||
OutOfPorts,
|
||||
#[error("container failed to become ready in {0:?}")]
|
||||
StartTimeout(Duration),
|
||||
#[error("runtime error: {0}")]
|
||||
Runtime(#[from] RuntimeError),
|
||||
#[error("internal: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type PoolResult<T> = Result<T, PoolError>;
|
||||
|
||||
/// Tunable knobs the binary wires up at boot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolConfig {
|
||||
pub image: String,
|
||||
pub max_concurrent: usize,
|
||||
pub idle_timeout: Duration,
|
||||
pub data_root: PathBuf,
|
||||
pub boosapi_base: String,
|
||||
pub builtin_key: String,
|
||||
}
|
||||
|
||||
impl PoolConfig {
|
||||
pub fn from_env(boosapi_base: String, builtin_key: String) -> Self {
|
||||
let image = std::env::var("BOOSAPI_COMFY_IMAGE").unwrap_or_else(|_| DEFAULT_IMAGE.into());
|
||||
let max_concurrent: usize = std::env::var("BOOSAPI_COMFY_MAX")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_CONCURRENT);
|
||||
let idle_secs: u64 = std::env::var("BOOSAPI_COMFY_IDLE_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_IDLE_TIMEOUT.as_secs());
|
||||
let data_root = std::env::var("BOOSAPI_COMFY_DATA_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(DEFAULT_DATA_ROOT));
|
||||
Self {
|
||||
image,
|
||||
max_concurrent,
|
||||
idle_timeout: Duration::from_secs(idle_secs),
|
||||
data_root,
|
||||
boosapi_base,
|
||||
builtin_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe allocator over `PORT_RANGE_START..=PORT_RANGE_END`.
|
||||
struct PortAllocator {
|
||||
in_use: Mutex<HashSet<u16>>,
|
||||
}
|
||||
|
||||
impl PortAllocator {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
in_use: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(&self, port: u16) {
|
||||
self.in_use.lock().insert(port);
|
||||
}
|
||||
|
||||
fn allocate(&self) -> Option<u16> {
|
||||
let mut g = self.in_use.lock();
|
||||
for p in PORT_RANGE_START..=PORT_RANGE_END {
|
||||
if !g.contains(&p) {
|
||||
g.insert(p);
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn release(&self, port: u16) {
|
||||
self.in_use.lock().remove(&port);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-user start lock. Prevents two concurrent calls to
|
||||
/// `get_or_start(7)` from spawning two containers for user 7.
|
||||
type StartLock = Arc<AsyncMutex<()>>;
|
||||
|
||||
/// The pool itself.
|
||||
pub struct ComfyUIPool {
|
||||
runtime: Arc<dyn ContainerRuntime>,
|
||||
config: PoolConfig,
|
||||
containers: RwLock<HashMap<i64, PoolEntry>>,
|
||||
start_locks: Mutex<HashMap<i64, StartLock>>,
|
||||
ports: PortAllocator,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ComfyUIPool {
|
||||
pub fn new(runtime: Arc<dyn ContainerRuntime>, config: PoolConfig) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
runtime,
|
||||
config,
|
||||
containers: RwLock::new(HashMap::new()),
|
||||
start_locks: Mutex::new(HashMap::new()),
|
||||
ports: PortAllocator::new(),
|
||||
http: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("reqwest client"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &PoolConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Start the user's container if it isn't already running and return a
|
||||
/// handle. Idempotent.
|
||||
pub async fn get_or_start(&self, user_id: i64) -> PoolResult<PoolEntry> {
|
||||
// Fast path: already ready.
|
||||
let existing = {
|
||||
let map = self.containers.read().await;
|
||||
map.get(&user_id).cloned()
|
||||
};
|
||||
if let Some(entry) = existing {
|
||||
if entry.state == ContainerState::Ready {
|
||||
self.touch_inner(user_id).await;
|
||||
return Ok(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-user start lock — only one task gets to spawn per user.
|
||||
let lock = self
|
||||
.start_locks
|
||||
.lock()
|
||||
.entry(user_id)
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
|
||||
.clone();
|
||||
let _g = lock.lock().await;
|
||||
|
||||
// Re-check after acquiring the lock; another task may have done the work.
|
||||
let existing = {
|
||||
let map = self.containers.read().await;
|
||||
map.get(&user_id).cloned()
|
||||
};
|
||||
if let Some(entry) = existing {
|
||||
if entry.state == ContainerState::Ready {
|
||||
self.touch_inner(user_id).await;
|
||||
return Ok(entry);
|
||||
}
|
||||
if entry.state == ContainerState::Starting {
|
||||
// Probe ourselves — the previous winner may have died mid-start.
|
||||
return self.probe_until_ready(user_id, entry).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Capacity check.
|
||||
{
|
||||
let map = self.containers.read().await;
|
||||
let live = map
|
||||
.values()
|
||||
.filter(|e| matches!(e.state, ContainerState::Starting | ContainerState::Ready))
|
||||
.count();
|
||||
if live >= self.config.max_concurrent {
|
||||
return Err(PoolError::AtCapacity(live));
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate port.
|
||||
let port = self.ports.allocate().ok_or(PoolError::OutOfPorts)?;
|
||||
let name = format!("comfyui-user-{}", user_id);
|
||||
|
||||
// Insert a Starting marker so concurrent callers see it.
|
||||
{
|
||||
let mut map = self.containers.write().await;
|
||||
map.insert(
|
||||
user_id,
|
||||
PoolEntry {
|
||||
user_id,
|
||||
container_name: name.clone(),
|
||||
host_port: port,
|
||||
state: ContainerState::Starting,
|
||||
last_used_at: Instant::now(),
|
||||
active_ws: 0,
|
||||
last_error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let spec = self.build_spec(user_id, &name, port);
|
||||
|
||||
match self.runtime.start(spec).await {
|
||||
Ok(handle) => {
|
||||
debug!(user_id, container = %handle.name, port = handle.host_port, "container started");
|
||||
}
|
||||
Err(e) => {
|
||||
self.ports.release(port);
|
||||
let mut map = self.containers.write().await;
|
||||
if let Some(entry) = map.get_mut(&user_id) {
|
||||
entry.state = ContainerState::Failed;
|
||||
entry.last_error = Some(e.to_string());
|
||||
}
|
||||
return Err(PoolError::Runtime(e));
|
||||
}
|
||||
}
|
||||
|
||||
let entry_now = self
|
||||
.containers
|
||||
.read()
|
||||
.await
|
||||
.get(&user_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| PoolError::Internal("entry vanished mid-start".into()))?;
|
||||
|
||||
self.probe_until_ready(user_id, entry_now).await
|
||||
}
|
||||
|
||||
/// Wait for the container to answer `/system_stats`, then mark Ready.
|
||||
async fn probe_until_ready(&self, user_id: i64, entry: PoolEntry) -> PoolResult<PoolEntry> {
|
||||
let url = format!("http://127.0.0.1:{}/system_stats", entry.host_port);
|
||||
let deadline = Instant::now() + READINESS_TIMEOUT;
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match self.http.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let mut map = self.containers.write().await;
|
||||
if let Some(e) = map.get_mut(&user_id) {
|
||||
e.state = ContainerState::Ready;
|
||||
e.last_used_at = Instant::now();
|
||||
e.last_error = None;
|
||||
return Ok(e.clone());
|
||||
}
|
||||
return Err(PoolError::Internal("entry removed during probe".into()));
|
||||
}
|
||||
_ => {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout. Mark Failed and let the next call retry.
|
||||
let mut map = self.containers.write().await;
|
||||
if let Some(e) = map.get_mut(&user_id) {
|
||||
e.state = ContainerState::Failed;
|
||||
e.last_error = Some("readiness probe timed out".into());
|
||||
}
|
||||
// Best-effort stop so the slot frees on the next reaper tick.
|
||||
let name = entry.container_name.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = runtime.stop(&name).await;
|
||||
});
|
||||
|
||||
Err(PoolError::StartTimeout(READINESS_TIMEOUT))
|
||||
}
|
||||
|
||||
/// Stop the user's container, release its port.
|
||||
pub async fn stop(&self, user_id: i64) -> PoolResult<()> {
|
||||
let entry = {
|
||||
let mut map = self.containers.write().await;
|
||||
map.remove(&user_id)
|
||||
};
|
||||
let Some(entry) = entry else {
|
||||
return Ok(());
|
||||
};
|
||||
self.ports.release(entry.host_port);
|
||||
self.runtime.stop(&entry.container_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current state for a user (or `Stopped` if no entry).
|
||||
pub async fn state(&self, user_id: i64) -> ContainerState {
|
||||
self.containers
|
||||
.read()
|
||||
.await
|
||||
.get(&user_id)
|
||||
.map(|e| e.state)
|
||||
.unwrap_or(ContainerState::Stopped)
|
||||
}
|
||||
|
||||
/// Snapshot of the user's pool entry, if any.
|
||||
pub async fn entry(&self, user_id: i64) -> Option<PoolEntry> {
|
||||
self.containers.read().await.get(&user_id).cloned()
|
||||
}
|
||||
|
||||
/// Bump `last_used_at`. Cheap; called on every proxied frame.
|
||||
pub async fn touch(&self, user_id: i64) {
|
||||
self.touch_inner(user_id).await
|
||||
}
|
||||
|
||||
async fn touch_inner(&self, user_id: i64) {
|
||||
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
|
||||
e.last_used_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment / decrement the active-WS counter for a user.
|
||||
pub async fn ws_open(&self, user_id: i64) {
|
||||
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
|
||||
e.active_ws = e.active_ws.saturating_add(1);
|
||||
e.last_used_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ws_close(&self, user_id: i64) {
|
||||
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
|
||||
e.active_ws = e.active_ws.saturating_sub(1);
|
||||
e.last_used_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile against the runtime at startup. Adopts existing
|
||||
/// `comfyui-user-*` containers so a Rust restart doesn't orphan them.
|
||||
pub async fn reconcile_on_startup(&self) {
|
||||
let listings = match self.runtime.list_user_containers().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "could not reconcile existing containers; assuming none");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut map = self.containers.write().await;
|
||||
for listing in listings {
|
||||
// Names look like `comfyui-user-7`.
|
||||
let Some(suffix) = listing.name.strip_prefix("comfyui-user-") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(user_id) = suffix.parse::<i64>() else {
|
||||
continue;
|
||||
};
|
||||
self.ports.claim(listing.host_port);
|
||||
map.insert(
|
||||
user_id,
|
||||
PoolEntry {
|
||||
user_id,
|
||||
container_name: listing.name.clone(),
|
||||
host_port: listing.host_port,
|
||||
state: ContainerState::Ready, // assume ready; first request re-probes
|
||||
last_used_at: Instant::now(),
|
||||
active_ws: 0,
|
||||
last_error: None,
|
||||
},
|
||||
);
|
||||
info!(user_id, container = %listing.name, port = listing.host_port, "adopted existing container");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop every container we manage (graceful-shutdown hook).
|
||||
pub async fn shutdown_all(&self) {
|
||||
let names: Vec<String> = {
|
||||
let map = self.containers.read().await;
|
||||
map.values().map(|e| e.container_name.clone()).collect()
|
||||
};
|
||||
for name in names {
|
||||
if let Err(e) = self.runtime.stop(&name).await {
|
||||
warn!(container = %name, error = %e, "shutdown stop failed");
|
||||
}
|
||||
}
|
||||
self.containers.write().await.clear();
|
||||
}
|
||||
|
||||
/// Build the [`ContainerSpec`] for a given user.
|
||||
fn build_spec(&self, user_id: i64, name: &str, host_port: u16) -> ContainerSpec {
|
||||
let user_root = self.config.data_root.join(user_id.to_string());
|
||||
|
||||
// Subdirs we mount in. Created on demand by the host operator (or
|
||||
// the runtime can `mkdir -p` them — Docker `bind` will fail if the
|
||||
// path is missing).
|
||||
let mk = |sub: &str| BindMount {
|
||||
host_path: user_root.join(sub),
|
||||
container_path: format!("/workspace/ComfyUI/{}", sub),
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
// Best-effort mkdir so a fresh user just works. Failures here surface
|
||||
// through the Docker error message later.
|
||||
for sub in ["input", "output", "temp", "user", "custom_nodes_user"] {
|
||||
let _ = std::fs::create_dir_all(user_root.join(sub));
|
||||
}
|
||||
|
||||
let mut env = std::collections::HashMap::new();
|
||||
env.insert(
|
||||
"BOOSAPI_BUILTIN_KEY".into(),
|
||||
self.config.builtin_key.clone(),
|
||||
);
|
||||
env.insert(
|
||||
"BOOSAPI_BASE".into(),
|
||||
container_reachable_boosapi_base(&self.config.boosapi_base),
|
||||
);
|
||||
env.insert("BOOSAPI_KEY".into(), self.config.builtin_key.clone());
|
||||
|
||||
ContainerSpec {
|
||||
image: self.config.image.clone(),
|
||||
name: name.to_string(),
|
||||
host_port,
|
||||
env,
|
||||
binds: vec![
|
||||
mk("input"),
|
||||
mk("output"),
|
||||
mk("temp"),
|
||||
mk("user"),
|
||||
mk("custom_nodes_user"),
|
||||
],
|
||||
tmpfs: vec![TmpfsMount {
|
||||
container_path: "/tmp".into(),
|
||||
size_bytes: 512 * 1024 * 1024,
|
||||
}],
|
||||
limits: ResourceLimits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn container_reachable_boosapi_base(base: &str) -> String {
|
||||
if let Ok(override_base) = std::env::var("BOOSAPI_COMFY_CONTAINER_BASE") {
|
||||
let trimmed = override_base.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.trim_end_matches('/').to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = base.trim().trim_end_matches('/');
|
||||
for local_host in ["127.0.0.1", "localhost"] {
|
||||
let needle = format!("//{}:", local_host);
|
||||
if let Some((prefix, rest)) = trimmed.split_once(&needle) {
|
||||
return format!("{}//host.docker.internal:{}", prefix, rest);
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Spawn the idle-reaper task. Lives for the life of the process.
|
||||
pub fn spawn_reaper(pool: Arc<ComfyUIPool>) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(REAPER_TICK);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
reap_once(&pool).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn reap_once(pool: &Arc<ComfyUIPool>) {
|
||||
let now = Instant::now();
|
||||
let idle = pool.config.idle_timeout;
|
||||
let candidates: Vec<i64> = {
|
||||
let map = pool.containers.read().await;
|
||||
map.values()
|
||||
.filter(|e| {
|
||||
e.active_ws == 0
|
||||
&& now.saturating_duration_since(e.last_used_at) > idle
|
||||
&& matches!(e.state, ContainerState::Ready | ContainerState::Failed)
|
||||
})
|
||||
.map(|e| e.user_id)
|
||||
.collect()
|
||||
};
|
||||
|
||||
for user_id in candidates {
|
||||
info!(user_id, "reaping idle comfyui container");
|
||||
if let Err(e) = pool.stop(user_id).await {
|
||||
error!(user_id, error = %e, "reap failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::services::container_runtime::{ContainerHandle, ContainerListing, RuntimeResult};
|
||||
|
||||
/// In-memory runtime that succeeds and pretends `/system_stats` is up.
|
||||
/// Real readiness probe is tested separately via the live Docker setup.
|
||||
struct MockRuntime {
|
||||
starts: AtomicUsize,
|
||||
stops: AtomicUsize,
|
||||
}
|
||||
|
||||
impl MockRuntime {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
starts: AtomicUsize::new(0),
|
||||
stops: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for MockRuntime {
|
||||
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle> {
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(ContainerHandle {
|
||||
id: format!("mock-{}", spec.name),
|
||||
name: spec.name,
|
||||
host_port: spec.host_port,
|
||||
})
|
||||
}
|
||||
async fn stop(&self, _name: &str) -> RuntimeResult<()> {
|
||||
self.stops.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
async fn is_alive(&self, _name: &str) -> RuntimeResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg() -> PoolConfig {
|
||||
PoolConfig {
|
||||
image: "test".into(),
|
||||
max_concurrent: 2,
|
||||
idle_timeout: Duration::from_secs(60),
|
||||
data_root: std::env::temp_dir().join("boosapi-pool-test"),
|
||||
boosapi_base: "http://127.0.0.1:9999".into(),
|
||||
builtin_key: "sk-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_cap_enforced() {
|
||||
let runtime = Arc::new(MockRuntime::new());
|
||||
let pool = ComfyUIPool::new(runtime.clone(), cfg());
|
||||
|
||||
// Inject 2 containers manually so we don't have to satisfy the
|
||||
// /system_stats probe (this test isolates the cap check).
|
||||
{
|
||||
let mut map = pool.containers.write().await;
|
||||
map.insert(
|
||||
1,
|
||||
PoolEntry {
|
||||
user_id: 1,
|
||||
container_name: "comfyui-user-1".into(),
|
||||
host_port: 8200,
|
||||
state: ContainerState::Ready,
|
||||
last_used_at: Instant::now(),
|
||||
active_ws: 0,
|
||||
last_error: None,
|
||||
},
|
||||
);
|
||||
map.insert(
|
||||
2,
|
||||
PoolEntry {
|
||||
user_id: 2,
|
||||
container_name: "comfyui-user-2".into(),
|
||||
host_port: 8201,
|
||||
state: ContainerState::Ready,
|
||||
last_used_at: Instant::now(),
|
||||
active_ws: 0,
|
||||
last_error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let err = pool.get_or_start(3).await.unwrap_err();
|
||||
assert!(matches!(err, PoolError::AtCapacity(2)), "got: {:?}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_releases_port() {
|
||||
let runtime = Arc::new(MockRuntime::new());
|
||||
let pool = ComfyUIPool::new(runtime.clone(), cfg());
|
||||
{
|
||||
let mut map = pool.containers.write().await;
|
||||
map.insert(
|
||||
7,
|
||||
PoolEntry {
|
||||
user_id: 7,
|
||||
container_name: "comfyui-user-7".into(),
|
||||
host_port: 8205,
|
||||
state: ContainerState::Ready,
|
||||
last_used_at: Instant::now(),
|
||||
active_ws: 0,
|
||||
last_error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
pool.ports.claim(8205);
|
||||
pool.stop(7).await.unwrap();
|
||||
assert_eq!(runtime.stops.load(Ordering::SeqCst), 1);
|
||||
// Port is back in the free pool.
|
||||
let next = pool.ports.allocate().unwrap();
|
||||
assert_eq!(next, PORT_RANGE_START);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_allocator_assigns_in_order_and_releases() {
|
||||
let p = PortAllocator::new();
|
||||
let a = p.allocate().unwrap();
|
||||
let b = p.allocate().unwrap();
|
||||
assert_eq!(a, PORT_RANGE_START);
|
||||
assert_eq!(b, PORT_RANGE_START + 1);
|
||||
p.release(a);
|
||||
let c = p.allocate().unwrap();
|
||||
assert_eq!(c, PORT_RANGE_START);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Backend-agnostic container lifecycle abstraction.
|
||||
//!
|
||||
//! v1 ships [`crate::services::docker_runtime::DockerRuntime`] against the
|
||||
//! local Docker daemon. v2 will add a K3S implementation behind the same
|
||||
//! trait so the pool, proxy, and route handlers do NOT have to change.
|
||||
//!
|
||||
//! Callers (the pool, in particular) MUST go through this trait — never
|
||||
//! import `bollard`, the `docker` CLI, or `kube` types directly.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resource and security caps for a container.
|
||||
///
|
||||
/// Mirrors §design Decision 5 verbatim: 2 CPU, 2 GB RAM, 2 GB swap,
|
||||
/// `pids_limit=512`, read-only rootfs, drop all caps, `no-new-privileges`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceLimits {
|
||||
pub cpus: f64,
|
||||
pub memory_bytes: u64,
|
||||
pub memory_swap_bytes: u64,
|
||||
pub pids_limit: u32,
|
||||
pub read_only_rootfs: bool,
|
||||
pub cap_drop_all: bool,
|
||||
pub no_new_privileges: bool,
|
||||
}
|
||||
|
||||
impl Default for ResourceLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpus: 2.0,
|
||||
memory_bytes: 2 * 1024 * 1024 * 1024,
|
||||
memory_swap_bytes: 2 * 1024 * 1024 * 1024,
|
||||
pids_limit: 512,
|
||||
read_only_rootfs: true,
|
||||
cap_drop_all: true,
|
||||
no_new_privileges: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One bind mount: `host_path -> container_path`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BindMount {
|
||||
pub host_path: PathBuf,
|
||||
pub container_path: String,
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Tmpfs mount (used to satisfy a few writable paths under read-only rootfs).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TmpfsMount {
|
||||
pub container_path: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// A complete container start request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContainerSpec {
|
||||
pub image: String,
|
||||
pub name: String,
|
||||
/// Host port to bind to. The container's internal port is fixed at 8188.
|
||||
pub host_port: u16,
|
||||
pub env: HashMap<String, String>,
|
||||
pub binds: Vec<BindMount>,
|
||||
pub tmpfs: Vec<TmpfsMount>,
|
||||
pub limits: ResourceLimits,
|
||||
}
|
||||
|
||||
/// Result of a successful start: identifies the container and tells the
|
||||
/// caller which loopback port to forward traffic to.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContainerHandle {
|
||||
/// Backend-specific id (Docker container id, k8s pod name, ...).
|
||||
pub id: String,
|
||||
/// Container name we asked for, e.g. `comfyui-user-7`.
|
||||
pub name: String,
|
||||
/// Loopback host port the runtime mapped for us.
|
||||
pub host_port: u16,
|
||||
}
|
||||
|
||||
/// Listing entry returned by [`ContainerRuntime::list_user_containers`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContainerListing {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host_port: u16,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum RuntimeError {
|
||||
#[error("backend error: {0}")]
|
||||
Backend(String),
|
||||
#[error("container not found: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
pub type RuntimeResult<T> = Result<T, RuntimeError>;
|
||||
|
||||
/// Backend-agnostic container lifecycle.
|
||||
#[async_trait]
|
||||
pub trait ContainerRuntime: Send + Sync + 'static {
|
||||
/// Start a container and return a handle once it is running.
|
||||
///
|
||||
/// The runtime is NOT required to wait for the application inside the
|
||||
/// container to be ready — that probe lives in the pool.
|
||||
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle>;
|
||||
|
||||
/// Stop and remove a container by name. Idempotent: removing a
|
||||
/// nonexistent container returns `Ok`.
|
||||
async fn stop(&self, name: &str) -> RuntimeResult<()>;
|
||||
|
||||
/// Check whether a container is running.
|
||||
async fn is_alive(&self, name: &str) -> RuntimeResult<bool>;
|
||||
|
||||
/// List every running container whose name starts with `comfyui-user-`.
|
||||
/// Used at API startup to reconstruct the pool's in-memory map.
|
||||
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>>;
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
//! Docker implementation of [`ContainerRuntime`].
|
||||
//!
|
||||
//! We shell out to the `docker` CLI rather than pull in a Rust Docker SDK,
|
||||
//! to keep the dependency footprint small. The CLI is universally
|
||||
//! available on the hosts we ship to, and the surface area we need
|
||||
//! (`run / stop / rm / inspect / ps`) is tiny.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::container_runtime::{
|
||||
ContainerHandle, ContainerListing, ContainerRuntime, ContainerSpec, RuntimeError, RuntimeResult,
|
||||
};
|
||||
|
||||
/// Internal port the ComfyUI process listens on inside the container.
|
||||
/// Hard-coded to match the image's ENTRYPOINT.
|
||||
const CONTAINER_INTERNAL_PORT: u16 = 8188;
|
||||
|
||||
/// Label we stamp onto every container we manage. Used by
|
||||
/// `list_user_containers` and as a sanity-check on `stop`.
|
||||
pub const MANAGED_LABEL: &str = "boosapi.comfy.tenant=1";
|
||||
|
||||
/// Default Docker runtime. Stateless — every method shells out fresh.
|
||||
pub struct DockerRuntime {
|
||||
docker_bin: String,
|
||||
}
|
||||
|
||||
impl DockerRuntime {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
docker_bin: std::env::var("BOOSAPI_DOCKER_BIN").unwrap_or_else(|_| "docker".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd(&self) -> Command {
|
||||
let mut c = Command::new(&self.docker_bin);
|
||||
c.stdin(Stdio::null());
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DockerRuntime {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for DockerRuntime {
|
||||
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle> {
|
||||
// Best-effort: kill any stale container with the same name.
|
||||
// `docker rm -f <name>` is a no-op if the name is free.
|
||||
let _ = self
|
||||
.cmd()
|
||||
.args(["rm", "-f", &spec.name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let mut cmd = self.cmd();
|
||||
cmd.arg("run")
|
||||
.arg("-d")
|
||||
.arg("--rm")
|
||||
.arg("--name")
|
||||
.arg(&spec.name)
|
||||
.arg("--label")
|
||||
.arg(MANAGED_LABEL)
|
||||
.arg("-p")
|
||||
.arg(format!(
|
||||
"127.0.0.1:{}:{}",
|
||||
spec.host_port, CONTAINER_INTERNAL_PORT
|
||||
))
|
||||
.arg("--add-host")
|
||||
.arg("host.docker.internal:host-gateway")
|
||||
// Resource limits (§design Decision 5)
|
||||
.arg(format!("--cpus={}", spec.limits.cpus))
|
||||
.arg(format!("--memory={}", spec.limits.memory_bytes))
|
||||
.arg(format!("--memory-swap={}", spec.limits.memory_swap_bytes))
|
||||
.arg(format!("--pids-limit={}", spec.limits.pids_limit));
|
||||
|
||||
if spec.limits.read_only_rootfs {
|
||||
cmd.arg("--read-only");
|
||||
}
|
||||
if spec.limits.cap_drop_all {
|
||||
cmd.args(["--cap-drop", "ALL"]);
|
||||
}
|
||||
if spec.limits.no_new_privileges {
|
||||
cmd.args(["--security-opt", "no-new-privileges"]);
|
||||
}
|
||||
|
||||
for (k, v) in &spec.env {
|
||||
cmd.arg("-e").arg(format!("{}={}", k, v));
|
||||
}
|
||||
|
||||
for bind in &spec.binds {
|
||||
// Using `--mount` over `-v` so the syntax is unambiguous and
|
||||
// we get a clear error if the host path is wrong.
|
||||
let mut s = format!(
|
||||
"type=bind,source={},target={}",
|
||||
bind.host_path.display(),
|
||||
bind.container_path
|
||||
);
|
||||
if bind.read_only {
|
||||
s.push_str(",readonly");
|
||||
}
|
||||
cmd.arg("--mount").arg(s);
|
||||
}
|
||||
|
||||
for tmp in &spec.tmpfs {
|
||||
cmd.arg("--tmpfs")
|
||||
.arg(format!("{}:size={}", tmp.container_path, tmp.size_bytes));
|
||||
}
|
||||
|
||||
cmd.arg(&spec.image);
|
||||
|
||||
debug!(?cmd, "starting comfyui container");
|
||||
|
||||
let output = cmd.output().await.map_err(io_to_runtime)?;
|
||||
if !output.status.success() {
|
||||
return Err(RuntimeError::Backend(format!(
|
||||
"docker run failed (exit {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err(RuntimeError::Backend(
|
||||
"docker run returned empty container id".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ContainerHandle {
|
||||
id,
|
||||
name: spec.name,
|
||||
host_port: spec.host_port,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stop(&self, name: &str) -> RuntimeResult<()> {
|
||||
// `docker stop` flushes nicely; `--rm` from start removes it after.
|
||||
// If it's already gone, treat as success.
|
||||
let output = self
|
||||
.cmd()
|
||||
.args(["stop", "-t", "10", name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(io_to_runtime)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("No such container") {
|
||||
return Ok(());
|
||||
}
|
||||
warn!(name, stderr = stderr.trim(), "docker stop failed");
|
||||
// Fall through to rm -f as a last resort.
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.cmd()
|
||||
.args(["rm", "-f", name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_alive(&self, name: &str) -> RuntimeResult<bool> {
|
||||
let output = self
|
||||
.cmd()
|
||||
.args(["inspect", "-f", "{{.State.Running}}", name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(io_to_runtime)?;
|
||||
|
||||
if !output.status.success() {
|
||||
// `docker inspect` returns 1 if the container is unknown.
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim() == "true")
|
||||
}
|
||||
|
||||
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>> {
|
||||
// `--format {{json .}}` gives one JSON object per line. We pull the
|
||||
// fields we need without depending on a JSON-deserialize crate
|
||||
// schema for Docker's somewhat-fluid output.
|
||||
let output = self
|
||||
.cmd()
|
||||
.args([
|
||||
"ps",
|
||||
"--filter",
|
||||
"name=comfyui-user-",
|
||||
"--filter",
|
||||
&format!("label={}", MANAGED_LABEL),
|
||||
"--format",
|
||||
"{{.Names}}\t{{.ID}}\t{{.Ports}}",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(io_to_runtime)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(RuntimeError::Backend(format!(
|
||||
"docker ps failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut out = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let mut parts = line.splitn(3, '\t');
|
||||
let name = parts.next().unwrap_or("").trim().to_string();
|
||||
let id = parts.next().unwrap_or("").trim().to_string();
|
||||
let ports = parts.next().unwrap_or("").trim();
|
||||
if name.is_empty() || id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let host_port = match parse_host_port(ports) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
warn!(
|
||||
name,
|
||||
ports, "could not parse host port from docker ps; skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push(ContainerListing {
|
||||
id,
|
||||
name,
|
||||
host_port,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the host-side port out of a `docker ps` Ports column.
|
||||
///
|
||||
/// Examples it must handle:
|
||||
/// "127.0.0.1:8200->8188/tcp"
|
||||
/// "127.0.0.1:8200->8188/tcp, 127.0.0.1:8200->8188/udp"
|
||||
fn parse_host_port(ports: &str) -> Option<u16> {
|
||||
for spec in ports.split(',') {
|
||||
let spec = spec.trim();
|
||||
// Accept `<host>:<port>->...`
|
||||
let arrow = spec.find("->")?;
|
||||
let left = &spec[..arrow];
|
||||
let port_str = left.rsplit(':').next()?;
|
||||
if let Ok(p) = port_str.parse::<u16>() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn io_to_runtime(e: std::io::Error) -> RuntimeError {
|
||||
RuntimeError::Backend(format!("docker cli io error: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_port_mapping() {
|
||||
assert_eq!(parse_host_port("127.0.0.1:8200->8188/tcp"), Some(8200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_port_mapping_with_extra_entries() {
|
||||
assert_eq!(
|
||||
parse_host_port("127.0.0.1:8201->8188/tcp, 127.0.0.1:8201->8188/udp"),
|
||||
Some(8201)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_garbage() {
|
||||
assert_eq!(parse_host_port(""), None);
|
||||
assert_eq!(parse_host_port("nope"), None);
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -1,13 +1,11 @@
|
||||
pub mod announcement;
|
||||
pub mod billing;
|
||||
pub mod builtin_keys;
|
||||
pub mod comfyui_pool;
|
||||
pub mod comfyui_process;
|
||||
pub mod container_runtime;
|
||||
pub mod cron;
|
||||
pub mod docker_runtime;
|
||||
pub mod notification;
|
||||
pub mod oauth;
|
||||
pub mod registration_limiter;
|
||||
pub mod route_rebuild;
|
||||
pub mod shared;
|
||||
pub mod update_center;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
net::IpAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
const IP_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||
const EMAIL_WINDOW: Duration = Duration::from_secs(60 * 60);
|
||||
const GLOBAL_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
const SUCCESS_LOCK_WINDOW: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
const MAX_ATTEMPTS_PER_IP: usize = 8;
|
||||
const MAX_ATTEMPTS_PER_EMAIL: usize = 3;
|
||||
const MAX_GLOBAL_ATTEMPTS: usize = 120;
|
||||
const MAX_SUCCESS_PER_IP: usize = 2;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RegistrationLimitExceeded {
|
||||
pub retry_after_secs: u64,
|
||||
pub message: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LimiterState {
|
||||
attempts_by_ip: HashMap<String, VecDeque<Instant>>,
|
||||
attempts_by_email: HashMap<String, VecDeque<Instant>>,
|
||||
successful_by_ip: HashMap<String, VecDeque<Instant>>,
|
||||
global_attempts: VecDeque<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RegistrationLimiter {
|
||||
inner: Mutex<LimiterState>,
|
||||
}
|
||||
|
||||
impl RegistrationLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn check_and_record_attempt(
|
||||
&self,
|
||||
ip: Option<IpAddr>,
|
||||
email: &str,
|
||||
) -> Result<(), RegistrationLimitExceeded> {
|
||||
let now = Instant::now();
|
||||
let ip_key = ip
|
||||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let email_key = normalize_email(email);
|
||||
|
||||
let mut guard = self.inner.lock();
|
||||
prune_deque(&mut guard.global_attempts, now, GLOBAL_WINDOW);
|
||||
if guard.global_attempts.len() >= MAX_GLOBAL_ATTEMPTS {
|
||||
return Err(limit_error(
|
||||
&guard.global_attempts,
|
||||
now,
|
||||
GLOBAL_WINDOW,
|
||||
"注册请求过于频繁,请稍后再试。",
|
||||
));
|
||||
}
|
||||
|
||||
{
|
||||
let ip_attempts = guard.attempts_by_ip.entry(ip_key.clone()).or_default();
|
||||
prune_deque(ip_attempts, now, IP_WINDOW);
|
||||
if ip_attempts.len() >= MAX_ATTEMPTS_PER_IP {
|
||||
return Err(limit_error(
|
||||
ip_attempts,
|
||||
now,
|
||||
IP_WINDOW,
|
||||
"当前网络注册过于频繁,请稍后再试。",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let ip_successes = guard.successful_by_ip.entry(ip_key.clone()).or_default();
|
||||
prune_deque(ip_successes, now, SUCCESS_LOCK_WINDOW);
|
||||
if ip_successes.len() >= MAX_SUCCESS_PER_IP {
|
||||
return Err(limit_error(
|
||||
ip_successes,
|
||||
now,
|
||||
SUCCESS_LOCK_WINDOW,
|
||||
"当前网络短时间内已注册多个账号,请稍后再试。",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let email_attempts = guard
|
||||
.attempts_by_email
|
||||
.entry(email_key.clone())
|
||||
.or_default();
|
||||
prune_deque(email_attempts, now, EMAIL_WINDOW);
|
||||
if email_attempts.len() >= MAX_ATTEMPTS_PER_EMAIL {
|
||||
return Err(limit_error(
|
||||
email_attempts,
|
||||
now,
|
||||
EMAIL_WINDOW,
|
||||
"该邮箱注册尝试过于频繁,请稍后再试。",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
guard.global_attempts.push_back(now);
|
||||
guard
|
||||
.attempts_by_ip
|
||||
.entry(ip_key)
|
||||
.or_default()
|
||||
.push_back(now);
|
||||
guard
|
||||
.attempts_by_email
|
||||
.entry(email_key)
|
||||
.or_default()
|
||||
.push_back(now);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn record_success(&self, ip: Option<IpAddr>) {
|
||||
let now = Instant::now();
|
||||
let ip_key = ip
|
||||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let mut guard = self.inner.lock();
|
||||
let entries = guard.successful_by_ip.entry(ip_key).or_default();
|
||||
prune_deque(entries, now, SUCCESS_LOCK_WINDOW);
|
||||
entries.push_back(now);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_email(email: &str) -> String {
|
||||
email.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn prune_deque(entries: &mut VecDeque<Instant>, now: Instant, window: Duration) {
|
||||
while entries
|
||||
.front()
|
||||
.is_some_and(|timestamp| now.duration_since(*timestamp) >= window)
|
||||
{
|
||||
entries.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_error(
|
||||
entries: &VecDeque<Instant>,
|
||||
now: Instant,
|
||||
window: Duration,
|
||||
message: &'static str,
|
||||
) -> RegistrationLimitExceeded {
|
||||
let retry_after_secs = entries
|
||||
.front()
|
||||
.map(|first| {
|
||||
window
|
||||
.saturating_sub(now.duration_since(*first))
|
||||
.as_secs()
|
||||
.max(1)
|
||||
})
|
||||
.unwrap_or_else(|| window.as_secs().max(1));
|
||||
RegistrationLimitExceeded {
|
||||
retry_after_secs,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn ip() -> Option<IpAddr> {
|
||||
Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_repeated_attempts_from_same_ip() {
|
||||
let limiter = RegistrationLimiter::new();
|
||||
for index in 0..MAX_ATTEMPTS_PER_IP {
|
||||
limiter
|
||||
.check_and_record_attempt(ip(), &format!("u{index}@example.com"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let err = limiter
|
||||
.check_and_record_attempt(ip(), "blocked@example.com")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.message, "当前网络注册过于频繁,请稍后再试。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_repeated_attempts_for_same_email_case_insensitive() {
|
||||
let limiter = RegistrationLimiter::new();
|
||||
for _ in 0..MAX_ATTEMPTS_PER_EMAIL {
|
||||
limiter
|
||||
.check_and_record_attempt(None, "User@Example.com")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let err = limiter
|
||||
.check_and_record_attempt(None, "user@example.com")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.message, "该邮箱注册尝试过于频繁,请稍后再试。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_successful_registrations_from_same_ip() {
|
||||
let limiter = RegistrationLimiter::new();
|
||||
for index in 0..MAX_SUCCESS_PER_IP {
|
||||
limiter
|
||||
.check_and_record_attempt(ip(), &format!("u{index}@example.com"))
|
||||
.unwrap();
|
||||
limiter.record_success(ip());
|
||||
}
|
||||
|
||||
let err = limiter
|
||||
.check_and_record_attempt(ip(), "extra@example.com")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.message, "当前网络短时间内已注册多个账号,请稍后再试。");
|
||||
}
|
||||
}
|
||||
+30
-14
@@ -22,6 +22,7 @@ pub struct WorkspaceAssetInput {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredWorkspaceAsset {
|
||||
pub public_id: String,
|
||||
pub file_name: Option<String>,
|
||||
pub file_path: Option<String>,
|
||||
pub remote_url: Option<String>,
|
||||
pub status: String,
|
||||
@@ -56,7 +57,12 @@ fn safe_segment(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn extension_for_mime(mime: &str) -> &'static str {
|
||||
let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||
let mime = mime
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match mime.as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
@@ -156,6 +162,7 @@ async fn insert_asset(
|
||||
|
||||
Ok(StoredWorkspaceAsset {
|
||||
public_id: public_id.to_string(),
|
||||
file_name,
|
||||
file_path: file_path_string,
|
||||
remote_url: input.remote_url.clone(),
|
||||
status: status.to_string(),
|
||||
@@ -193,12 +200,13 @@ pub async fn record_remote_asset(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("download workspace asset: {err}")))?;
|
||||
let ext = extension_from_url(url).unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
|
||||
let path = write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
|
||||
let bytes = response.bytes().await.map_err(|err| {
|
||||
AppError::UpstreamError(format!("download workspace asset: {err}"))
|
||||
})?;
|
||||
let ext = extension_from_url(url)
|
||||
.unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
|
||||
let path =
|
||||
write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
|
||||
insert_asset(
|
||||
state,
|
||||
&input,
|
||||
@@ -283,17 +291,22 @@ pub async fn record_image_assets_from_response(
|
||||
prompt: Option<String>,
|
||||
model: Option<String>,
|
||||
response: &Value,
|
||||
) {
|
||||
) -> Vec<StoredWorkspaceAsset> {
|
||||
let Some(items) = response.get("data").and_then(Value::as_array) else {
|
||||
return;
|
||||
return Vec::new();
|
||||
};
|
||||
let mut stored_assets = Vec::new();
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let title = prompt
|
||||
.as_deref()
|
||||
.map(|value| value.chars().take(36).collect::<String>())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| Some(format!("图片产出 #{}", index + 1)));
|
||||
if let Some(url) = item.get("url").and_then(Value::as_str).filter(|value| !value.trim().is_empty()) {
|
||||
if let Some(url) = item
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let input = WorkspaceAssetInput {
|
||||
user_id,
|
||||
source: source.to_string(),
|
||||
@@ -304,8 +317,9 @@ pub async fn record_image_assets_from_response(
|
||||
remote_url: Some(url.trim().to_string()),
|
||||
metadata: json!({ "responseItem": item }),
|
||||
};
|
||||
if let Err(err) = record_remote_asset(state, input).await {
|
||||
tracing::warn!(%err, "failed to archive remote image asset");
|
||||
match record_remote_asset(state, input).await {
|
||||
Ok(asset) => stored_assets.push(asset),
|
||||
Err(err) => tracing::warn!(%err, "failed to archive remote image asset"),
|
||||
}
|
||||
} else if let Some(b64) = item
|
||||
.get("b64_json")
|
||||
@@ -322,9 +336,11 @@ pub async fn record_image_assets_from_response(
|
||||
remote_url: None,
|
||||
metadata: json!({ "responseItem": item }),
|
||||
};
|
||||
if let Err(err) = record_base64_asset(state, input, b64).await {
|
||||
tracing::warn!(%err, "failed to archive base64 image asset");
|
||||
match record_base64_asset(state, input, b64).await {
|
||||
Ok(asset) => stored_assets.push(asset),
|
||||
Err(err) => tracing::warn!(%err, "failed to archive base64 image asset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
stored_assets
|
||||
}
|
||||
|
||||
@@ -294,7 +294,13 @@ fn parse_message_content(
|
||||
return Ok((Vec::new(), None, None));
|
||||
};
|
||||
if let Some(text) = content.as_str() {
|
||||
return Ok((vec![ContentPart::Text { text: text.to_string() }], None, None));
|
||||
return Ok((
|
||||
vec![ContentPart::Text {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
None,
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
@@ -307,7 +313,9 @@ fn parse_message_content(
|
||||
match item["type"].as_str() {
|
||||
Some("text") => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
parts.push(ContentPart::Text { text: text.to_string() });
|
||||
parts.push(ContentPart::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("image") => {
|
||||
@@ -317,9 +325,7 @@ fn parse_message_content(
|
||||
detail: None,
|
||||
});
|
||||
} else if item["source"]["type"].as_str() == Some("base64") {
|
||||
let media_type = item["source"]["media_type"]
|
||||
.as_str()
|
||||
.unwrap_or("image/png");
|
||||
let media_type = item["source"]["media_type"].as_str().unwrap_or("image/png");
|
||||
if let Some(data) = item["source"]["data"].as_str() {
|
||||
parts.push(ContentPart::Image {
|
||||
url: format!("data:{media_type};base64,{data}"),
|
||||
@@ -350,14 +356,20 @@ fn parse_message_content(
|
||||
}
|
||||
Ok((
|
||||
parts,
|
||||
if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_anthropic_content(value: &Value) -> Result<Vec<ContentPart>, String> {
|
||||
if let Some(text) = value.as_str() {
|
||||
return Ok(vec![ContentPart::Text { text: text.to_string() }]);
|
||||
return Ok(vec![ContentPart::Text {
|
||||
text: text.to_string(),
|
||||
}]);
|
||||
}
|
||||
let Some(items) = value.as_array() else {
|
||||
return Ok(Vec::new());
|
||||
@@ -366,7 +378,9 @@ fn parse_anthropic_content(value: &Value) -> Result<Vec<ContentPart>, String> {
|
||||
for item in items {
|
||||
if item["type"].as_str() == Some("text") {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
parts.push(ContentPart::Text { text: text.to_string() });
|
||||
parts.push(ContentPart::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,7 +401,9 @@ fn parse_anthropic_tool_choice(value: Option<&Value>) -> Option<ToolChoice> {
|
||||
match value["type"].as_str()? {
|
||||
"auto" => Some(ToolChoice::Auto),
|
||||
"any" => Some(ToolChoice::Required),
|
||||
"tool" => value["name"].as_str().map(|name| ToolChoice::Specific(name.to_string())),
|
||||
"tool" => value["name"]
|
||||
.as_str()
|
||||
.map(|name| ToolChoice::Specific(name.to_string())),
|
||||
"none" => Some(ToolChoice::None),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod outbound;
|
||||
pub mod stream;
|
||||
|
||||
pub use compatibility::AnthropicMessagesCompatibility;
|
||||
pub use inbound::{format_messages_request, format_messages_request_with_compat, parse_messages_request};
|
||||
pub use inbound::{
|
||||
format_messages_request, format_messages_request_with_compat, parse_messages_request,
|
||||
};
|
||||
pub use outbound::parse_messages_response;
|
||||
pub use stream::parse_stream_event;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
@@ -12,14 +11,6 @@ function collectText(node: ReactTestInstance): string {
|
||||
}
|
||||
|
||||
describe('Login surface', () => {
|
||||
it('uses the site root as the documentation URL', () => {
|
||||
expect(SITE_DOCS_URL).toBe('https://boosapi.cita777.me');
|
||||
});
|
||||
|
||||
it('uses the author github profile for the login github shortcut', () => {
|
||||
expect(SITE_GITHUB_URL).toBe('https://github.com/cita-777');
|
||||
});
|
||||
|
||||
it('renders a poster-style hero with a floating admin login panel', () => {
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
@@ -52,15 +43,9 @@ describe('Login surface', () => {
|
||||
expect(pageText).toContain('统一代理网关');
|
||||
expect(pageText).toContain('智能路由引擎');
|
||||
expect(pageText).toContain('自动模型发现');
|
||||
expect(pageText).toContain('部署文档');
|
||||
expect(lightBrandPanel).toBeTruthy();
|
||||
expect(authStage).toBeTruthy();
|
||||
expect(brandMarkCanvas).toBeTruthy();
|
||||
|
||||
const docsLink = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& node.props.href === SITE_DOCS_URL
|
||||
));
|
||||
const tokenInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '管理员令牌'
|
||||
@@ -70,14 +55,6 @@ describe('Login surface', () => {
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('login-mode-tab')
|
||||
));
|
||||
const githubLink = root.root.find((node) => (
|
||||
node.type === 'a'
|
||||
&& node.props.href === SITE_GITHUB_URL
|
||||
));
|
||||
|
||||
expect(docsLink.props.target).toBe('_blank');
|
||||
expect(githubLink.props['aria-label']).toBe('GitHub');
|
||||
expect(githubLink.props.target).toBe('_blank');
|
||||
expect(tokenInput.props.type).toBe('password');
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
|
||||
} finally {
|
||||
|
||||
+17
-15
@@ -7,13 +7,11 @@ import TooltipLayer from './components/TooltipLayer.js';
|
||||
import { api, userApi } from './api.js';
|
||||
import { clearSession, getSessionToken } from './authSession.js';
|
||||
import {
|
||||
FIRST_USE_DOC_REMINDER_KEY,
|
||||
LEGACY_THEME_STORAGE_KEY,
|
||||
THEME_MODE_STORAGE_KEY,
|
||||
USER_PROFILE_STORAGE_KEY,
|
||||
} from './appLocalState.js';
|
||||
import { I18nProvider, useI18n } from './i18n.js';
|
||||
import { SITE_DOCS_URL } from './docsLink.js';
|
||||
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from './components/useIsMobile.js';
|
||||
import { MobileDrawer } from './components/MobileDrawer.js';
|
||||
@@ -36,10 +34,11 @@ const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
|
||||
const Models = lazy(() => import('./pages/Models.js'));
|
||||
const About = lazy(() => import('./pages/About.js'));
|
||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
|
||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
||||
const Workflows = lazy(() => import('./pages/Workflows.js'));
|
||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||
@@ -348,7 +347,8 @@ export const sidebarGroups = [
|
||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
||||
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
|
||||
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
|
||||
],
|
||||
@@ -369,6 +369,7 @@ export const userSidebarGroups = [
|
||||
label: '概览',
|
||||
items: [
|
||||
{ to: '/user/dashboard', label: '个人中心', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> },
|
||||
{ to: '/user/models', label: '模型广场', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 6h16M4 10h10M4 14h16M4 18h8" /></svg> },
|
||||
{ to: '/user/usage', label: '使用记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
||||
{ to: '/user/announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/user/about', label: '关于', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M13 16h-1v-4h-1m1-4h.01M12 3a9 9 0 110 18 9 9 0 010-18z" /></svg> },
|
||||
@@ -378,7 +379,8 @@ export const userSidebarGroups = [
|
||||
label: '创作应用',
|
||||
items: [
|
||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/user/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
|
||||
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
|
||||
],
|
||||
@@ -388,7 +390,7 @@ const topNavItems = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
{ label: '生图助手', to: '/picture-agent' },
|
||||
{ label: '视频工作台', to: '/video-agent' },
|
||||
{ label: '工作流', to: '/workflows' },
|
||||
{ label: '视频助手', to: '/comfyui-agent' },
|
||||
{ label: '工作空间', to: '/workspace' },
|
||||
{ label: '关于', to: '/about' },
|
||||
@@ -535,12 +537,6 @@ function AppShell() {
|
||||
const timer = setInterval(check, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [authed, toast]);
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
|
||||
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
|
||||
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
|
||||
}, [authed, t, toast]);
|
||||
useEffect(() => {
|
||||
if (!userAuthed || !location.pathname.startsWith('/user/') || location.pathname === '/user/login') {
|
||||
setAnnouncementPopupRows([]);
|
||||
@@ -677,8 +673,9 @@ function AppShell() {
|
||||
const currentSidebarGroups = isUserPage ? userSidebarGroups : sidebarGroups;
|
||||
const currentTopNavItems = isUserPage ? [
|
||||
{ label: '个人中心', to: '/user/dashboard' },
|
||||
{ label: '模型广场', to: '/user/models' },
|
||||
{ label: '用户公告', to: '/user/announcements' },
|
||||
{ label: '视频工作台', to: '/user/video-agent' },
|
||||
{ label: '工作流', to: '/user/workflows' },
|
||||
{ label: '视频助手', to: '/user/comfyui-agent' },
|
||||
{ label: '工作空间', to: '/user/workspace' },
|
||||
{ label: '关于', to: '/user/about' },
|
||||
@@ -917,7 +914,9 @@ function AppShell() {
|
||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/video-agent" element={<ComfyUI />} />
|
||||
<Route path="/digital-human" element={<DigitalHuman />} />
|
||||
<Route path="/workflows" element={<Workflows />} />
|
||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
<Route path="/workspace" element={<Workspace />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
@@ -928,12 +927,15 @@ function AppShell() {
|
||||
path="/user/dashboard"
|
||||
element={<UserDashboard onLogout={() => setUserAuthed(false)} />}
|
||||
/>
|
||||
<Route path="/user/models" element={<Models />} />
|
||||
<Route path="/user/dashboard/keys" element={<UserKeys />} />
|
||||
<Route path="/user/dashboard/profile" element={<UserProfile />} />
|
||||
<Route path="/user/usage" element={<UsageRecords />} />
|
||||
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
||||
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/user/video-agent" element={<ComfyUI />} />
|
||||
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
||||
<Route path="/user/workflows" element={<Workflows />} />
|
||||
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
||||
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
<Route path="/user/workspace" element={<Workspace />} />
|
||||
<Route path="/user/about" element={<About />} />
|
||||
|
||||
+115
-2
@@ -55,6 +55,17 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
|
||||
return message;
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
|
||||
if (res.status === 204 || res.status === 205) {
|
||||
return { success: true } as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text.trim()) {
|
||||
return { success: true } as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
function parseContentDispositionFilename(
|
||||
headerValue: string | null,
|
||||
): string | null {
|
||||
@@ -245,7 +256,7 @@ async function userRequest<T = any>(
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
|
||||
async function request<T = any>(
|
||||
@@ -256,7 +267,7 @@ async function request<T = any>(
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
|
||||
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
|
||||
@@ -1081,6 +1092,56 @@ export type WorkspaceAssetQuery = {
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export type WorkflowSummary = {
|
||||
id: number;
|
||||
publicId: string;
|
||||
userId?: number | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
graph: any;
|
||||
isTemplate?: boolean;
|
||||
visibility: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowNodeRun = {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
status: string;
|
||||
inputs: any;
|
||||
outputs?: any;
|
||||
errorMessage?: string | null;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowRunSummary = {
|
||||
id: number;
|
||||
publicId: string;
|
||||
workflowId: number;
|
||||
workflowPublicId?: string | null;
|
||||
userId?: number | null;
|
||||
status: string;
|
||||
inputOverrides: any;
|
||||
finalOutputs?: any;
|
||||
errorMessage?: string | null;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
createdAt: string;
|
||||
nodeRuns: WorkflowNodeRun[];
|
||||
};
|
||||
|
||||
export type WorkflowSavePayload = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
graph?: any;
|
||||
visibility?: string;
|
||||
isTemplate?: boolean;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -1939,6 +2000,26 @@ export const api = {
|
||||
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
||||
deleteWorkspaceAsset: (publicId: string) =>
|
||||
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||
getWorkflowNodeTypes: () =>
|
||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
request<{ success: boolean; workflow: WorkflowSummary }>("/api/workflows", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateWorkflow: (publicId: string, data: WorkflowSavePayload) =>
|
||||
request<{ success: boolean; workflow: WorkflowSummary }>(`/api/workflows/${encodeURIComponent(publicId)}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
deleteWorkflow: (publicId: string) =>
|
||||
request(`/api/workflows/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||
runWorkflow: (publicId: string, inputs?: any) =>
|
||||
request<{ success: boolean; runId: string; run: WorkflowRunSummary }>(`/api/workflows/${encodeURIComponent(publicId)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ inputs: inputs || {} }),
|
||||
timeoutMs: 180_000,
|
||||
}),
|
||||
getWorkflowRuns: (params?: { limit?: number }) =>
|
||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
};
|
||||
export const userApi = {
|
||||
register: (data: { username: string; email: string; password: string }) =>
|
||||
@@ -1960,6 +2041,18 @@ export const userApi = {
|
||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||
getSummary: () => userRequest("/api/user/summary"),
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.refresh) params.set("refresh", "1");
|
||||
if (options?.includePricing) params.set("includePricing", "1");
|
||||
const query = params.toString();
|
||||
return userRequest(`/api/user/models/marketplace${query ? `?${query}` : ""}`, {
|
||||
timeoutMs: options?.refresh ? 45_000 : 15_000,
|
||||
});
|
||||
},
|
||||
getAnnouncements: (params?: string) =>
|
||||
userRequest(`/api/user/announcements${params ? "?" + params : ""}`),
|
||||
markAnnouncementRead: (id: number) =>
|
||||
@@ -1970,4 +2063,24 @@ export const userApi = {
|
||||
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
||||
deleteWorkspaceAsset: (publicId: string) =>
|
||||
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||
getWorkflowNodeTypes: () =>
|
||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
|
||||
createWorkflow: (data: WorkflowSavePayload) =>
|
||||
userRequest<{ success: boolean; workflow: WorkflowSummary }>("/api/user/workflows", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateWorkflow: (publicId: string, data: WorkflowSavePayload) =>
|
||||
userRequest<{ success: boolean; workflow: WorkflowSummary }>(`/api/user/workflows/${encodeURIComponent(publicId)}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
deleteWorkflow: (publicId: string) =>
|
||||
userRequest(`/api/user/workflows/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||
runWorkflow: (publicId: string, inputs?: any) =>
|
||||
userRequest<{ success: boolean; runId: string; run: WorkflowRunSummary }>(`/api/user/workflows/${encodeURIComponent(publicId)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ inputs: inputs || {} }),
|
||||
timeoutMs: 180_000,
|
||||
}),
|
||||
getWorkflowRuns: (params?: { limit?: number }) =>
|
||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
};
|
||||
|
||||
@@ -43,6 +43,10 @@ const zhToEn: Record<string, string> = {
|
||||
'仪表盘': 'Dashboard',
|
||||
'站点': 'Sites',
|
||||
'账号': 'Accounts',
|
||||
'连接管理': 'Connection Management',
|
||||
'用户公告': 'User Announcements',
|
||||
'OAuth 管理': 'OAuth Management',
|
||||
'下游密钥': 'Downstream Keys',
|
||||
'未关联站点': 'Unlinked Site',
|
||||
'余额': 'Balance',
|
||||
'令牌管理': 'Account Tokens',
|
||||
@@ -57,11 +61,20 @@ const zhToEn: Record<string, string> = {
|
||||
'使用日志': 'Usage Logs',
|
||||
'暂无使用日志': 'No Usage Logs',
|
||||
'可用性监控': 'Availability Monitor',
|
||||
'生图助手': 'Picture Agent',
|
||||
'数字人': 'Digital Human',
|
||||
'工作流': 'Workflow',
|
||||
'视频助手': 'Video Assistant',
|
||||
'工作空间': 'Workspace',
|
||||
'系统': 'System',
|
||||
'设置': 'Settings',
|
||||
'程序日志': 'System Logs',
|
||||
'导入/导出': 'Import/Export',
|
||||
'通知设置': 'Notification Settings',
|
||||
'概览': 'Overview',
|
||||
'个人中心': 'Profile',
|
||||
'使用记录': 'Usage Records',
|
||||
'创作应用': 'Creative Apps',
|
||||
'暂无通知': 'No Notifications',
|
||||
'模型广场': 'Model Marketplace',
|
||||
'模型广场刷新进行中': 'Marketplace refresh in progress',
|
||||
|
||||
+524
-66
@@ -2887,6 +2887,8 @@ textarea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picture-agent-panel-head {
|
||||
@@ -3115,6 +3117,354 @@ textarea {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.picture-agent-job-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 56%, var(--color-bg-card));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job-summary div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job-summary strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picture-agent-job-summary span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-job-summary .btn {
|
||||
flex-shrink: 0;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-job-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 56%, var(--color-bg-card));
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picture-agent-job-head,
|
||||
.picture-agent-job-row,
|
||||
.picture-agent-job-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job-head strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picture-agent-job-head-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job-head-copy span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.picture-agent-job-head .btn {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-job-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.picture-agent-job-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.picture-agent-job-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picture-agent-job-row-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.picture-agent-job-title-wrap {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job-row strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picture-agent-job-title-wrap span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picture-agent-job-row span {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-job-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: color-mix(in srgb, var(--color-bg) 72%, transparent);
|
||||
}
|
||||
|
||||
.picture-agent-job-status-running,
|
||||
.picture-agent-job-status-pending {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.picture-agent-job-status-succeeded {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.picture-agent-job-status-failed {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.picture-agent-job-status-cancelled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picture-agent-job-progress-line {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.picture-agent-job-progress-labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.picture-agent-job-progress-labels span,
|
||||
.picture-agent-job-progress-labels em {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-job-progress-labels em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.picture-agent-job-progress {
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-border) 52%, transparent);
|
||||
}
|
||||
|
||||
.picture-agent-job-progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-info));
|
||||
transition: width 0.22s ease;
|
||||
}
|
||||
|
||||
.picture-agent-job-succeeded .picture-agent-job-progress span {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.picture-agent-job-failed .picture-agent-job-progress span {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.picture-agent-job-cancelled .picture-agent-job-progress span {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picture-agent-job-meta {
|
||||
align-items: flex-start;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.picture-agent-job-meta span,
|
||||
.picture-agent-job-meta em {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-job-meta em {
|
||||
color: var(--color-danger);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.picture-agent-job-hint {
|
||||
color: var(--color-warning);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picture-agent-job-terminal {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.picture-agent-job-cancel {
|
||||
justify-self: end;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picture-agent-job-cancel:hover:not(:disabled) {
|
||||
color: var(--color-danger);
|
||||
border-color: color-mix(in srgb, var(--color-danger) 46%, var(--color-border-light));
|
||||
}
|
||||
|
||||
.picture-agent-job-cancel:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog {
|
||||
width: min(820px, calc(100vw - 40px));
|
||||
max-height: min(760px, calc(100vh - 56px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog-head > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog-head span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-job-list-dialog {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog .picture-agent-job {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-job-dialog .picture-agent-job-row strong {
|
||||
max-height: 5.6em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.picture-agent-canvas {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
@@ -3146,9 +3496,10 @@ textarea {
|
||||
|
||||
.picture-agent-template-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
padding: 2px 2px 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.picture-agent-template-empty {
|
||||
@@ -3166,13 +3517,13 @@ textarea {
|
||||
}
|
||||
|
||||
.picture-agent-template-card {
|
||||
width: 156px;
|
||||
min-height: 168px;
|
||||
width: 142px;
|
||||
min-height: 154px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-rows: 112px auto;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
grid-template-rows: 102px auto;
|
||||
gap: 7px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
@@ -3188,7 +3539,7 @@ textarea {
|
||||
|
||||
.picture-agent-template-card img {
|
||||
width: 100%;
|
||||
height: 112px;
|
||||
height: 102px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
@@ -3209,12 +3560,12 @@ textarea {
|
||||
}
|
||||
|
||||
.picture-agent-template-card strong {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-template-card em {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -4287,11 +4638,9 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
padding: 30px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 34%),
|
||||
radial-gradient(circle at right 18%, rgba(34, 211, 238, 0.16), transparent 30%),
|
||||
linear-gradient(180deg, #eef4fb 0%, #e9eef7 100%);
|
||||
linear-gradient(135deg, rgba(219, 234, 254, 0.92) 0%, rgba(248, 250, 252, 0.98) 44%, rgba(224, 242, 254, 0.88) 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-shell {
|
||||
@@ -4302,17 +4651,18 @@ textarea {
|
||||
}
|
||||
|
||||
.login-surface {
|
||||
width: min(1240px, 100%);
|
||||
width: min(1280px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.16fr) minmax(360px, 440px);
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
grid-template-columns: minmax(620px, 1fr) minmax(360px, 420px);
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(244, 248, 253, 0.88));
|
||||
border: 1px solid rgba(255, 255, 255, 0.58);
|
||||
border-radius: 36px;
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(244, 248, 253, 0.94));
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 30px;
|
||||
box-shadow:
|
||||
0 24px 60px rgba(148, 163, 184, 0.24),
|
||||
0 30px 72px rgba(59, 130, 246, 0.16),
|
||||
0 18px 44px rgba(15, 23, 42, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@@ -4327,21 +4677,19 @@ textarea {
|
||||
|
||||
.login-brand-panel {
|
||||
position: relative;
|
||||
min-height: 760px;
|
||||
padding: 56px 56px 44px;
|
||||
min-height: 720px;
|
||||
padding: 54px 58px 42px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
gap: 30px;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 30px;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-brand-panel-light {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(96, 165, 250, 0.22), transparent 26%),
|
||||
radial-gradient(circle at right 20%, rgba(34, 211, 238, 0.16), transparent 22%),
|
||||
linear-gradient(160deg, #f8fbff 0%, #edf4ff 52%, #e9f0fa 100%);
|
||||
linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(239, 246, 255, 0.94) 46%, rgba(226, 239, 255, 0.9) 100%);
|
||||
}
|
||||
|
||||
.login-brand-panel::before {
|
||||
@@ -4369,7 +4717,8 @@ textarea {
|
||||
.login-brand-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 22px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-brand-summary {
|
||||
@@ -4402,9 +4751,9 @@ textarea {
|
||||
}
|
||||
|
||||
.brand-mark-frame-hero {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
border-radius: 36px;
|
||||
width: 124px;
|
||||
height: 124px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.brand-mark-canvas {
|
||||
@@ -4429,9 +4778,10 @@ textarea {
|
||||
|
||||
.login-brand-name {
|
||||
font-family: 'Avenir Next', 'Segoe UI Variable Display', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
font-size: clamp(34px, 3.2vw, 46px);
|
||||
font-size: clamp(38px, 3.6vw, 54px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-brand-kicker {
|
||||
@@ -4444,47 +4794,57 @@ textarea {
|
||||
|
||||
.login-brand-copy-block {
|
||||
display: grid;
|
||||
max-width: 580px;
|
||||
max-width: 620px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-brand-copy {
|
||||
max-width: 42ch;
|
||||
max-width: 44ch;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.92;
|
||||
font-size: 20px;
|
||||
line-height: 1.82;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.login-compat-line {
|
||||
max-width: 42ch;
|
||||
max-width: 52ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: #475569;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-capability-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-width: 44rem;
|
||||
max-width: 46rem;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-capability-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
padding: 18px 0;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.login-capability-index {
|
||||
padding-top: 3px;
|
||||
font-size: 11px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
color: #64748b;
|
||||
letter-spacing: 0;
|
||||
color: #2563eb;
|
||||
border: 1px solid rgba(37, 99, 235, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.login-capability-content {
|
||||
@@ -4494,9 +4854,9 @@ textarea {
|
||||
|
||||
.login-capability-title {
|
||||
font-family: 'Avenir Next', 'Segoe UI Variable Display', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
font-size: 18px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
letter-spacing: 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -4512,6 +4872,7 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-icon-link {
|
||||
@@ -4563,21 +4924,21 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
border-radius: 30px;
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(125, 211, 252, 0.16), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.44), rgba(241, 245, 249, 0.58));
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(241, 245, 249, 0.7));
|
||||
}
|
||||
|
||||
.login-auth-panel {
|
||||
width: min(100%, 392px);
|
||||
padding: 40px 34px 28px;
|
||||
border-radius: 28px;
|
||||
padding: 38px 34px 28px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
0 28px 50px rgba(148, 163, 184, 0.22),
|
||||
0 24px 44px rgba(37, 99, 235, 0.1),
|
||||
0 10px 22px rgba(15, 23, 42, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
@@ -4594,10 +4955,10 @@ textarea {
|
||||
.login-auth-title {
|
||||
margin: 0 0 10px;
|
||||
font-family: 'Avenir Next', 'Segoe UI Variable Display', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
font-size: 42px;
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-auth-copy {
|
||||
@@ -4610,18 +4971,18 @@ textarea {
|
||||
.login-mode-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 12%, var(--color-border));
|
||||
border-radius: 16px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--color-bg) 78%, transparent);
|
||||
}
|
||||
|
||||
.login-mode-tab {
|
||||
min-height: 38px;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
@@ -4648,7 +5009,7 @@ textarea {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border));
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
@@ -4668,7 +5029,7 @@ textarea {
|
||||
padding: 13px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 24px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
@@ -4687,6 +5048,18 @@ textarea {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.login-register-form .login-auth-label {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-register-form .login-auth-input {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-register-form .login-auth-submit {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-surface {
|
||||
width: min(560px, 100%);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -4751,6 +5124,35 @@ textarea {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-height: 900px) and (min-width: 641px) {
|
||||
.login-shell[data-variant="user"] {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-surface {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-panel {
|
||||
padding-block: 20px 14px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .brand-mark-frame-hero {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-name {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-auth-panel {
|
||||
padding: 30px 30px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-surface,
|
||||
[data-theme="dark"] .brand-mark-frame,
|
||||
[data-theme="dark"] .brand-mark-canvas,
|
||||
@@ -7788,3 +8190,59 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.comfyui-agent-page article p {
|
||||
margin: 0 0 0.72em;
|
||||
}
|
||||
|
||||
.comfyui-agent-page article p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comfyui-agent-page article ul,
|
||||
.comfyui-agent-page article ol {
|
||||
margin: 0.4em 0 0.8em;
|
||||
padding-left: 1.35em;
|
||||
}
|
||||
|
||||
.comfyui-agent-page article blockquote {
|
||||
margin: 0.6em 0;
|
||||
padding: 0.2em 0 0.2em 0.9em;
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.comfyui-agent-page article pre {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
margin: 0.75em 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-bg) 82%, #111827);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.comfyui-agent-page article code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.comfyui-agent-page article :not(pre) > code {
|
||||
padding: 0.12em 0.35em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-agent-page article table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.comfyui-agent-page article th,
|
||||
.comfyui-agent-page article td {
|
||||
border: 1px solid var(--color-border-light);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ const FEATURES = [
|
||||
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
||||
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
||||
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
||||
{ icon: 'CU', title: 'ComfyUI 工作台', desc: '内置多租户 ComfyUI 容器池,支持用 BoosAPI 模型驱动文本、图像和视频节点。' },
|
||||
{ icon: 'AG', title: 'ComfyUI Agent', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
|
||||
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
||||
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
|
||||
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
||||
@@ -17,18 +17,18 @@ const FEATURES = [
|
||||
|
||||
const TECH_STACK = [
|
||||
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
|
||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和容器生命周期管理。' },
|
||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' },
|
||||
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
||||
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
||||
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
||||
{ name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
|
||||
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
|
||||
];
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
|
||||
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
|
||||
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' },
|
||||
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
|
||||
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
|
||||
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
||||
@@ -56,10 +56,10 @@ export default function About() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、ComfyUI、图像生成、视频任务和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和 ComfyUI 节点都能使用后台已经配置好的模型。')}
|
||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
||||
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
@@ -171,7 +171,7 @@ export default function About() {
|
||||
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、ComfyUI 容器与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -493,7 +493,37 @@ export default function Accounts() {
|
||||
credentialMode,
|
||||
skipModelFetch: tokenForm.skipModelFetch,
|
||||
});
|
||||
const createdAccountIds = result?.batch
|
||||
? Array.isArray(result?.data)
|
||||
? result.data
|
||||
.map((item: any) => Number(item?.id))
|
||||
.filter((id: number) => Number.isFinite(id) && id > 0)
|
||||
: []
|
||||
: Number(result?.id) > 0
|
||||
? [Number(result.id)]
|
||||
: [];
|
||||
if (result?.batch) {
|
||||
if (
|
||||
activeSegment === "apikey" &&
|
||||
createdAccountIds.length > 0
|
||||
) {
|
||||
try {
|
||||
const syncResults = await Promise.allSettled(
|
||||
createdAccountIds.map((accountId: number) =>
|
||||
api.checkModels(accountId),
|
||||
),
|
||||
);
|
||||
const syncedCount = syncResults.filter(
|
||||
(item) => item.status === "fulfilled",
|
||||
).length;
|
||||
if (syncedCount > 0) {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success("批量 API Key 已自动同步模型并重建路由");
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
toast.error(syncErr?.message || "批量连接已添加,但自动建路由失败");
|
||||
}
|
||||
}
|
||||
closeAddPanel();
|
||||
const createdCount = Number(result.createdCount) || 0;
|
||||
const failedCount = Number(result.failedCount) || 0;
|
||||
@@ -527,6 +557,7 @@ export default function Accounts() {
|
||||
createdAccountId,
|
||||
recommendedModels,
|
||||
);
|
||||
await api.rebuildRoutes(false, false);
|
||||
seededRecommendedModels = true;
|
||||
} catch (seedErr: any) {
|
||||
toast.error(seedErr?.message || "连接已添加,但推荐模型补录失败");
|
||||
@@ -549,6 +580,52 @@ export default function Accounts() {
|
||||
`已补入 ${recommendedModels.length} 个推荐模型并重建路由`,
|
||||
);
|
||||
}
|
||||
const shouldAutoSyncModels =
|
||||
credentialMode === "apikey" &&
|
||||
!tokenForm.skipModelFetch &&
|
||||
createdAccountIds.length > 0;
|
||||
if (shouldAutoSyncModels) {
|
||||
if (
|
||||
!result?.batch &&
|
||||
verifyResult?.success &&
|
||||
Array.isArray(verifyResult.models) &&
|
||||
verifyResult.models.length > 0
|
||||
) {
|
||||
try {
|
||||
await api.addAccountAvailableModels(
|
||||
createdAccountIds[0],
|
||||
verifyResult.models,
|
||||
);
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success("已自动同步模型并重建路由");
|
||||
} catch (syncErr: any) {
|
||||
toast.error(syncErr?.message || "连接已添加,但模型同步失败");
|
||||
}
|
||||
} else {
|
||||
const syncResults = await Promise.allSettled(
|
||||
createdAccountIds.map((accountId: number) =>
|
||||
api.checkModels(accountId),
|
||||
),
|
||||
);
|
||||
const syncedCount = syncResults.filter(
|
||||
(item) => item.status === "fulfilled",
|
||||
).length;
|
||||
const failedCount = syncResults.length - syncedCount;
|
||||
if (syncedCount > 0) {
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success(
|
||||
`已自动同步 ${syncedCount} 个连接的模型并重建路由`,
|
||||
);
|
||||
} catch (rebuildErr: any) {
|
||||
toast.error(rebuildErr?.message || "模型已同步,但路由重建失败");
|
||||
}
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
toast.error(`有 ${failedCount} 个连接同步模型失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
load(true);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "添加失败");
|
||||
|
||||
+93
-135
@@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
|
||||
export default function ComfyUI() {
|
||||
const [isDark, setIsDark] = React.useState(true);
|
||||
const [iframeSrc, setIframeSrc] = React.useState('');
|
||||
const [starting, setStarting] = React.useState(true);
|
||||
const [message, setMessage] = React.useState('正在启动 ComfyUI...');
|
||||
const [error, setError] = React.useState('');
|
||||
const location = useLocation();
|
||||
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const token = getSessionToken(audience, localStorage);
|
||||
const [available, setAvailable] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('正在检查外部 ComfyUI...');
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const openFullscreen = () => {
|
||||
window.open('/api/comfyui/proxy/', '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const reloadFrame = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = '/api/comfyui/proxy/';
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
@@ -23,121 +27,25 @@ export default function ComfyUI() {
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const encodedToken = token ? encodeURIComponent(token) : '';
|
||||
const requestTimeoutMs = 35_000;
|
||||
|
||||
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveIframeFromPayload = (payload: any) => {
|
||||
const comfyui = payload?.comfyui || payload || {};
|
||||
const mode = String(comfyui?.mode || '').toLowerCase();
|
||||
const status = String(comfyui?.status || '').toLowerCase();
|
||||
const lastError = String(comfyui?.lastError || comfyui?.last_error || '').trim();
|
||||
|
||||
if (mode === 'container') {
|
||||
if (status === 'running') return `/comfy/?token=${encodedToken}`;
|
||||
if (status === 'unhealthy' || status === 'failed') {
|
||||
throw new Error(lastError || 'ComfyUI 容器启动失败');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (status && status !== 'disabled') {
|
||||
return '/api/comfyui/proxy/';
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
setIframeSrc('');
|
||||
setStarting(false);
|
||||
setMessage('登录状态已过期,请重新登录。');
|
||||
setError('');
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setStarting(true);
|
||||
setIframeSrc('');
|
||||
setError('');
|
||||
setMessage('正在启动 ComfyUI...');
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/video-agent/start', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!cancelled) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`启动接口返回 ${response.status}`);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
const nextSrc = resolveIframeFromPayload(payload);
|
||||
if (nextSrc) {
|
||||
setIframeSrc(nextSrc);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) {
|
||||
setMessage('ComfyUI 启动较慢,正在检查状态...');
|
||||
if (e?.name !== 'AbortError') setError(e?.message || '启动请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (!cancelled && Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/video-agent/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const nextSrc = resolveIframeFromPayload(payload);
|
||||
if (nextSrc) {
|
||||
setIframeSrc(nextSrc);
|
||||
return;
|
||||
}
|
||||
setMessage('ComfyUI 仍在启动,等待容器就绪...');
|
||||
} else {
|
||||
setError(`状态接口返回 ${response.status}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.name === 'AbortError' ? '状态检查超时' : (e?.message || '状态检查失败'));
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setStarting(false);
|
||||
setMessage('ComfyUI 启动超时');
|
||||
setError((current) => current || '请检查 /api/video-agent/status、Docker 容器日志和 4000 后端进程。');
|
||||
}
|
||||
};
|
||||
|
||||
void start().finally(() => {
|
||||
if (!cancelled) setStarting(false);
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
void fetch('/api/comfyui/proxy/', { method: 'GET', signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setAvailable(response.ok);
|
||||
setMessage(response.ok
|
||||
? ''
|
||||
: '外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled || err?.name === 'AbortError') return;
|
||||
setAvailable(false);
|
||||
setMessage('外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [token]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -149,15 +57,23 @@ export default function ComfyUI() {
|
||||
background: isDark ? '#1a1a2e' : '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
{iframeSrc ? (
|
||||
<div style={toolbarStyle}>
|
||||
<div style={toolbarTitleStyle}>外部 ComfyUI</div>
|
||||
<div style={toolbarActionsStyle}>
|
||||
<button type="button" onClick={reloadFrame} disabled={!available} style={toolbarButtonStyle}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" onClick={openFullscreen} disabled={!available} style={primaryToolbarButtonStyle}>
|
||||
新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{available ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="ComfyUI"
|
||||
ref={iframeRef}
|
||||
src="/api/comfyui/proxy/"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="External ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
) : (
|
||||
@@ -168,16 +84,58 @@ export default function ComfyUI() {
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
gap: 8,
|
||||
padding: 24,
|
||||
}}>
|
||||
<div>{starting ? message : message}</div>
|
||||
{error && (
|
||||
<div style={{ color: '#ef4444', fontSize: 12, maxWidth: 520 }}>
|
||||
{error}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 8 }}>
|
||||
内置 Docker ComfyUI 工作台已移除
|
||||
</div>
|
||||
)}
|
||||
<div style={{ maxWidth: 560, lineHeight: 1.7 }}>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const toolbarTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const toolbarActionsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const toolbarButtonStyle: React.CSSProperties = {
|
||||
minHeight: 30,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '5px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const primaryToolbarButtonStyle: React.CSSProperties = {
|
||||
...toolbarButtonStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
+873
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
import React, { CSSProperties, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { fetchSessionResponse } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
|
||||
type Mode = 'audio' | 'image' | 'text';
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
|
||||
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
|
||||
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
|
||||
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
|
||||
];
|
||||
|
||||
const MODEL_PRESETS = [
|
||||
'longcat-video-avatar',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'wan2-1-14b-i2v-250225',
|
||||
];
|
||||
|
||||
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
|
||||
|
||||
async function parseError(response: Response) {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!text) return `HTTP ${response.status}`;
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return value?.error?.message || value?.message || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTaskId(value: any): string {
|
||||
return value?.response?.id
|
||||
|| value?.response?.task_id
|
||||
|| value?.response?.data?.id
|
||||
|| value?.response?.data?.task_id
|
||||
|| value?.id
|
||||
|| value?.task_id
|
||||
|| '';
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
if (!value) return;
|
||||
void navigator.clipboard?.writeText(value);
|
||||
}
|
||||
|
||||
export default function DigitalHuman() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const [mode, setMode] = useState<Mode>('audio');
|
||||
const [model, setModel] = useState(MODEL_PRESETS[0]);
|
||||
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
|
||||
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
|
||||
const [resolution, setResolution] = useState('720p');
|
||||
const [ratio, setRatio] = useState('9:16');
|
||||
const [duration, setDuration] = useState(8);
|
||||
const [refImageIndex, setRefImageIndex] = useState(0);
|
||||
const [audioCfg, setAudioCfg] = useState(4.5);
|
||||
const [segments, setSegments] = useState(false);
|
||||
const [multiPerson, setMultiPerson] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
const bodyPreview = useMemo(() => ({
|
||||
model: model.trim(),
|
||||
prompt: [
|
||||
prompt.trim(),
|
||||
script.trim() ? `台词:${script.trim()}` : '',
|
||||
`风格:${voiceStyle}`,
|
||||
].filter(Boolean).join('\n'),
|
||||
mode,
|
||||
ratio,
|
||||
resolution,
|
||||
duration,
|
||||
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
|
||||
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
|
||||
audio_cfg: audioCfg,
|
||||
ref_img_index: refImageIndex,
|
||||
segmented: segments,
|
||||
multi_person: multiPerson,
|
||||
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(bodyPreview),
|
||||
timeoutMs: 300_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
setResult(await response.json());
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '数字人任务创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const taskId = extractTaskId(result);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">数字人</h1>
|
||||
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建数字人任务'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
<div style={layoutStyle}>
|
||||
<section style={panelStyle}>
|
||||
<div style={modeGridStyle}>
|
||||
{MODE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setMode(item.value)}
|
||||
style={mode === item.value ? activeModeStyle : modeStyle}
|
||||
>
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={formGridStyle}>
|
||||
<label style={fieldStyle}>
|
||||
<span>模型</span>
|
||||
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
|
||||
<datalist id="digital-human-models">
|
||||
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>画幅</span>
|
||||
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
<option value="1:1">1:1 方屏</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>分辨率</span>
|
||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>时长(秒)</span>
|
||||
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={fieldStyle}>
|
||||
<span>人物/场景描述</span>
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>台词/脚本</span>
|
||||
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
|
||||
</label>
|
||||
|
||||
<div style={formGridStyle}>
|
||||
{mode !== 'text' && (
|
||||
<label style={fieldStyle}>
|
||||
<span>人物图 URL</span>
|
||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
)}
|
||||
{mode === 'audio' && (
|
||||
<label style={fieldStyle}>
|
||||
<span>音频 URL</span>
|
||||
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
)}
|
||||
<label style={fieldStyle}>
|
||||
<span>口播风格</span>
|
||||
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
|
||||
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>ref_img_index</span>
|
||||
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>audio CFG</span>
|
||||
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={toggleRowStyle}>
|
||||
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
|
||||
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside style={panelStyle}>
|
||||
<h2 style={sideTitleStyle}>任务请求</h2>
|
||||
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
|
||||
|
||||
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
|
||||
{result ? (
|
||||
<>
|
||||
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
|
||||
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||
<div className="empty-state-title">暂无任务</div>
|
||||
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1.25fr) minmax(320px, 0.75fr)',
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
const modeGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 10,
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const modeStyle: CSSProperties = {
|
||||
textAlign: 'left',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const activeModeStyle: CSSProperties = {
|
||||
...modeStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
boxShadow: '0 0 0 2px color-mix(in srgb, var(--color-primary) 15%, transparent)',
|
||||
};
|
||||
|
||||
const formGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const fieldStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const textareaStyle: CSSProperties = {
|
||||
...inputStyle,
|
||||
minHeight: 110,
|
||||
resize: 'vertical',
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 13,
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
|
||||
const sideTitleStyle: CSSProperties = {
|
||||
margin: '0 0 10px',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const codeStyle: CSSProperties = {
|
||||
maxHeight: 280,
|
||||
overflow: 'auto',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
};
|
||||
|
||||
const taskIdStyle: CSSProperties = {
|
||||
marginBottom: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 12,
|
||||
};
|
||||
+151
-86
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { api, userApi } from '../api.js';
|
||||
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import SiteBadgeLink from '../components/SiteBadgeLink.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
@@ -125,6 +125,24 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
|
||||
return `${pricing.perCallTotal ?? 0} USD / call`;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50];
|
||||
|
||||
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
|
||||
@@ -153,6 +171,9 @@ function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'a
|
||||
/* ---- component ---- */
|
||||
export default function Models() {
|
||||
const toast = useToast();
|
||||
const location = useLocation();
|
||||
const isUserView = location.pathname.startsWith('/user/');
|
||||
const marketplaceApi = isUserView ? userApi : api;
|
||||
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -172,7 +193,6 @@ export default function Models() {
|
||||
const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220);
|
||||
const latestPrimaryRequestRef = useRef(0);
|
||||
const latestMetadataRequestRef = useRef(0);
|
||||
const location = useLocation();
|
||||
const siteIdByName = useMemo(() => {
|
||||
const index = new Map<string, number>();
|
||||
for (const model of data.models) {
|
||||
@@ -192,7 +212,7 @@ export default function Models() {
|
||||
setMetadataHydrating(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.getModelsMarketplace({
|
||||
const res = await marketplaceApi.getModelsMarketplace({
|
||||
refresh,
|
||||
includePricing: false,
|
||||
});
|
||||
@@ -216,7 +236,7 @@ export default function Models() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [toast]);
|
||||
}, [marketplaceApi, toast]);
|
||||
|
||||
const hydrateMarketplaceMetadata = useCallback(async (baseModels: ModelRow[]) => {
|
||||
if (!shouldHydrateMarketplaceMetadata(baseModels)) return;
|
||||
@@ -225,7 +245,7 @@ export default function Models() {
|
||||
const baseRequestId = latestPrimaryRequestRef.current;
|
||||
setMetadataHydrating(true);
|
||||
try {
|
||||
const res = await api.getModelsMarketplace({
|
||||
const res = await marketplaceApi.getModelsMarketplace({
|
||||
includePricing: true,
|
||||
});
|
||||
if (metadataRequestId !== latestMetadataRequestRef.current) return;
|
||||
@@ -244,7 +264,7 @@ export default function Models() {
|
||||
setMetadataHydrating(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [marketplaceApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -311,6 +331,7 @@ export default function Models() {
|
||||
|
||||
/* ---- derived: site list ---- */
|
||||
const siteMap = useMemo(() => {
|
||||
if (isUserView) return [];
|
||||
const m = new Map<string, number>();
|
||||
for (const model of data.models) {
|
||||
for (const a of model.accounts) {
|
||||
@@ -318,7 +339,7 @@ export default function Models() {
|
||||
}
|
||||
}
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [data.models]);
|
||||
}, [data.models, isUserView]);
|
||||
|
||||
/* ---- filtered ---- */
|
||||
const filteredModels = useMemo(() => {
|
||||
@@ -366,8 +387,8 @@ export default function Models() {
|
||||
};
|
||||
}) : filteredModels;
|
||||
|
||||
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
|
||||
}, [filteredModels, activeSite, sortBy, sortDir]);
|
||||
return [...scopedModels].sort((a, b) => compareModels(a, b, isUserView ? 'name' : sortBy, isUserView ? 'asc' : sortDir));
|
||||
}, [filteredModels, activeSite, sortBy, sortDir, isUserView]);
|
||||
|
||||
/* ---- pagination ---- */
|
||||
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
|
||||
@@ -395,10 +416,15 @@ export default function Models() {
|
||||
: null;
|
||||
|
||||
/* ---- copy ---- */
|
||||
const copyName = (name: string) => {
|
||||
navigator.clipboard.writeText(name).catch(() => { });
|
||||
setCopied(name);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
const copyName = async (name: string) => {
|
||||
try {
|
||||
await copyTextToClipboard(name);
|
||||
setCopied(name);
|
||||
toast.success(tr('已复制模型名'));
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || tr('复制模型名失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const filterControls = (
|
||||
@@ -441,27 +467,29 @@ export default function Models() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('供应商')}
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
key={site}
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
{site.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
{!isUserView && (
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('供应商')}
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
key={site}
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
{site.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-panel-section">
|
||||
{!isUserView && <div className="filter-panel-section">
|
||||
<div className="filter-panel-title">{tr('排序方式')}</div>
|
||||
{[
|
||||
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
|
||||
@@ -490,7 +518,7 @@ export default function Models() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -626,15 +654,17 @@ export default function Models() {
|
||||
/>
|
||||
</div>
|
||||
{/* Quick stats */}
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
|
||||
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
|
||||
</span>
|
||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||
</span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
|
||||
</div>
|
||||
{!isUserView && (
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
|
||||
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
|
||||
</span>
|
||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||
</span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty */}
|
||||
@@ -660,14 +690,14 @@ export default function Models() {
|
||||
<div className="model-card-info">
|
||||
<div className="model-card-name">{m.name}</div>
|
||||
<div className="model-card-meta">
|
||||
<span>
|
||||
{!isUserView && <span>
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
{m.accountCount} {tr('个账号')}
|
||||
</span>
|
||||
<span>
|
||||
</span>}
|
||||
{!isUserView && <span>
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
{m.tokenCount} {tr('令牌')}
|
||||
</span>
|
||||
</span>}
|
||||
<span
|
||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
@@ -685,7 +715,16 @@ export default function Models() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
|
||||
<button className="model-card-action-btn" data-tooltip={tr('复制模型名')} aria-label={tr('复制模型名')} onClick={() => copyName(m.name)}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-card-action-btn"
|
||||
data-tooltip={tr('复制模型名')}
|
||||
aria-label={tr('复制模型名')}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
void copyName(m.name);
|
||||
}}
|
||||
>
|
||||
{copied === m.name ? (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
@@ -709,7 +748,7 @@ export default function Models() {
|
||||
{getBrand(m.name) && (
|
||||
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
|
||||
)}
|
||||
{m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
|
||||
{!isUserView && m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
|
||||
<span key={site} className="model-tag model-tag-blue">{site}</span>
|
||||
))}
|
||||
{m.successRate != null && m.successRate >= 90 && (
|
||||
@@ -750,35 +789,48 @@ export default function Models() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
|
||||
{m.pricingSources.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{m.pricingSources.map((source) => (
|
||||
<div
|
||||
key={`${source.siteId}-${source.accountId}`}
|
||||
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
|
||||
>
|
||||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||||
<SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
|
||||
{!isUserView && (
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
|
||||
{m.pricingSources.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{m.pricingSources.map((source) => (
|
||||
<div
|
||||
key={`${source.siteId}-${source.accountId}`}
|
||||
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
|
||||
>
|
||||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||||
{isUserView ? tr('隐藏账号详情') : <><SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}</>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{Object.entries(source.groupPricing).map(([group, pricing]) => (
|
||||
<span key={group} className="badge badge-info">
|
||||
{group}: {renderGroupPricingValue(pricing)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{Object.entries(source.groupPricing).map(([group, pricing]) => (
|
||||
<span key={group} className="badge badge-info">
|
||||
{group}: {renderGroupPricingValue(pricing)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
{isUserView ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('品牌')}</div>
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
{getBrand(m.name) ? (
|
||||
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
|
||||
) : (
|
||||
<span className="badge badge-muted">{tr('未识别品牌')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('账号明细')}</div>
|
||||
{m.accounts.map((a) => (
|
||||
@@ -828,8 +880,8 @@ export default function Models() {
|
||||
<tbody>
|
||||
{m.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
|
||||
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
|
||||
<td>{isUserView ? <span className="badge badge-muted">{tr('隐藏')}</span> : <SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} />}</td>
|
||||
<td style={{ fontSize: 12 }}>{isUserView ? tr('隐藏') : (a.username || `ID:${a.id}`)}</td>
|
||||
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{a.tokens.length > 0 ? a.tokens.map(t => (
|
||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||
@@ -864,12 +916,16 @@ export default function Models() {
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
{!isUserView && (
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
)}
|
||||
{!isUserView && (
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
)}
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
@@ -893,8 +949,8 @@ export default function Models() {
|
||||
{m.name}
|
||||
</code>
|
||||
</td>
|
||||
<td><span className="badge badge-info">{m.accountCount}</span></td>
|
||||
<td><span className="badge badge-muted">{m.tokenCount}</span></td>
|
||||
{!isUserView && <td><span className="badge badge-info">{m.accountCount}</span></td>}
|
||||
{!isUserView && <td><span className="badge badge-muted">{m.tokenCount}</span></td>}
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||
@@ -912,7 +968,16 @@ export default function Models() {
|
||||
</span>
|
||||
</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-card-action-btn"
|
||||
data-tooltip={tr('复制')}
|
||||
aria-label={tr('复制')}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
void copyName(m.name);
|
||||
}}
|
||||
>
|
||||
{copied === m.name ? (
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
@@ -923,7 +988,7 @@ export default function Models() {
|
||||
</tr>
|
||||
{isExpanded ? (
|
||||
<tr className="log-detail-row">
|
||||
<td colSpan={7} style={{ padding: 0 }}>
|
||||
<td colSpan={isUserView ? 5 : 7} style={{ padding: 0 }}>
|
||||
<div className="anim-collapse is-open">
|
||||
<div className="anim-collapse-inner">
|
||||
<div style={{ padding: '12px 16px 12px 54px' }}>
|
||||
|
||||
+390
-13
@@ -13,6 +13,36 @@ type AgentImage = {
|
||||
};
|
||||
|
||||
type PictureMode = 'generate' | 'edit';
|
||||
type PictureJobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
|
||||
type PictureJob = {
|
||||
id: string;
|
||||
status: PictureJobStatus | string;
|
||||
model?: string | null;
|
||||
message?: string | null;
|
||||
error?: string | null;
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
result?: {
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
mode?: PictureMode | string;
|
||||
response?: any;
|
||||
images?: any[];
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
finishedAt?: string | null;
|
||||
};
|
||||
|
||||
function staleRunningHint(job: PictureJob) {
|
||||
if (job.status !== 'pending' && job.status !== 'running') return '';
|
||||
const updatedAt = parseServerTime(job.updatedAt || job.createdAt);
|
||||
if (!Number.isFinite(updatedAt)) return '';
|
||||
const ageMs = Date.now() - updatedAt;
|
||||
if (ageMs < 180_000) return '';
|
||||
return '图片模型响应时间较长,任务仍在后台等待。大型提示词或高峰期通常需要 3-5 分钟。';
|
||||
}
|
||||
|
||||
type PictureAgentStatus = {
|
||||
success?: boolean;
|
||||
@@ -47,6 +77,7 @@ type GalleryItem = {
|
||||
|
||||
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
|
||||
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
|
||||
const TEMPLATE_LIMIT = 144;
|
||||
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
|
||||
const ASPECT_OPTIONS = [
|
||||
{ value: '1:1', label: '1:1 方图', width: 1024, height: 1024 },
|
||||
@@ -84,7 +115,13 @@ function makeId() {
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: any): string[] {
|
||||
const data = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const data = Array.isArray(payload?.data)
|
||||
? payload.data
|
||||
: Array.isArray(payload?.response?.data)
|
||||
? payload.response.data
|
||||
: Array.isArray(payload?.images)
|
||||
? payload.images
|
||||
: [];
|
||||
return data
|
||||
.map((item: any) => {
|
||||
if (typeof item?.url === 'string' && item.url.trim()) return item.url.trim();
|
||||
@@ -112,6 +149,28 @@ function getFilename(image: AgentImage) {
|
||||
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
|
||||
}
|
||||
|
||||
function parseServerTime(value?: string | null): number {
|
||||
if (!value) return Number.NaN;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return Number.NaN;
|
||||
const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
|
||||
const withTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
|
||||
? normalized
|
||||
: `${normalized}Z`;
|
||||
return Date.parse(withTimezone);
|
||||
}
|
||||
|
||||
function formatLocalTime(value: number | string | null | undefined): string {
|
||||
const timestamp = typeof value === 'number' ? value : parseServerTime(value);
|
||||
if (!Number.isFinite(timestamp)) return '-';
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function clampDimension(value: number) {
|
||||
if (!Number.isFinite(value)) return 1024;
|
||||
return Math.min(4096, Math.max(256, Math.round(value)));
|
||||
@@ -168,7 +227,62 @@ function templateSize(prompt: string) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate[] {
|
||||
function isPictureModel(model: string) {
|
||||
const normalized = model.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
const imageKeywords = [
|
||||
'gpt-image',
|
||||
'dall-e',
|
||||
'dalle',
|
||||
'midjourney',
|
||||
'recraft',
|
||||
'kolors',
|
||||
'cogview',
|
||||
'seedream',
|
||||
'sdxl',
|
||||
'stable-diffusion',
|
||||
'stablediffusion',
|
||||
'pixart',
|
||||
'playground',
|
||||
'firefly',
|
||||
'imagen',
|
||||
'flux',
|
||||
'image',
|
||||
'img',
|
||||
'canvas',
|
||||
'painting',
|
||||
'draw',
|
||||
];
|
||||
if (imageKeywords.some((keyword) => normalized.includes(keyword))) return true;
|
||||
const chatKeywords = [
|
||||
'claude',
|
||||
'chatgpt',
|
||||
'gpt-4',
|
||||
'gpt-3',
|
||||
'gpt-5',
|
||||
'o1',
|
||||
'o3',
|
||||
'o4',
|
||||
'gemini',
|
||||
'deepseek',
|
||||
'qwen',
|
||||
'llama',
|
||||
'mistral',
|
||||
'command',
|
||||
'kimi',
|
||||
'doubao',
|
||||
'glm',
|
||||
'hunyuan',
|
||||
'spark',
|
||||
'baichuan',
|
||||
'ernie',
|
||||
'wenxin',
|
||||
'grok',
|
||||
];
|
||||
return !chatKeywords.some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
function normalizeGalleryItems(items: GalleryItem[], limit = TEMPLATE_LIMIT): PromptTemplate[] {
|
||||
return items
|
||||
.map<PromptTemplate | null>((item) => {
|
||||
const prompt = (item.prompt || '').trim();
|
||||
@@ -194,7 +308,7 @@ function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadGalleryTemplatesDirectly(limit = 48): Promise<PromptTemplate[]> {
|
||||
async function loadGalleryTemplatesDirectly(limit = TEMPLATE_LIMIT): Promise<PromptTemplate[]> {
|
||||
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
|
||||
const items = await response.json();
|
||||
@@ -221,20 +335,68 @@ export default function PictureAgent() {
|
||||
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
||||
const [sourcePreview, setSourcePreview] = useState('');
|
||||
const [images, setImages] = useState<AgentImage[]>([]);
|
||||
const [jobs, setJobs] = useState<PictureJob[]>([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancellingJobId, setCancellingJobId] = useState('');
|
||||
const [clearingJobs, setClearingJobs] = useState(false);
|
||||
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||
const [bootError, setBootError] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
|
||||
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
|
||||
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
|
||||
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
|
||||
const resolvedSize = buildSize(aspect, customWidth, customHeight);
|
||||
const latestJob = jobs[0] ?? null;
|
||||
const latestJobLabel = latestJob?.status === 'running'
|
||||
? '正在处理最新任务'
|
||||
: latestJob?.status === 'pending'
|
||||
? '最新任务已排队'
|
||||
: latestJob?.status === 'failed'
|
||||
? '最新任务失败'
|
||||
: latestJob?.status === 'cancelled'
|
||||
? '最新任务已取消'
|
||||
: latestJob?.status === 'succeeded'
|
||||
? '最新任务已完成'
|
||||
: '暂无最新任务';
|
||||
const terminalJobs = useMemo(
|
||||
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
|
||||
[jobs],
|
||||
);
|
||||
|
||||
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
|
||||
return fetchSessionResponse(audience, url, options);
|
||||
}, [audience]);
|
||||
|
||||
const hydrateImagesFromJobs = useCallback((items: PictureJob[]) => {
|
||||
const completed = items.filter((job) => job.status === 'succeeded');
|
||||
if (!completed.length) return;
|
||||
setImages((current) => {
|
||||
const seen = new Set(current.map((image) => image.url));
|
||||
const next: AgentImage[] = [];
|
||||
for (const job of completed) {
|
||||
const urls = extractImageUrls(job.result || {});
|
||||
for (const url of urls) {
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
next.push({
|
||||
id: makeId(),
|
||||
url,
|
||||
prompt: job.result?.prompt || '',
|
||||
model: job.result?.model || model || 'image',
|
||||
mode: job.result?.mode === 'edit' ? 'edit' : 'generate',
|
||||
createdAt: parseServerTime(job.finishedAt) || Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!next.length) return current;
|
||||
setSelectedId((selected) => selected || next[0]?.id || '');
|
||||
return [...next, ...current].slice(0, 48);
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function load() {
|
||||
@@ -247,10 +409,11 @@ export default function PictureAgent() {
|
||||
setTemplatesLoading(true);
|
||||
setTemplatesError('');
|
||||
try {
|
||||
const [statusRes, modelsRes, templatesRes] = await Promise.all([
|
||||
const [statusRes, modelsRes, templatesRes, jobsRes] = await Promise.all([
|
||||
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
|
||||
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
|
||||
authedFetch('/api/picture-agent/templates?limit=48', { timeoutMs: 30_000 }),
|
||||
authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 30_000 }),
|
||||
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 }),
|
||||
]);
|
||||
if (!statusRes.ok) throw new Error(await parseError(statusRes));
|
||||
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
|
||||
@@ -262,17 +425,32 @@ export default function PictureAgent() {
|
||||
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
|
||||
} else {
|
||||
try {
|
||||
nextTemplates = await loadGalleryTemplatesDirectly(48);
|
||||
nextTemplates = await loadGalleryTemplatesDirectly(TEMPLATE_LIMIT);
|
||||
} catch {
|
||||
setTemplatesError(await parseError(templatesRes));
|
||||
}
|
||||
}
|
||||
let nextJobs: PictureJob[] = [];
|
||||
if (jobsRes.ok) {
|
||||
const jobsPayload = await jobsRes.json();
|
||||
nextJobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : [];
|
||||
}
|
||||
if (ignore) return;
|
||||
const availableModels = Array.isArray(nextModels?.models) ? nextModels.models : [];
|
||||
const availableModels = Array.isArray(nextModels?.models)
|
||||
? nextModels.models.filter((item: string) => isPictureModel(item))
|
||||
: [];
|
||||
setStatus(nextStatus);
|
||||
setModels(availableModels);
|
||||
setTemplates(nextTemplates);
|
||||
setModel((current) => current || nextModels?.defaultModel || availableModels[0] || 'gpt-image-2');
|
||||
setJobs(nextJobs);
|
||||
hydrateImagesFromJobs(nextJobs);
|
||||
setModel((current) => {
|
||||
if (current && isPictureModel(current)) return current;
|
||||
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
|
||||
? nextModels.defaultModel
|
||||
: availableModels[0];
|
||||
return fallback || 'gpt-image-2';
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
|
||||
} finally {
|
||||
@@ -283,7 +461,36 @@ export default function PictureAgent() {
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [audience, authedFetch, token]);
|
||||
}, [audience, authedFetch, hydrateImagesFromJobs, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeJobs.length || !token) return;
|
||||
let stopped = false;
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const updated = await Promise.all(activeJobs.map(async (job) => {
|
||||
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(job.id)}`, { timeoutMs: 15_000 });
|
||||
if (!response.ok) return job;
|
||||
const payload = await response.json();
|
||||
return payload?.job || job;
|
||||
}));
|
||||
if (stopped) return;
|
||||
setJobs((current) => {
|
||||
const map = new Map(current.map((job) => [job.id, job]));
|
||||
for (const job of updated) map.set(job.id, job);
|
||||
const next = Array.from(map.values()).sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
||||
hydrateImagesFromJobs(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep polling quietly; task status is also visible after refresh.
|
||||
}
|
||||
}, 1800);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [activeJobs, authedFetch, hydrateImagesFromJobs, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceFile) {
|
||||
@@ -322,7 +529,7 @@ export default function PictureAgent() {
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
} else {
|
||||
response = await authedFetch('/api/picture-agent/images/generations', {
|
||||
response = await authedFetch('/api/picture-agent/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: model.trim(),
|
||||
@@ -330,12 +537,17 @@ export default function PictureAgent() {
|
||||
size: resolvedSize,
|
||||
quality,
|
||||
}),
|
||||
timeoutMs: 180_000,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
if (mode === 'generate' && payload?.job) {
|
||||
setJobs((current) => [payload.job, ...current.filter((job) => job.id !== payload.job.id)].slice(0, 20));
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
const urls = extractImageUrls(payload);
|
||||
if (urls.length === 0) throw new Error('上游没有返回图片 URL 或 base64 图片。');
|
||||
|
||||
@@ -357,6 +569,56 @@ export default function PictureAgent() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshJobs = async () => {
|
||||
const response = await authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 });
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
const nextJobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
|
||||
setJobs(nextJobs);
|
||||
hydrateImagesFromJobs(nextJobs);
|
||||
};
|
||||
|
||||
const clearJobs = async () => {
|
||||
if (clearingJobs) return;
|
||||
setClearingJobs(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await authedFetch('/api/picture-agent/jobs/clear', {
|
||||
method: 'POST',
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
await refreshJobs();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '清空日志失败');
|
||||
} finally {
|
||||
setClearingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelJob = async (jobId: string) => {
|
||||
if (!jobId || cancellingJobId) return;
|
||||
setCancellingJobId(jobId);
|
||||
setError('');
|
||||
try {
|
||||
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(jobId)}/cancel`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
if (payload?.job) {
|
||||
setJobs((current) => current.map((job) => job.id === jobId ? payload.job : job));
|
||||
} else {
|
||||
await refreshJobs();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '取消任务失败');
|
||||
} finally {
|
||||
setCancellingJobId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (image: AgentImage | null) => {
|
||||
if (!image) return;
|
||||
const link = document.createElement('a');
|
||||
@@ -390,6 +652,59 @@ export default function PictureAgent() {
|
||||
setError('');
|
||||
};
|
||||
|
||||
const renderJobCard = (job: PictureJob) => {
|
||||
const total = Math.max(1, Number(job.progressTotal || 4));
|
||||
const progress = Math.min(total, Math.max(0, Number(job.progress || 0)));
|
||||
const percent = Math.round((progress / total) * 100);
|
||||
const hint = staleRunningHint(job);
|
||||
const isActive = job.status === 'pending' || job.status === 'running';
|
||||
const isFinished = !isActive;
|
||||
const statusText = job.status === 'succeeded'
|
||||
? '完成'
|
||||
: job.status === 'failed'
|
||||
? '失败'
|
||||
: job.status === 'cancelled'
|
||||
? '已取消'
|
||||
: `${percent}%`;
|
||||
return (
|
||||
<div key={job.id} className={`picture-agent-job picture-agent-job-${job.status}`}>
|
||||
<div className="picture-agent-job-row picture-agent-job-row-top">
|
||||
<div className="picture-agent-job-title-wrap">
|
||||
<strong>{job.result?.prompt || job.message || '生图任务'}</strong>
|
||||
<span>{job.model || job.result?.model || '未知模型'}</span>
|
||||
</div>
|
||||
<span className={`picture-agent-job-status picture-agent-job-status-${job.status}`}>{statusText}</span>
|
||||
</div>
|
||||
<div className="picture-agent-job-progress-line">
|
||||
<div className="picture-agent-job-progress-labels">
|
||||
<span>{job.message || job.id}</span>
|
||||
<em>{percent}%</em>
|
||||
</div>
|
||||
<div className="picture-agent-job-progress">
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="picture-agent-job-meta">
|
||||
<span>{formatLocalTime(job.createdAt)}</span>
|
||||
{job.error ? <em>{job.error}</em> : <span>{isFinished ? '已结束' : '进行中'}</span>}
|
||||
</div>
|
||||
{hint ? <div className="picture-agent-job-hint">{hint}</div> : null}
|
||||
{isActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="picture-agent-job-cancel"
|
||||
disabled={cancellingJobId === job.id}
|
||||
onClick={() => void cancelJob(job.id)}
|
||||
>
|
||||
{cancellingJobId === job.id ? '取消中...' : '取消任务'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="picture-agent-job-terminal">{job.result?.images?.length ? `结果 ${job.result.images.length} 张` : '可在历史结果中查看'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (bootError) {
|
||||
return (
|
||||
<div className="empty-state" style={{ minHeight: 320 }}>
|
||||
@@ -539,8 +854,18 @@ export default function PictureAgent() {
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{loading ? '处理中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
</button>
|
||||
|
||||
<div className="picture-agent-job-summary">
|
||||
<div>
|
||||
<strong>生图进度</strong>
|
||||
<span>{latestJobLabel} · {jobs.length} 条记录</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(true)}>
|
||||
查看进度
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="picture-agent-canvas">
|
||||
@@ -581,7 +906,7 @@ export default function PictureAgent() {
|
||||
<div className="picture-agent-board-meta">
|
||||
<div>
|
||||
<strong>{resultLabel}</strong>
|
||||
<span>{selectedImage.model} · {new Date(selectedImage.createdAt).toLocaleTimeString()}</span>
|
||||
<span>{selectedImage.model} · {formatLocalTime(selectedImage.createdAt)}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => handleDownload(selectedImage)}>下载</button>
|
||||
</div>
|
||||
@@ -618,6 +943,58 @@ export default function PictureAgent() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{jobDialogOpen && (
|
||||
<div
|
||||
className="picture-agent-job-dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setJobDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="picture-agent-job-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="picture-agent-job-dialog-title"
|
||||
>
|
||||
<div className="picture-agent-job-dialog-head">
|
||||
<div>
|
||||
<strong id="picture-agent-job-dialog-title">生图进度</strong>
|
||||
<span>{latestJobLabel} · {jobs.length} 条记录</span>
|
||||
</div>
|
||||
<div className="picture-agent-job-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
refreshJobs().catch((err: any) => setError(err?.message || '刷新任务失败'));
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void clearJobs()}
|
||||
disabled={clearingJobs || terminalJobs.length === 0}
|
||||
>
|
||||
{clearingJobs ? '清理中...' : '清空日志'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="picture-agent-job-empty">提交后会先进入队列,完成后会自动出现在这里和使用记录里。</div>
|
||||
) : (
|
||||
<div className="picture-agent-job-list picture-agent-job-list-dialog">
|
||||
{jobs.map(renderJobCard)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface LogItem {
|
||||
status: string | null;
|
||||
http_status: number | null;
|
||||
latency_ms: number | null;
|
||||
first_byte_latency_ms?: number | null;
|
||||
prompt_tokens: number | null;
|
||||
completion_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
@@ -42,6 +43,23 @@ function latencyColor(ms: number | null): string {
|
||||
return 'var(--color-danger)';
|
||||
}
|
||||
|
||||
function displayLatency(log: LogItem): string {
|
||||
const firstByte = log.first_byte_latency_ms;
|
||||
const total = log.latency_ms;
|
||||
if (firstByte != null && Number.isFinite(firstByte)) {
|
||||
return `${firstByte}ms / ${total != null ? `${total}ms` : '-'}`;
|
||||
}
|
||||
return total != null ? `${total}ms` : '-';
|
||||
}
|
||||
|
||||
function latencyTextColor(log: LogItem): string {
|
||||
const firstByte = log.first_byte_latency_ms;
|
||||
if (firstByte != null && Number.isFinite(firstByte)) {
|
||||
return latencyColor(firstByte);
|
||||
}
|
||||
return latencyColor(log.latency_ms);
|
||||
}
|
||||
|
||||
export default function UsageRecords() {
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
@@ -181,6 +199,9 @@ export default function UsageRecords() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
延迟优先显示首字延迟,Tokens 与费用依赖上游返回的 usage 数据。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
@@ -236,8 +257,8 @@ export default function UsageRecords() {
|
||||
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
|
||||
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
|
||||
<td style={{ fontSize: 13, color: latencyTextColor(log), fontFamily: 'var(--font-mono)' }}>
|
||||
{displayLatency(log)}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
|
||||
|
||||
@@ -157,9 +157,9 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
<section className="card" style={{ padding: '20px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<Link to="/user/video-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
<span>视频工作台</span>
|
||||
<Link to="/user/workflows" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg>
|
||||
<span>工作流</span>
|
||||
</Link>
|
||||
<Link to="/user/comfyui-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import LoginShell from './auth/LoginShell.js';
|
||||
|
||||
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -46,95 +47,90 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
|
||||
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
|
||||
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">用户注册</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<h2 className="login-auth-title">创建账号</h2>
|
||||
<p className="login-auth-copy">注册后即可使用 API 代理服务。</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
||||
<input
|
||||
id="reg-username-input"
|
||||
type="text"
|
||||
placeholder="你的昵称"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="username"
|
||||
required
|
||||
<LoginShell
|
||||
t={(text) => text}
|
||||
variant="user"
|
||||
eyebrow="用户注册"
|
||||
title="创建账号"
|
||||
description="注册后即可使用 API 代理服务。"
|
||||
error={error}
|
||||
footer={(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
<span>
|
||||
已有账号?
|
||||
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}>
|
||||
登录
|
||||
</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="login-register-form">
|
||||
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
||||
<input
|
||||
id="reg-username-input"
|
||||
type="text"
|
||||
placeholder="你的昵称"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
|
||||
<input
|
||||
id="reg-email-input"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
|
||||
<input
|
||||
id="reg-password-input"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password-input"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-email-input" style={{ marginTop: 12 }}>邮箱</label>
|
||||
<input
|
||||
id="reg-email-input"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-password-input" style={{ marginTop: 12 }}>密码</label>
|
||||
<input
|
||||
id="reg-password-input"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-confirm-password-input" style={{ marginTop: 12 }}>确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password-input"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 注册中...</> : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
|
||||
<span>
|
||||
已有账号?<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>登录</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
'注册'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Workflows() {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const openFullscreen = () => {
|
||||
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const reloadFrame = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = '/workflow-ui/';
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void fetch('/workflow-ui/', { signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setReady(response.ok);
|
||||
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled || error?.name === 'AbortError') return;
|
||||
setReady(false);
|
||||
setMessage('Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={toolbarStyle}>
|
||||
<div style={toolbarTitleStyle}>工作流</div>
|
||||
<div style={toolbarActionsStyle}>
|
||||
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
|
||||
新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ready ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/workflow-ui/"
|
||||
title="Workflow Studio"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
|
||||
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const toolbarTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const toolbarActionsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const toolbarButtonStyle: React.CSSProperties = {
|
||||
minHeight: 30,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '5px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const primaryToolbarButtonStyle: React.CSSProperties = {
|
||||
...toolbarButtonStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
};
|
||||
@@ -2,13 +2,14 @@ import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'all', label: '全部来源' },
|
||||
{ value: 'picture-agent', label: '生图助手' },
|
||||
{ value: 'comfyui-agent', label: '视频助手' },
|
||||
{ value: 'video-agent', label: '视频工作台' },
|
||||
{ value: 'comfyui', label: 'ComfyUI' },
|
||||
{ value: 'workflow-studio', label: '工作流' },
|
||||
{ value: 'comfyui', label: '外部 ComfyUI' },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
@@ -46,6 +47,10 @@ function assetTitle(asset: WorkspaceAsset) {
|
||||
return asset.title || asset.prompt || asset.fileName || asset.publicId;
|
||||
}
|
||||
|
||||
function assetCreatedAtLabel(asset: WorkspaceAsset) {
|
||||
return formatDateTimeLocal(asset.createdAt);
|
||||
}
|
||||
|
||||
export default function Workspace() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
@@ -167,7 +172,7 @@ export default function Workspace() {
|
||||
<div>
|
||||
<h1 className="page-title">工作空间</h1>
|
||||
<p className="page-subtitle">
|
||||
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手和 ComfyUI 产出
|
||||
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
@@ -235,7 +240,7 @@ export default function Workspace() {
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
|
||||
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
|
||||
<div className="empty-state-desc">生图助手、视频助手和 ComfyUI 的产出会归档到这里。</div>
|
||||
<div className="empty-state-desc">生图助手、视频助手、工作流和外部 ComfyUI 的产出会归档到这里。</div>
|
||||
</div>
|
||||
) : items.map((asset) => {
|
||||
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||
@@ -267,8 +272,8 @@ export default function Workspace() {
|
||||
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
||||
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
|
||||
<div style={cardFooterStyle}>
|
||||
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
|
||||
<span style={metaStyle}>{asset.createdAt || ''}</span>
|
||||
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
|
||||
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -303,6 +308,7 @@ export default function Workspace() {
|
||||
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
||||
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
|
||||
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
|
||||
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
|
||||
</div>
|
||||
{selected.prompt && (
|
||||
<div style={promptPanelStyle}>
|
||||
|
||||
@@ -5,16 +5,18 @@ import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import { installAccountsSnapshotCompat } from './testApiCompat.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getAccountsSnapshot: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
addAccount: vi.fn(),
|
||||
addAccountAvailableModels: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getAccountsSnapshot: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
addAccount: vi.fn(),
|
||||
addAccountAvailableModels: vi.fn(),
|
||||
checkModels: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
@@ -44,6 +46,15 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
apiMock.checkModels.mockResolvedValue({
|
||||
message: '模型刷新成功',
|
||||
refresh: {
|
||||
status: 'success',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5'],
|
||||
},
|
||||
});
|
||||
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
|
||||
apiMock.addAccount.mockResolvedValue({
|
||||
id: 18,
|
||||
siteId: 10,
|
||||
@@ -73,7 +84,7 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('添加 API Key 连接');
|
||||
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
|
||||
expect(rendered).toContain('阿里云 Coding Plan (OpenAI)');
|
||||
expect(rendered).toContain('qwen3-coder-plus');
|
||||
|
||||
const checkedBoxes = root.root.findAll((node) => (
|
||||
@@ -110,8 +121,10 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
}));
|
||||
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
|
||||
18,
|
||||
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
|
||||
['qwen3-coder-plus', 'qwen3.5-plus'],
|
||||
);
|
||||
expect(apiMock.checkModels).not.toHaveBeenCalled();
|
||||
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
@@ -127,6 +140,14 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
tokenType: 'apikey',
|
||||
queued: false,
|
||||
});
|
||||
apiMock.checkModels.mockResolvedValue({
|
||||
message: '模型刷新成功',
|
||||
refresh: {
|
||||
status: 'success',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
@@ -142,7 +163,7 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('DeepSeek / OpenAI');
|
||||
expect(rendered).toContain('DeepSeek (OpenAI)');
|
||||
expect(rendered).toContain('deepseek-chat');
|
||||
expect(rendered).toContain('deepseek-reasoner');
|
||||
|
||||
@@ -175,6 +196,8 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
19,
|
||||
['deepseek-chat', 'deepseek-reasoner'],
|
||||
);
|
||||
expect(apiMock.checkModels).not.toHaveBeenCalled();
|
||||
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -31,14 +31,20 @@ async function readErrorReason(res: Response): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
type AdminLoginMode = 'token' | 'password';
|
||||
|
||||
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
|
||||
const [mode, setMode] = useState<AdminLoginMode>('token');
|
||||
const [token, setToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loadingMode, setLoadingMode] = useState<AdminLoginMode | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleTokenLogin = async () => {
|
||||
const handleTokenLogin = async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!token.trim()) return;
|
||||
setLoading(true);
|
||||
setLoadingMode('token');
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/settings/auth/info', {
|
||||
@@ -54,58 +60,198 @@ export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !!token.trim();
|
||||
const handlePasswordLogin = async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!email.trim() || !password) return;
|
||||
setLoadingMode('password');
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: {
|
||||
token?: unknown;
|
||||
user?: { role?: unknown };
|
||||
data?: { token?: unknown; user?: { role?: unknown } };
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
} = {};
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = { message: text };
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const reason =
|
||||
typeof parsed.message === 'string'
|
||||
? parsed.message
|
||||
: typeof parsed.error === 'string'
|
||||
? parsed.error
|
||||
: text;
|
||||
setError(t(resolveLoginErrorMessage(res.status, reason)));
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = typeof parsed.token === 'string' ? parsed.token : parsed.data?.token;
|
||||
const role =
|
||||
typeof parsed.user?.role === 'string' ? parsed.user.role : parsed.data?.user?.role;
|
||||
if (typeof jwt !== 'string' || !jwt) {
|
||||
setError(t('登录响应缺少访问令牌。'));
|
||||
return;
|
||||
}
|
||||
if (role !== 'admin') {
|
||||
setError(t('该账号不是管理员账号。'));
|
||||
return;
|
||||
}
|
||||
commitSession('admin', jwt);
|
||||
onLogin(jwt);
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoadingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = loadingMode !== null;
|
||||
const canTokenSubmit = !!token.trim();
|
||||
const canPasswordSubmit = !!email.trim() && !!password;
|
||||
|
||||
return (
|
||||
<LoginShell
|
||||
t={t}
|
||||
variant="admin"
|
||||
eyebrow={t('管理员入口')}
|
||||
title={t('登录')}
|
||||
description={t('请输入管理员令牌后继续。')}
|
||||
title={t('管理员登录')}
|
||||
description={t('使用管理员令牌,或管理员账号密码进入控制台。')}
|
||||
error={error}
|
||||
footer={<span>{t('管理员登录后继续。')}</span>}
|
||||
footer={<span>{t('管理员身份用于管理站点、模型路由、用户与计费。')}</span>}
|
||||
>
|
||||
<label className="login-auth-label" htmlFor="admin-token-input">
|
||||
{t('管理员令牌')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-token-input"
|
||||
type="password"
|
||||
placeholder={t('管理员令牌')}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
onClick={handleTokenLogin}
|
||||
disabled={loading || !canSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('登录')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
|
||||
<div className="login-mode-tabs" role="tablist" aria-label={t('管理员登录方式')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-mode-tab${mode === 'token' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('token');
|
||||
setError('');
|
||||
}}
|
||||
aria-selected={mode === 'token'}
|
||||
>
|
||||
Auth Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-mode-tab${mode === 'password' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('password');
|
||||
setError('');
|
||||
}}
|
||||
aria-selected={mode === 'password'}
|
||||
>
|
||||
{t('账号密码')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'token' ? (
|
||||
<form onSubmit={handleTokenLogin}>
|
||||
<label className="login-auth-label" htmlFor="admin-token-input">
|
||||
{t('管理员令牌')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-token-input"
|
||||
type="password"
|
||||
placeholder={t('管理员令牌')}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !canTokenSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loadingMode === 'token' ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('进入控制台')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin}>
|
||||
<label className="login-auth-label" htmlFor="admin-email-input">
|
||||
{t('管理员邮箱')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-email-input"
|
||||
type="email"
|
||||
placeholder={t('管理员邮箱')}
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="admin-password-input">
|
||||
{t('密码')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-password-input"
|
||||
type="password"
|
||||
placeholder={t('密码')}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !canPasswordSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loadingMode === 'password' ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('进入控制台')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('只有 role=admin 的账号可以进入管理员控制台。')}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
|
||||
|
||||
export type LoginShellVariant = 'admin' | 'user';
|
||||
|
||||
@@ -82,31 +81,6 @@ export default function LoginShell({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="login-brand-footer">
|
||||
<a
|
||||
href={SITE_GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-icon-link"
|
||||
aria-label="GitHub"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={SITE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-doc-link"
|
||||
>
|
||||
{t('部署文档')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Models from './Models.js';
|
||||
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({ api: apiMock }));
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Models copy action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'doubao-seedance-2-0-260128',
|
||||
accountCount: 1,
|
||||
tokenCount: 1,
|
||||
avgLatency: null,
|
||||
successRate: null,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('copies the model name from the action button', async () => {
|
||||
let root!: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const copyBtn = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '复制模型名'
|
||||
));
|
||||
await act(async () => {
|
||||
await copyBtn.props.onClick();
|
||||
});
|
||||
|
||||
expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith('doubao-seedance-2-0-260128');
|
||||
expect(toastMock.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Models from './Models.js';
|
||||
|
||||
const { apiMock, userApiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
userApiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
userApi: userApiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Models user view', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userApiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
accountCount: 1,
|
||||
tokenCount: 1,
|
||||
avgLatency: null,
|
||||
successRate: null,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: 'Site A',
|
||||
username: 'tester',
|
||||
latency: null,
|
||||
balance: 1,
|
||||
tokens: [{ id: 1, name: 'default', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('hides vendor and account details for user path', async () => {
|
||||
let root!: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('模型广场');
|
||||
expect(text).not.toContain('供应商');
|
||||
expect(text).not.toContain('排序方式');
|
||||
expect(text).not.toContain('账号数');
|
||||
expect(text).not.toContain('令牌数');
|
||||
expect(text).not.toContain('Site A');
|
||||
expect(text).not.toContain('tester');
|
||||
expect(userApiMock.getModelsMarketplace).toHaveBeenCalled();
|
||||
expect(apiMock.getModelsMarketplace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,925 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::workspace,
|
||||
workflow::{
|
||||
graph,
|
||||
models::{NodeExecution, NodeOutputStore, RunContext, WorkflowGraph, WorkflowNode},
|
||||
storage,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkflowProgressEvent {
|
||||
Started {
|
||||
prompt_id: String,
|
||||
total: usize,
|
||||
},
|
||||
NodeStarted {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
},
|
||||
NodeCompleted {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
output: Value,
|
||||
},
|
||||
Finished {
|
||||
prompt_id: String,
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub type WorkflowProgressCallback = Arc<dyn Fn(WorkflowProgressEvent) + Send + Sync>;
|
||||
|
||||
pub async fn run_workflow(
|
||||
state: &AppState,
|
||||
workflow_id: i64,
|
||||
graph_value: Value,
|
||||
input_overrides: Value,
|
||||
ctx: RunContext,
|
||||
) -> AppResult<String> {
|
||||
run_workflow_with_progress(state, workflow_id, graph_value, input_overrides, ctx, None).await
|
||||
}
|
||||
|
||||
pub async fn run_workflow_with_progress(
|
||||
state: &AppState,
|
||||
workflow_id: i64,
|
||||
graph_value: Value,
|
||||
input_overrides: Value,
|
||||
ctx: RunContext,
|
||||
progress: Option<WorkflowProgressCallback>,
|
||||
) -> AppResult<String> {
|
||||
let graph: WorkflowGraph = serde_json::from_value(graph_value.clone())
|
||||
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
||||
let order = graph::validate_and_sort(&graph)?;
|
||||
let total = order.len().max(1);
|
||||
let (run_db_id, run_public_id) =
|
||||
storage::insert_run(state, workflow_id, ctx.user_id, &input_overrides).await?;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Started {
|
||||
prompt_id: run_public_id.clone(),
|
||||
total,
|
||||
},
|
||||
);
|
||||
let nodes = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| (node.id.clone(), node.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut store = NodeOutputStore::new();
|
||||
|
||||
for (index, node_id) in order.into_iter().enumerate() {
|
||||
let Some(node) = nodes.get(&node_id) else {
|
||||
continue;
|
||||
};
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::NodeStarted {
|
||||
prompt_id: run_public_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
index,
|
||||
total,
|
||||
},
|
||||
);
|
||||
let inputs = collect_inputs(&graph, &store, node, &input_overrides)?;
|
||||
let node_run_id =
|
||||
storage::insert_node_run_started(state, run_db_id, &node.id, &node.node_type, &inputs)
|
||||
.await?;
|
||||
match execute_node(state, &ctx, node, inputs.clone()).await {
|
||||
Ok(result) => {
|
||||
storage::complete_node_run(state, node_run_id, &result.outputs).await?;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::NodeCompleted {
|
||||
prompt_id: run_public_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
index: index + 1,
|
||||
total,
|
||||
output: result.outputs.clone(),
|
||||
},
|
||||
);
|
||||
store.insert(node.id.clone(), result.outputs);
|
||||
}
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let _ = storage::fail_node_run(state, node_run_id, &message).await;
|
||||
let _ = storage::fail_run(state, run_db_id, &message).await;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Finished {
|
||||
prompt_id: run_public_id.clone(),
|
||||
success: false,
|
||||
error: Some(message),
|
||||
},
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let final_outputs = json!({ "nodes": store });
|
||||
storage::complete_run(state, run_db_id, &final_outputs).await?;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Finished {
|
||||
prompt_id: run_public_id.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
Ok(run_public_id)
|
||||
}
|
||||
|
||||
fn emit_progress(progress: &Option<WorkflowProgressCallback>, event: WorkflowProgressEvent) {
|
||||
if let Some(callback) = progress {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_inputs(
|
||||
graph: &WorkflowGraph,
|
||||
store: &NodeOutputStore,
|
||||
node: &WorkflowNode,
|
||||
input_overrides: &Value,
|
||||
) -> AppResult<Value> {
|
||||
let mut inputs = Map::new();
|
||||
for edge in &graph.edges {
|
||||
if graph::endpoint_node(&edge.to)? != node.id {
|
||||
continue;
|
||||
}
|
||||
let from_node = graph::endpoint_node(&edge.from)?;
|
||||
let from_port = graph::endpoint_port(&edge.from)?;
|
||||
let to_port = graph::endpoint_port(&edge.to)?;
|
||||
if let Some(value) = store
|
||||
.get(from_node)
|
||||
.and_then(|outputs| outputs.get(from_port))
|
||||
{
|
||||
inputs.insert(to_port.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(overrides) = input_overrides.as_object() {
|
||||
for (key, value) in overrides {
|
||||
if let Some((override_node, port)) = key.split_once('.') {
|
||||
if override_node == node.id {
|
||||
inputs.insert(port.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(inputs))
|
||||
}
|
||||
|
||||
async fn execute_node(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
match node.node_type.as_str() {
|
||||
"input.text" => {
|
||||
let text = inputs
|
||||
.get("value")
|
||||
.or_else(|| node.params.get("value"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "text": text }),
|
||||
})
|
||||
}
|
||||
"boosapi.chat_completion" => chat_completion(state, ctx, node, inputs, false).await,
|
||||
"boosapi.comfyui_workflow" => comfyui_workflow(ctx, 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.text_to_image" => text_to_image(state, ctx, node, inputs).await,
|
||||
"boosapi.text_to_video" => text_to_video(ctx, node, inputs).await,
|
||||
"boosapi.image_to_video" => image_to_video(ctx, node, inputs).await,
|
||||
"boosapi.video_status" => video_status(ctx, node, inputs).await,
|
||||
"CheckpointLoaderSimple" => {
|
||||
let model = param_str(node, "ckpt_name", "gpt-image-2");
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"MODEL": { "model": model },
|
||||
"CLIP": { "model": model },
|
||||
"VAE": { "model": model }
|
||||
}),
|
||||
})
|
||||
}
|
||||
"CLIPTextEncode" => {
|
||||
let text = param_str(node, "text", "");
|
||||
let model = inputs
|
||||
.pointer("/clip/model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gpt-image-2")
|
||||
.to_string();
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "CONDITIONING": { "text": text, "model": model } }),
|
||||
})
|
||||
}
|
||||
"EmptyLatentImage" => Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"LATENT": {
|
||||
"width": param_i64(node, "width", 1024),
|
||||
"height": param_i64(node, "height", 1024),
|
||||
"batch_size": param_i64(node, "batch_size", 1)
|
||||
}
|
||||
}),
|
||||
}),
|
||||
"KSampler" => {
|
||||
let model = inputs
|
||||
.pointer("/model/model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| inputs.pointer("/positive/model").and_then(Value::as_str))
|
||||
.unwrap_or("gpt-image-2")
|
||||
.to_string();
|
||||
let prompt = inputs
|
||||
.pointer("/positive/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let negative_prompt = inputs
|
||||
.pointer("/negative/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let width = inputs
|
||||
.pointer("/latent_image/width")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
let height = inputs
|
||||
.pointer("/latent_image/height")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"LATENT": {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"negative_prompt": negative_prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"steps": param_i64(node, "steps", 20),
|
||||
"cfg": param_number(node, "cfg", 8.0),
|
||||
"seed": param_i64(node, "seed", 0)
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
"VAEDecode" => vae_decode_to_image(state, ctx, inputs).await,
|
||||
"SaveImage" | "PreviewImage" => {
|
||||
let image = inputs
|
||||
.get("images")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("image").cloned())
|
||||
.or_else(|| inputs.get("IMAGE").cloned())
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("{} missing images input", node.id))
|
||||
})?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "IMAGE": image, "image": image }),
|
||||
})
|
||||
}
|
||||
"output.preview_image" => {
|
||||
let image = inputs
|
||||
.get("image")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("images").cloned())
|
||||
.or_else(|| inputs.get("IMAGE").cloned())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing image input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "image": image }),
|
||||
})
|
||||
}
|
||||
"output.preview_video" => {
|
||||
let video = inputs
|
||||
.get("video")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("videos").cloned())
|
||||
.or_else(|| inputs.get("VIDEO").cloned())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing video input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "video": video }),
|
||||
})
|
||||
}
|
||||
"output.save_file" => {
|
||||
let file = inputs
|
||||
.get("file")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("image").cloned())
|
||||
.or_else(|| inputs.get("images").and_then(|images| images.get(0)).cloned())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "file": file }),
|
||||
})
|
||||
}
|
||||
"util.text_join" => {
|
||||
let prefix = param_str(node, "prefix", "");
|
||||
let suffix = param_str(node, "suffix", "");
|
||||
let separator = param_str(node, "separator", "\n");
|
||||
let text = value_to_string(inputs.get("text"));
|
||||
let joined = [prefix, text, suffix]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(&separator);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "text": joined }),
|
||||
})
|
||||
}
|
||||
"util.json_extract" => {
|
||||
let mut current = inputs.get("json").cloned().unwrap_or(Value::Null);
|
||||
for segment in param_str(node, "path", "")
|
||||
.split('.')
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
current = if let Ok(index) = segment.parse::<usize>() {
|
||||
current.get(index).cloned().unwrap_or(Value::Null)
|
||||
} else {
|
||||
current.get(segment).cloned().unwrap_or(Value::Null)
|
||||
};
|
||||
}
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "value": current, "text": value_to_string(Some(¤t)) }),
|
||||
})
|
||||
}
|
||||
_ => Err(AppError::BadRequest(format!(
|
||||
"unsupported node type: {}",
|
||||
node.node_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn comfyui_workflow(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let text = value_to_string(inputs.get("text"));
|
||||
if text.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing text input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let system = param_str(
|
||||
node,
|
||||
"system",
|
||||
"Generate a valid ComfyUI workflow JSON object. Return JSON only.",
|
||||
);
|
||||
let body = json!({
|
||||
"model": param_str(node, "model", "gpt-5.4"),
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": text }
|
||||
],
|
||||
"stream": false,
|
||||
"temperature": param_number(node, "temperature", 0.3)
|
||||
});
|
||||
let raw = post_json(ctx, "/chat/completions", &body).await?;
|
||||
let content = raw
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let workflow = parse_jsonish(&content).unwrap_or_else(|| {
|
||||
json!({
|
||||
"boosapi": {
|
||||
"kind": "comfyui-workflow-draft",
|
||||
"prompt": text,
|
||||
"raw": content
|
||||
},
|
||||
"nodes": {}
|
||||
})
|
||||
});
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "workflow": workflow, "text": content, "raw": raw }),
|
||||
})
|
||||
}
|
||||
|
||||
async fn submit_comfyui_workflow(
|
||||
state: &AppState,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let workflow = inputs
|
||||
.get("workflow")
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing workflow input", node.id)))?;
|
||||
if param_bool(node, "dry_run", true) {
|
||||
return Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"prompt_id": null,
|
||||
"response": { "dryRun": true },
|
||||
"workflow": workflow,
|
||||
}),
|
||||
});
|
||||
}
|
||||
let body = json!({
|
||||
"client_id": param_str(node, "client_id", "boosapi-workflow-studio"),
|
||||
"prompt": workflow,
|
||||
});
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{}/api/comfyui/prompt", ctx_server_base(state)))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(value.to_string()));
|
||||
}
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"prompt_id": value.get("prompt_id").and_then(Value::as_str),
|
||||
"response": value,
|
||||
"workflow": body.get("prompt").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat_completion(
|
||||
_state: &AppState,
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
rewrite: bool,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let text = value_to_string(inputs.get("text"));
|
||||
if text.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing text input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let model = param_str(node, "model", "gpt-5.4");
|
||||
let system = if rewrite {
|
||||
format!(
|
||||
"Rewrite the user input as a concise, high quality prompt. Style requirement: {}. Return only the rewritten prompt.",
|
||||
param_str(node, "style", "cinematic English image prompt")
|
||||
)
|
||||
} else {
|
||||
param_str(node, "system", "")
|
||||
};
|
||||
let mut messages = Vec::new();
|
||||
if !system.trim().is_empty() {
|
||||
messages.push(json!({ "role": "system", "content": system }));
|
||||
}
|
||||
messages.push(json!({ "role": "user", "content": text }));
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
"temperature": param_number(node, "temperature", 0.7)
|
||||
});
|
||||
let raw = post_json(ctx, "/chat/completions", &body).await?;
|
||||
let content = raw
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let outputs = if rewrite {
|
||||
json!({ "prompt": content, "raw": raw })
|
||||
} else {
|
||||
json!({ "text": content, "raw": raw })
|
||||
};
|
||||
Ok(NodeExecution { inputs, outputs })
|
||||
}
|
||||
|
||||
async fn text_to_image(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing prompt input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let width = param_i64(node, "width", 1024);
|
||||
let height = param_i64(node, "height", 1024);
|
||||
let size = param_str(node, "size", "");
|
||||
let body = json!({
|
||||
"model": param_str(node, "model", "gpt-image-2"),
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": if size.trim().is_empty() { format!("{width}x{height}") } else { size },
|
||||
});
|
||||
execute_image_generation(state, ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn text_to_video(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing prompt input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let body = video_request_body(node, prompt, None);
|
||||
execute_video_generation(ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn image_to_video(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing prompt input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let image_url = param_str(node, "image_url", "");
|
||||
let image_url = if image_url.trim().is_empty() {
|
||||
extract_media_url(inputs.get("image"))
|
||||
} else {
|
||||
Some(image_url)
|
||||
};
|
||||
let body = video_request_body(node, prompt, image_url);
|
||||
execute_video_generation(ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn video_status(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let task_id = value_to_string(inputs.get("task_id"));
|
||||
if task_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing task_id input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let model = param_str(node, "model", "seedance-1-0-lite-t2v");
|
||||
let path = format!(
|
||||
"/videos/{}?model={}",
|
||||
simple_urlencode(task_id.trim()),
|
||||
simple_urlencode(model.trim())
|
||||
);
|
||||
let raw = get_json(ctx, &path).await?;
|
||||
let video = video_output_from_response(&raw);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "video": video, "raw": raw }),
|
||||
})
|
||||
}
|
||||
|
||||
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
|
||||
let mut body = Map::new();
|
||||
body.insert("model".to_string(), json!(param_str(node, "model", "seedance-1-0-lite-t2v")));
|
||||
body.insert("prompt".to_string(), json!(prompt));
|
||||
for key in ["ratio", "resolution"] {
|
||||
let value = param_str(node, key, "");
|
||||
if !value.trim().is_empty() {
|
||||
body.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
for key in ["duration", "fps"] {
|
||||
if let Some(value) = node.params.get(key).and_then(Value::as_i64) {
|
||||
body.insert(key.to_string(), json!(value));
|
||||
} else if let Some(value) = node.params.get(key).and_then(Value::as_f64) {
|
||||
body.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
if let Some(url) = image_url.filter(|value| !value.trim().is_empty()) {
|
||||
body.insert("image_url".to_string(), json!(url));
|
||||
}
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
async fn execute_video_generation(
|
||||
ctx: &RunContext,
|
||||
inputs: Value,
|
||||
body: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let raw = post_json(ctx, "/videos", &body).await?;
|
||||
let task_id = extract_task_id(&raw);
|
||||
let video = video_output_from_response(&raw);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"task_id": task_id,
|
||||
"video": video,
|
||||
"raw": raw
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn vae_decode_to_image(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let samples = inputs.get("samples").cloned().unwrap_or(Value::Null);
|
||||
let vae = inputs.get("vae").cloned().unwrap_or(Value::Null);
|
||||
let prompt = samples
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"VAEDecode missing positive prompt from KSampler".into(),
|
||||
));
|
||||
}
|
||||
let model = samples
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| vae.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("gpt-image-2");
|
||||
let width = samples
|
||||
.get("width")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
let height = samples
|
||||
.get("height")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
let mut prompt_text = prompt;
|
||||
if let Some(negative) = samples.get("negative_prompt").and_then(Value::as_str) {
|
||||
if !negative.trim().is_empty() {
|
||||
prompt_text.push_str("\nNegative prompt: ");
|
||||
prompt_text.push_str(negative.trim());
|
||||
}
|
||||
}
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"prompt": prompt_text,
|
||||
"n": 1,
|
||||
"size": format!("{width}x{height}"),
|
||||
});
|
||||
execute_image_generation(state, ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn execute_image_generation(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
inputs: Value,
|
||||
body: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let raw = post_json(ctx, "/images/generations", &body).await?;
|
||||
let assets = workspace::record_image_assets_from_response(
|
||||
state,
|
||||
ctx.user_id,
|
||||
"workflow-studio",
|
||||
body.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
body.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
&raw,
|
||||
)
|
||||
.await;
|
||||
let images = raw.get("data").cloned().unwrap_or_else(|| json!([]));
|
||||
let first = images.get(0).cloned().unwrap_or(Value::Null);
|
||||
let comfy_images = assets
|
||||
.iter()
|
||||
.map(|asset| {
|
||||
json!({
|
||||
"filename": asset.file_name.as_deref().unwrap_or(&asset.public_id),
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"image": first,
|
||||
"images": images,
|
||||
"comfy_images": comfy_images,
|
||||
"raw": raw
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_json(ctx: &RunContext, path: &str, body: &Value) -> AppResult<Value> {
|
||||
let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
|
||||
let response = reqwest::Client::new()
|
||||
.post(url)
|
||||
.bearer_auth(&ctx.bearer)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let json = serde_json::from_str::<Value>(&text).map_err(|err| {
|
||||
AppError::UpstreamError(format!(
|
||||
"invalid JSON from BoosAPI workflow node: {err}; body: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(json.to_string()));
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
async fn get_json(ctx: &RunContext, path: &str) -> AppResult<Value> {
|
||||
let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
|
||||
let response = reqwest::Client::new()
|
||||
.get(url)
|
||||
.bearer_auth(&ctx.bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let json = serde_json::from_str::<Value>(&text).map_err(|err| {
|
||||
AppError::UpstreamError(format!(
|
||||
"invalid JSON from BoosAPI workflow node: {err}; body: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(json.to_string()));
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
fn extract_task_id(value: &Value) -> Value {
|
||||
for pointer in ["/id", "/task_id", "/data/id", "/data/task_id", "/result/id", "/result/task_id"] {
|
||||
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
|
||||
if !id.trim().is_empty() {
|
||||
return json!(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn video_output_from_response(value: &Value) -> Value {
|
||||
if let Some(url) = find_url_by_extension(value, &[".mp4", ".webm", ".mov"])
|
||||
.or_else(|| find_url_key(value, &["video_url", "videoUrl", "url"]))
|
||||
{
|
||||
json!({ "url": url })
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?;
|
||||
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
||||
}
|
||||
|
||||
fn find_url_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for key in keys {
|
||||
if let Some(url) = map
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
|
||||
{
|
||||
return Some(url.to_string());
|
||||
}
|
||||
}
|
||||
map.values().find_map(|child| find_url_key(child, keys))
|
||||
}
|
||||
Value::Array(items) => items.iter().find_map(|child| find_url_key(child, keys)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_url_by_extension(value: &Value, extensions: &[&str]) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text)
|
||||
if (text.starts_with("http://") || text.starts_with("https://"))
|
||||
&& extensions.iter().any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
||||
{
|
||||
Some(text.clone())
|
||||
}
|
||||
Value::Object(map) => map
|
||||
.values()
|
||||
.find_map(|child| find_url_by_extension(child, extensions)),
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|child| find_url_by_extension(child, extensions)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_urlencode(value: &str) -> String {
|
||||
value
|
||||
.replace('%', "%25")
|
||||
.replace(' ', "%20")
|
||||
.replace('?', "%3F")
|
||||
.replace('&', "%26")
|
||||
.replace('#', "%23")
|
||||
.replace('/', "%2F")
|
||||
}
|
||||
|
||||
fn param_str(node: &WorkflowNode, key: &str, default: &str) -> String {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(default)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn param_i64(node: &WorkflowNode, key: &str, default: i64) -> i64 {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn param_number(node: &WorkflowNode, key: &str, default: f64) -> f64 {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn param_bool(node: &WorkflowNode, key: &str, default: bool) -> bool {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_jsonish(text: &str) -> Option<Value> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stripped = trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.unwrap_or(trimmed)
|
||||
.trim()
|
||||
.strip_suffix("```")
|
||||
.unwrap_or_else(|| {
|
||||
trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.unwrap_or(trimmed)
|
||||
.trim()
|
||||
})
|
||||
.trim();
|
||||
serde_json::from_str(stripped).ok()
|
||||
}
|
||||
|
||||
fn ctx_server_base(state: &AppState) -> String {
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
state.config.server.host.clone()
|
||||
};
|
||||
format!("http://{}:{}", host, state.config.server.port)
|
||||
}
|
||||
|
||||
fn value_to_string(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Null) | None => String::new(),
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
workflow::{models::WorkflowGraph, registry},
|
||||
};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
pub fn validate_and_sort(graph: &WorkflowGraph) -> AppResult<Vec<String>> {
|
||||
if graph.nodes.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"workflow must contain at least one node".into(),
|
||||
));
|
||||
}
|
||||
let mut indegree: HashMap<String, usize> = HashMap::new();
|
||||
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for node in &graph.nodes {
|
||||
let node_id = node.id.trim();
|
||||
if node_id.is_empty() {
|
||||
return Err(AppError::BadRequest("node id is required".into()));
|
||||
}
|
||||
if indegree.insert(node.id.clone(), 0).is_some() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"duplicate node id: {}",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
if !registry::is_known_node(&node.node_type) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"unknown node type: {}",
|
||||
node.node_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
for edge in &graph.edges {
|
||||
let from = endpoint_node(&edge.from)?;
|
||||
let to = endpoint_node(&edge.to)?;
|
||||
if !indegree.contains_key(from) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"edge references missing node: {from}"
|
||||
)));
|
||||
}
|
||||
if !indegree.contains_key(to) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"edge references missing node: {to}"
|
||||
)));
|
||||
}
|
||||
outgoing
|
||||
.entry(from.to_string())
|
||||
.or_default()
|
||||
.push(to.to_string());
|
||||
*indegree.entry(to.to_string()).or_default() += 1;
|
||||
}
|
||||
|
||||
let mut queue = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| indegree.get(&node.id).copied().unwrap_or(0) == 0)
|
||||
.map(|node| node.id.clone())
|
||||
.collect::<VecDeque<_>>();
|
||||
let mut order = Vec::with_capacity(graph.nodes.len());
|
||||
while let Some(node_id) = queue.pop_front() {
|
||||
order.push(node_id.clone());
|
||||
if let Some(next) = outgoing.get(&node_id) {
|
||||
for target in next {
|
||||
let entry = indegree.entry(target.clone()).or_default();
|
||||
*entry = entry.saturating_sub(1);
|
||||
if *entry == 0 {
|
||||
queue.push_back(target.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if order.len() != graph.nodes.len() {
|
||||
return Err(AppError::BadRequest(
|
||||
"workflow graph contains a cycle".into(),
|
||||
));
|
||||
}
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
pub fn endpoint_node(endpoint: &str) -> AppResult<&str> {
|
||||
endpoint
|
||||
.split_once('.')
|
||||
.map(|(node, _)| node)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("invalid endpoint: {endpoint}")))
|
||||
}
|
||||
|
||||
pub fn endpoint_port(endpoint: &str) -> AppResult<&str> {
|
||||
endpoint
|
||||
.split_once('.')
|
||||
.map(|(_, port)| port)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("invalid endpoint: {endpoint}")))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod executor;
|
||||
pub mod graph;
|
||||
pub mod models;
|
||||
pub mod registry;
|
||||
pub mod routes;
|
||||
pub mod storage;
|
||||
@@ -0,0 +1,155 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowGraph {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<WorkflowNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<WorkflowEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowNode {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
#[serde(default)]
|
||||
pub position: Value,
|
||||
#[serde(default)]
|
||||
pub params: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowSummary {
|
||||
pub id: i64,
|
||||
pub public_id: String,
|
||||
pub user_id: Option<i64>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub graph: Value,
|
||||
pub is_template: bool,
|
||||
pub visibility: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowRunSummary {
|
||||
pub id: i64,
|
||||
pub public_id: String,
|
||||
pub workflow_id: i64,
|
||||
pub workflow_public_id: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub status: String,
|
||||
pub input_overrides: Value,
|
||||
pub final_outputs: Option<Value>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub node_runs: Vec<WorkflowNodeRunSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowNodeRunSummary {
|
||||
pub id: i64,
|
||||
pub node_id: String,
|
||||
pub node_type: String,
|
||||
pub status: String,
|
||||
pub inputs: Value,
|
||||
pub outputs: Option<Value>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NodeTypeDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: &'static str,
|
||||
pub title: &'static str,
|
||||
pub category: &'static str,
|
||||
pub description: &'static str,
|
||||
pub inputs: Value,
|
||||
pub params: Value,
|
||||
pub outputs: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RunContext {
|
||||
pub user_id: Option<i64>,
|
||||
pub bearer: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeExecution {
|
||||
pub inputs: Value,
|
||||
pub outputs: Value,
|
||||
}
|
||||
|
||||
pub type NodeOutputStore = HashMap<String, Value>;
|
||||
|
||||
pub fn default_graph(name: &str) -> Value {
|
||||
json!({
|
||||
"version": default_version(),
|
||||
"name": name,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input_1",
|
||||
"type": "input.text",
|
||||
"position": { "x": 80, "y": 120 },
|
||||
"params": { "value": "生成一个电影感雨夜城市短镜头,包含一张 1024x1024 的起始画面" }
|
||||
},
|
||||
{
|
||||
"id": "workflow_1",
|
||||
"type": "boosapi.comfyui_workflow",
|
||||
"position": { "x": 360, "y": 120 },
|
||||
"params": {
|
||||
"model": "gpt-5.4",
|
||||
"system": "Generate a valid ComfyUI workflow JSON object. Return JSON only."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "submit_1",
|
||||
"type": "comfyui.submit_workflow",
|
||||
"position": { "x": 660, "y": 120 },
|
||||
"params": {
|
||||
"client_id": "boosapi-workflow-studio",
|
||||
"dry_run": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "save_1",
|
||||
"type": "output.save_file",
|
||||
"position": { "x": 960, "y": 120 },
|
||||
"params": {}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "input_1.text", "to": "workflow_1.text" },
|
||||
{ "from": "workflow_1.workflow", "to": "submit_1.workflow" },
|
||||
{ "from": "submit_1.workflow", "to": "save_1.file" }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
"1.0".to_string()
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use crate::workflow::models::NodeTypeDefinition;
|
||||
use serde_json::json;
|
||||
|
||||
fn core_node_types() -> Vec<NodeTypeDefinition> {
|
||||
vec![
|
||||
NodeTypeDefinition {
|
||||
node_type: "CheckpointLoaderSimple",
|
||||
title: "CheckpointLoaderSimple",
|
||||
category: "loaders",
|
||||
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"MODEL": { "type": "MODEL" },
|
||||
"CLIP": { "type": "CLIP" },
|
||||
"VAE": { "type": "VAE" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "CLIPTextEncode",
|
||||
title: "CLIPTextEncode",
|
||||
category: "conditioning",
|
||||
description: "Encodes text into conditioning.",
|
||||
inputs: json!({
|
||||
"clip": { "type": "CLIP", "required": false }
|
||||
}),
|
||||
params: json!({
|
||||
"text": { "ui": "textarea", "type": "STRING", "default": "" }
|
||||
}),
|
||||
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "EmptyLatentImage",
|
||||
title: "EmptyLatentImage",
|
||||
category: "latent",
|
||||
description: "Creates an empty latent image.",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
|
||||
}),
|
||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "KSampler",
|
||||
title: "KSampler",
|
||||
category: "sampling",
|
||||
description: "Samples latent images with a diffusion model.",
|
||||
inputs: json!({
|
||||
"model": { "type": "MODEL", "required": true },
|
||||
"positive": { "type": "CONDITIONING", "required": true },
|
||||
"negative": { "type": "CONDITIONING", "required": true },
|
||||
"latent_image": { "type": "LATENT", "required": true }
|
||||
}),
|
||||
params: json!({
|
||||
"seed": { "ui": "number", "type": "INT", "default": 0 },
|
||||
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
|
||||
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
|
||||
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
|
||||
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
|
||||
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
|
||||
}),
|
||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "VAEDecode",
|
||||
title: "VAEDecode",
|
||||
category: "latent",
|
||||
description: "Decodes latent samples to images.",
|
||||
inputs: json!({
|
||||
"samples": { "type": "LATENT", "required": true },
|
||||
"vae": { "type": "VAE", "required": true }
|
||||
}),
|
||||
params: json!({}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "SaveImage",
|
||||
title: "SaveImage",
|
||||
category: "image",
|
||||
description: "Saves images to disk.",
|
||||
inputs: json!({
|
||||
"images": { "type": "IMAGE", "required": true }
|
||||
}),
|
||||
params: json!({
|
||||
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
|
||||
}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "PreviewImage",
|
||||
title: "PreviewImage",
|
||||
category: "image",
|
||||
description: "Previews generated images.",
|
||||
inputs: json!({
|
||||
"images": { "type": "IMAGE", "required": true }
|
||||
}),
|
||||
params: json!({}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn node_types() -> Vec<NodeTypeDefinition> {
|
||||
let mut nodes = core_node_types();
|
||||
nodes.extend(vec![
|
||||
NodeTypeDefinition {
|
||||
node_type: "input.text",
|
||||
title: "文本输入",
|
||||
category: "Input",
|
||||
description: "输入脚本、提示词或任意文本。",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"value": { "ui": "textarea", "type": "STRING", "default": "一个电影感的雨夜城市镜头" }
|
||||
}),
|
||||
outputs: json!({ "text": { "type": "STRING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.comfyui_workflow",
|
||||
title: "生成 ComfyUI 工作流",
|
||||
category: "ComfyUI",
|
||||
description: "用 BoosAPI 模型把文本需求改写为 ComfyUI workflow JSON。",
|
||||
inputs: json!({ "text": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "gpt-5.4" },
|
||||
"system": { "ui": "textarea", "type": "STRING", "default": "You generate valid ComfyUI workflow JSON. Return JSON only." },
|
||||
"temperature": { "ui": "number", "type": "NUMBER", "default": 0.3, "min": 0, "max": 2 }
|
||||
}),
|
||||
outputs: json!({ "workflow": { "type": "JSON" }, "text": { "type": "STRING" }, "raw": { "type": "JSON" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "comfyui.submit_workflow",
|
||||
title: "提交到外部 ComfyUI",
|
||||
category: "ComfyUI",
|
||||
description: "把 workflow JSON 提交到已配置的外部 ComfyUI /prompt。",
|
||||
inputs: json!({ "workflow": { "type": "JSON", "required": true } }),
|
||||
params: json!({
|
||||
"client_id": { "ui": "text", "type": "STRING", "default": "boosapi-workflow-studio" },
|
||||
"dry_run": { "ui": "boolean", "type": "BOOLEAN", "default": true }
|
||||
}),
|
||||
outputs: json!({ "prompt_id": { "type": "STRING" }, "response": { "type": "JSON" }, "workflow": { "type": "JSON" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.chat_completion",
|
||||
title: "BoosAPI Chat Completion",
|
||||
category: "BoosAPI/Text",
|
||||
description: "调用 BoosAPI OpenAI 兼容聊天模型。",
|
||||
inputs: json!({ "text": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "gpt-5.4" },
|
||||
"system": { "ui": "textarea", "type": "STRING", "default": "" },
|
||||
"temperature": { "ui": "number", "type": "NUMBER", "default": 0.7, "min": 0, "max": 2 }
|
||||
}),
|
||||
outputs: json!({ "text": { "type": "STRING" }, "raw": { "type": "JSON" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.text_to_image",
|
||||
title: "文生图",
|
||||
category: "BoosAPI/Image",
|
||||
description: "通过 /v1/images/generations 生成图片,并归档到工作空间。",
|
||||
inputs: json!({ "prompt": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "gpt-image-2" },
|
||||
"width": { "ui": "number", "type": "NUMBER", "default": 1024, "min": 64, "max": 4096 },
|
||||
"height": { "ui": "number", "type": "NUMBER", "default": 1024, "min": 64, "max": 4096 },
|
||||
"size": { "ui": "text", "type": "STRING", "default": "" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"image": { "type": "IMAGE" },
|
||||
"images": { "type": "IMAGE", "is_list": true },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.text_to_video",
|
||||
title: "文生视频",
|
||||
category: "BoosAPI/Video",
|
||||
description: "通过 /v1/videos 创建文生视频任务。",
|
||||
inputs: json!({ "prompt": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-t2v" },
|
||||
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
||||
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
|
||||
}),
|
||||
outputs: json!({
|
||||
"task_id": { "type": "STRING" },
|
||||
"video": { "type": "VIDEO" },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.image_to_video",
|
||||
title: "图生视频",
|
||||
category: "BoosAPI/Video",
|
||||
description: "通过 /v1/videos 创建图生视频任务。",
|
||||
inputs: json!({
|
||||
"prompt": { "type": "STRING", "required": true },
|
||||
"image": { "type": "IMAGE", "required": false }
|
||||
}),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-i2v" },
|
||||
"image_url": { "ui": "text", "type": "STRING", "default": "" },
|
||||
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
||||
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
|
||||
}),
|
||||
outputs: json!({
|
||||
"task_id": { "type": "STRING" },
|
||||
"video": { "type": "VIDEO" },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.video_status",
|
||||
title: "查询视频任务",
|
||||
category: "BoosAPI/Video",
|
||||
description: "查询 /v1/videos/{id} 视频任务结果。",
|
||||
inputs: json!({ "task_id": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-t2v" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"video": { "type": "VIDEO" },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "output.preview_image",
|
||||
title: "图片预览",
|
||||
category: "Output",
|
||||
description: "收集图片输出,供运行结果面板展示。",
|
||||
inputs: json!({ "image": { "type": "IMAGE", "required": true } }),
|
||||
params: json!({}),
|
||||
outputs: json!({ "image": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "output.preview_video",
|
||||
title: "视频预览",
|
||||
category: "Output",
|
||||
description: "收集视频输出,供运行结果面板展示。",
|
||||
inputs: json!({ "video": { "type": "VIDEO", "required": true } }),
|
||||
params: json!({}),
|
||||
outputs: json!({ "video": { "type": "VIDEO" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "output.save_file",
|
||||
title: "保存文件",
|
||||
category: "Output",
|
||||
description: "标记上游文件为最终输出。",
|
||||
inputs: json!({ "file": { "type": "FILE", "required": true } }),
|
||||
params: json!({ "name": { "ui": "text", "type": "STRING", "default": "" } }),
|
||||
outputs: json!({ "file": { "type": "FILE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "util.text_join",
|
||||
title: "文本拼接",
|
||||
category: "Util",
|
||||
description: "拼接输入文本和静态文本。",
|
||||
inputs: json!({ "text": { "type": "STRING", "required": false } }),
|
||||
params: json!({
|
||||
"prefix": { "ui": "textarea", "type": "STRING", "default": "" },
|
||||
"suffix": { "ui": "textarea", "type": "STRING", "default": "" },
|
||||
"separator": { "ui": "text", "type": "STRING", "default": "\\n" }
|
||||
}),
|
||||
outputs: json!({ "text": { "type": "STRING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "util.json_extract",
|
||||
title: "JSON 提取",
|
||||
category: "Util",
|
||||
description: "从 JSON 对象中按点路径提取字段。",
|
||||
inputs: json!({ "json": { "type": "JSON", "required": true } }),
|
||||
params: json!({ "path": { "ui": "text", "type": "STRING", "default": "data.0.url" } }),
|
||||
outputs: json!({ "value": { "type": "JSON" }, "text": { "type": "STRING" } }),
|
||||
},
|
||||
]);
|
||||
nodes
|
||||
}
|
||||
|
||||
pub fn is_known_node(node_type: &str) -> bool {
|
||||
node_types()
|
||||
.iter()
|
||||
.any(|definition| definition.node_type == node_type)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
workflow::{
|
||||
executor, graph,
|
||||
models::{default_graph, RunContext, WorkflowGraph},
|
||||
registry, storage,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum WorkflowCaller {
|
||||
Admin,
|
||||
User { user_id: i64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkflowListQuery {
|
||||
#[serde(default)]
|
||||
include_public: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunListQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SaveWorkflowPayload {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
graph: Option<Value>,
|
||||
visibility: Option<String>,
|
||||
is_template: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunWorkflowPayload {
|
||||
inputs: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/workflow/node-types", get(node_types))
|
||||
.route("/api/workflows", get(list_workflows).post(create_workflow))
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
get(get_workflow)
|
||||
.put(update_workflow)
|
||||
.delete(delete_workflow),
|
||||
)
|
||||
.route("/api/workflows/{id}/run", post(run_workflow))
|
||||
.route("/api/workflow-runs", get(list_runs))
|
||||
.route("/api/workflow-runs/{id}", get(get_run))
|
||||
.route("/api/workflow-runs/{id}/outputs", get(get_run_outputs))
|
||||
.route("/api/user/workflow/node-types", get(node_types))
|
||||
.route(
|
||||
"/api/user/workflows",
|
||||
get(list_workflows).post(create_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/user/workflows/{id}",
|
||||
get(get_workflow)
|
||||
.put(update_workflow)
|
||||
.delete(delete_workflow),
|
||||
)
|
||||
.route("/api/user/workflows/{id}/run", post(run_workflow))
|
||||
.route("/api/user/workflow-runs", get(list_runs))
|
||||
.route("/api/user/workflow-runs/{id}", get(get_run))
|
||||
.route("/api/user/workflow-runs/{id}/outputs", get(get_run_outputs))
|
||||
}
|
||||
|
||||
fn bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn resolve_caller(headers: &HeaderMap, state: &AppState) -> AppResult<WorkflowCaller> {
|
||||
let token = bearer(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
if claims.role == "admin" {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
if claims.role == "user" {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<i64>()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
return Ok(WorkflowCaller::User { user_id });
|
||||
}
|
||||
Err(AppError::Forbidden("Workflow access denied".into()))
|
||||
}
|
||||
|
||||
fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
|
||||
match caller {
|
||||
WorkflowCaller::Admin => None,
|
||||
WorkflowCaller::User { user_id } => Some(*user_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn caller_is_admin(caller: &WorkflowCaller) -> bool {
|
||||
matches!(caller, WorkflowCaller::Admin)
|
||||
}
|
||||
|
||||
async fn user_downstream_key(state: &AppState, user_id: i64) -> AppResult<DownstreamApiKey> {
|
||||
sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT *
|
||||
FROM downstream_api_keys
|
||||
WHERE user_id = ?
|
||||
AND COALESCE(enabled, 1) = 1
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at = ''
|
||||
OR datetime(expires_at) > datetime('now')
|
||||
)
|
||||
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Forbidden("当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".into())
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<RunContext> {
|
||||
let bearer = match caller {
|
||||
WorkflowCaller::Admin => state.builtin_keys.comfyui_agent.1.clone(),
|
||||
WorkflowCaller::User { user_id } => user_downstream_key(state, *user_id).await?.key,
|
||||
};
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
state.config.server.host.clone()
|
||||
};
|
||||
Ok(RunContext {
|
||||
user_id: caller_user_id(caller),
|
||||
bearer,
|
||||
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_graph(value: Option<Value>, name: &str) -> AppResult<Value> {
|
||||
let graph = value.unwrap_or_else(|| default_graph(name));
|
||||
let parsed: WorkflowGraph = serde_json::from_value(graph.clone())
|
||||
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
||||
graph::validate_and_sort(&parsed)?;
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
fn normalize_visibility(raw: Option<String>, caller: &WorkflowCaller) -> String {
|
||||
let value = raw.unwrap_or_else(|| "private".into());
|
||||
match value.as_str() {
|
||||
"public" | "template" if caller_is_admin(caller) => value,
|
||||
_ => "private".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn node_types() -> Json<Value> {
|
||||
Json(json!({ "success": true, "nodeTypes": registry::node_types() }))
|
||||
}
|
||||
|
||||
async fn list_workflows(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WorkflowListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let items = storage::list_workflows(
|
||||
&state.db,
|
||||
caller_user_id(&caller),
|
||||
query.include_public || caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "items": items })))
|
||||
}
|
||||
|
||||
async fn create_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<SaveWorkflowPayload>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let name = payload
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("未命名工作流");
|
||||
let graph = normalize_graph(payload.graph, name)?;
|
||||
let visibility = normalize_visibility(payload.visibility, &caller);
|
||||
let workflow = storage::insert_workflow(
|
||||
&state,
|
||||
caller_user_id(&caller),
|
||||
name,
|
||||
payload.description.as_deref(),
|
||||
&graph,
|
||||
&visibility,
|
||||
payload.is_template.unwrap_or(false) && caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "success": true, "workflow": workflow })),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let workflow = storage::get_workflow(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "workflow": workflow })))
|
||||
}
|
||||
|
||||
async fn update_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<SaveWorkflowPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let current = storage::get_workflow(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
let name = payload
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(¤t.name);
|
||||
let graph = normalize_graph(payload.graph.or(Some(current.graph)), name)?;
|
||||
let visibility = normalize_visibility(payload.visibility.or(Some(current.visibility)), &caller);
|
||||
let workflow = storage::update_workflow(
|
||||
&state,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
name,
|
||||
payload
|
||||
.description
|
||||
.as_deref()
|
||||
.or(current.description.as_deref()),
|
||||
&graph,
|
||||
&visibility,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "workflow": workflow })))
|
||||
}
|
||||
|
||||
async fn delete_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
storage::delete_workflow(
|
||||
&state,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn run_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<RunWorkflowPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let workflow = storage::get_workflow(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
let ctx = run_context(&state, &caller).await?;
|
||||
let run_id = executor::run_workflow(
|
||||
&state,
|
||||
workflow.id,
|
||||
workflow.graph,
|
||||
payload.inputs.unwrap_or_else(|| json!({})),
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&run_id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
json!({ "success": true, "runId": run_id, "run": run }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<RunListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let items = storage::list_runs(
|
||||
&state.db,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
query.limit.unwrap_or(30),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "items": items })))
|
||||
}
|
||||
|
||||
async fn get_run(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "run": run })))
|
||||
}
|
||||
|
||||
async fn get_run_outputs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"runId": run.public_id,
|
||||
"outputs": run.final_outputs.unwrap_or_else(|| json!({}))
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
workflow::models::{WorkflowNodeRunSummary, WorkflowRunSummary, WorkflowSummary},
|
||||
AppState,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn new_workflow_id() -> String {
|
||||
format!("wf_{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
pub fn new_run_id() -> String {
|
||||
format!("run_{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
pub fn parse_json_text(raw: Option<String>, fallback: Value) -> Value {
|
||||
raw.and_then(|value| serde_json::from_str::<Value>(&value).ok())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn row_to_workflow(row: sqlx::sqlite::SqliteRow) -> WorkflowSummary {
|
||||
let graph_raw: String = row.try_get("graph").unwrap_or_else(|_| "{}".to_string());
|
||||
WorkflowSummary {
|
||||
id: row.try_get("id").unwrap_or_default(),
|
||||
public_id: row.try_get("public_id").unwrap_or_default(),
|
||||
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
|
||||
name: row.try_get("name").unwrap_or_default(),
|
||||
description: row.try_get("description").ok(),
|
||||
graph: serde_json::from_str(&graph_raw).unwrap_or_else(|_| json!({})),
|
||||
is_template: row
|
||||
.try_get::<i64, _>("is_template")
|
||||
.map(|value| value != 0)
|
||||
.unwrap_or(false),
|
||||
visibility: row
|
||||
.try_get("visibility")
|
||||
.unwrap_or_else(|_| "private".into()),
|
||||
created_at: row.try_get("created_at").unwrap_or_default(),
|
||||
updated_at: row.try_get("updated_at").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_node_run(row: sqlx::sqlite::SqliteRow) -> WorkflowNodeRunSummary {
|
||||
WorkflowNodeRunSummary {
|
||||
id: row.try_get("id").unwrap_or_default(),
|
||||
node_id: row.try_get("node_id").unwrap_or_default(),
|
||||
node_type: row.try_get("node_type").unwrap_or_default(),
|
||||
status: row.try_get("status").unwrap_or_default(),
|
||||
inputs: parse_json_text(row.try_get("inputs").ok(), json!({})),
|
||||
outputs: row
|
||||
.try_get::<Option<String>, _>("outputs")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok()),
|
||||
error_message: row.try_get("error_message").ok(),
|
||||
started_at: row.try_get("started_at").ok(),
|
||||
completed_at: row.try_get("completed_at").ok(),
|
||||
created_at: row.try_get("created_at").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_run(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
node_runs: Vec<WorkflowNodeRunSummary>,
|
||||
) -> WorkflowRunSummary {
|
||||
WorkflowRunSummary {
|
||||
id: row.try_get("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_public_id: row.try_get("workflow_public_id").ok(),
|
||||
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
|
||||
status: row.try_get("status").unwrap_or_default(),
|
||||
input_overrides: parse_json_text(row.try_get("input_overrides").ok(), json!({})),
|
||||
final_outputs: row
|
||||
.try_get::<Option<String>, _>("final_outputs")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok()),
|
||||
error_message: row.try_get("error_message").ok(),
|
||||
started_at: row.try_get("started_at").ok(),
|
||||
completed_at: row.try_get("completed_at").ok(),
|
||||
created_at: row.try_get("created_at").unwrap_or_default(),
|
||||
node_runs,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_workflows(
|
||||
pool: &SqlitePool,
|
||||
user_id: Option<i64>,
|
||||
include_public: bool,
|
||||
) -> AppResult<Vec<WorkflowSummary>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflows
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
? IS NULL
|
||||
OR user_id = ?
|
||||
OR (? = 1 AND visibility IN ('public', 'template'))
|
||||
)
|
||||
ORDER BY datetime(updated_at) DESC, id DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(user_id)
|
||||
.bind(if include_public { 1_i64 } else { 0_i64 })
|
||||
.map(row_to_workflow)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_workflow(
|
||||
pool: &SqlitePool,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<WorkflowSummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflows
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (? = 1 OR user_id = ? OR visibility IN ('public', 'template'))
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workflow not found".into()))?;
|
||||
Ok(row_to_workflow(row))
|
||||
}
|
||||
|
||||
pub async fn get_workflow_by_db_id(pool: &SqlitePool, id: i64) -> AppResult<WorkflowSummary> {
|
||||
let row = sqlx::query("SELECT * FROM workflows WHERE id = ? AND deleted_at IS NULL LIMIT 1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workflow not found".into()))?;
|
||||
Ok(row_to_workflow(row))
|
||||
}
|
||||
|
||||
pub async fn get_run(
|
||||
pool: &SqlitePool,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<WorkflowRunSummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
||||
FROM workflow_runs
|
||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||
WHERE workflow_runs.public_id = ?
|
||||
AND (? = 1 OR workflow_runs.user_id = ?)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workflow run not found".into()))?;
|
||||
let run_id: i64 = row.try_get("id").unwrap_or_default();
|
||||
let node_runs = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflow_node_runs
|
||||
WHERE run_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(run_id)
|
||||
.map(row_to_node_run)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(row_to_run(row, node_runs))
|
||||
}
|
||||
|
||||
pub async fn list_runs(
|
||||
pool: &SqlitePool,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
limit: i64,
|
||||
) -> AppResult<Vec<WorkflowRunSummary>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
||||
FROM workflow_runs
|
||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||
WHERE (? = 1 OR workflow_runs.user_id = ?)
|
||||
ORDER BY datetime(workflow_runs.created_at) DESC, workflow_runs.id DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.bind(limit.clamp(1, 100))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut runs = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let run_id: i64 = row.try_get("id").unwrap_or_default();
|
||||
let node_runs = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflow_node_runs
|
||||
WHERE run_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(run_id)
|
||||
.map(row_to_node_run)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
runs.push(row_to_run(row, node_runs));
|
||||
}
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub async fn insert_workflow(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
graph: &Value,
|
||||
visibility: &str,
|
||||
is_template: bool,
|
||||
) -> AppResult<WorkflowSummary> {
|
||||
let public_id = new_workflow_id();
|
||||
sqlx::query(
|
||||
"INSERT INTO workflows
|
||||
(public_id, user_id, name, description, graph, visibility, is_template)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&public_id)
|
||||
.bind(user_id)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(graph.to_string())
|
||||
.bind(visibility)
|
||||
.bind(if is_template { 1_i64 } else { 0_i64 })
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
get_workflow(&state.db, &public_id, user_id, true).await
|
||||
}
|
||||
|
||||
pub async fn update_workflow(
|
||||
state: &AppState,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
graph: &Value,
|
||||
visibility: &str,
|
||||
) -> AppResult<WorkflowSummary> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE workflows
|
||||
SET name = ?, description = ?, graph = ?, visibility = ?, updated_at = datetime('now')
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(graph.to_string())
|
||||
.bind(visibility)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Workflow not found".into()));
|
||||
}
|
||||
get_workflow(&state.db, public_id, user_id, allow_admin).await
|
||||
}
|
||||
|
||||
pub async fn delete_workflow(
|
||||
state: &AppState,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<()> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE workflows
|
||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Workflow not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_run(
|
||||
state: &AppState,
|
||||
workflow_id: i64,
|
||||
user_id: Option<i64>,
|
||||
input_overrides: &Value,
|
||||
) -> AppResult<(i64, String)> {
|
||||
let public_id = new_run_id();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO workflow_runs
|
||||
(public_id, workflow_id, user_id, status, input_overrides, started_at)
|
||||
VALUES (?, ?, ?, 'running', ?, datetime('now'))",
|
||||
)
|
||||
.bind(&public_id)
|
||||
.bind(workflow_id)
|
||||
.bind(user_id)
|
||||
.bind(input_overrides.to_string())
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let id = result.last_insert_rowid();
|
||||
Ok((id, public_id))
|
||||
}
|
||||
|
||||
pub async fn insert_node_run_started(
|
||||
state: &AppState,
|
||||
run_id: i64,
|
||||
node_id: &str,
|
||||
node_type: &str,
|
||||
inputs: &Value,
|
||||
) -> AppResult<i64> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO workflow_node_runs
|
||||
(run_id, node_id, node_type, status, inputs, started_at)
|
||||
VALUES (?, ?, ?, 'running', ?, datetime('now'))",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(node_id)
|
||||
.bind(node_type)
|
||||
.bind(inputs.to_string())
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let id = result.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn complete_node_run(
|
||||
state: &AppState,
|
||||
node_run_id: i64,
|
||||
outputs: &Value,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_node_runs
|
||||
SET status = 'completed', outputs = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(outputs.to_string())
|
||||
.bind(node_run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail_node_run(state: &AppState, node_run_id: i64, error: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_node_runs
|
||||
SET status = 'failed', error_message = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(error)
|
||||
.bind(node_run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_run(state: &AppState, run_id: i64, outputs: &Value) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_runs
|
||||
SET status = 'completed', final_outputs = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(outputs.to_string())
|
||||
.bind(run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail_run(state: &AppState, run_id: i64, error: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_runs
|
||||
SET status = 'failed', error_message = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(error)
|
||||
.bind(run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_runs(
|
||||
state: &AppState,
|
||||
public_ids: &[String],
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<usize> {
|
||||
let mut deleted = 0_usize;
|
||||
for public_id in public_ids {
|
||||
sqlx::query(
|
||||
"DELETE FROM workflow_node_runs
|
||||
WHERE run_id IN (
|
||||
SELECT id
|
||||
FROM workflow_runs
|
||||
WHERE public_id = ?
|
||||
AND (? = 1 OR user_id = ?)
|
||||
)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM workflow_runs
|
||||
WHERE public_id = ?
|
||||
AND (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
deleted += result.rows_affected() as usize;
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub async fn clear_runs(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<u64> {
|
||||
sqlx::query(
|
||||
"DELETE FROM workflow_node_runs
|
||||
WHERE run_id IN (
|
||||
SELECT id
|
||||
FROM workflow_runs
|
||||
WHERE (? = 1 OR user_id = ?)
|
||||
)",
|
||||
)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM workflow_runs
|
||||
WHERE (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
Reference in New Issue
Block a user