feat: add workflow studio and postgres support
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user