feat: improve agent workbench and digital human flows
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
-- Restore the documented $1 starter credit for existing user default keys.
|
||||||
|
-- Keep explicit admin/recharge limits intact, and never leave default user keys
|
||||||
|
-- with NULL max_cost because NULL is treated as unlimited by the proxy path.
|
||||||
|
UPDATE downstream_api_keys
|
||||||
|
SET max_cost = 1.0,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND (max_cost IS NULL OR max_cost <= 0.0)
|
||||||
|
AND (
|
||||||
|
name LIKE '% 的默认密钥'
|
||||||
|
OR name LIKE '% default key'
|
||||||
|
OR key LIKE 'sk-user-%'
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_desc_idx
|
||||||
|
ON proxy_logs (created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_cooldown_idx
|
||||||
|
ON route_channels (route_id, enabled, cooldown_until, account_id, token_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS token_routes_enabled_id_idx
|
||||||
|
ON token_routes (enabled, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS accounts_id_status_site_idx
|
||||||
|
ON accounts (id, status, site_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sites_id_status_url_idx
|
||||||
|
ON sites (id, status, url);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS account_tokens_id_enabled_idx
|
||||||
|
ON account_tokens (id, enabled);
|
||||||
@@ -105,6 +105,8 @@ const SQLITE_COMPAT_DDL: &[&str] = &[
|
|||||||
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
|
||||||
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_created_at_desc_idx
|
||||||
|
ON proxy_logs (created_at DESC, id DESC)",
|
||||||
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
ON proxy_logs (content_summary)",
|
ON proxy_logs (content_summary)",
|
||||||
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
@@ -131,6 +133,16 @@ const SQLITE_COMPAT_DDL: &[&str] = &[
|
|||||||
ON workspace_asset_daily (source, local_day DESC)",
|
ON workspace_asset_daily (source, local_day DESC)",
|
||||||
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
|
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
|
||||||
ON route_channels (oauth_route_unit_id)",
|
ON route_channels (oauth_route_unit_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS route_channels_route_enabled_cooldown_idx
|
||||||
|
ON route_channels (route_id, enabled, cooldown_until, account_id, token_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS token_routes_enabled_id_idx
|
||||||
|
ON token_routes (enabled, id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS accounts_id_status_site_idx
|
||||||
|
ON accounts (id, status, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS sites_id_status_url_idx
|
||||||
|
ON sites (id, status, url)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS account_tokens_id_enabled_idx
|
||||||
|
ON account_tokens (id, enabled)",
|
||||||
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
|
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
site_id INTEGER NOT NULL,
|
site_id INTEGER NOT NULL,
|
||||||
|
|||||||
+103
-3
@@ -443,9 +443,21 @@ pub async fn delete_user(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Deleting user: {}", id);
|
info!("Deleting user: {}", id);
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM downstream_api_keys WHERE user_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE invitations SET invited_by = NULL WHERE invited_by = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
|
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&*state.db)
|
.execute(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
|
|
||||||
@@ -453,6 +465,8 @@ pub async fn delete_user(
|
|||||||
return Err(AppError::NotFound(format!("User {} not found", id)));
|
return Err(AppError::NotFound(format!("User {} not found", id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,6 +967,11 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("in-memory database");
|
.expect("in-memory database");
|
||||||
|
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("enable foreign keys");
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE users (
|
"CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
@@ -989,7 +1008,7 @@ mod tests {
|
|||||||
excluded_site_ids TEXT,
|
excluded_site_ids TEXT,
|
||||||
excluded_credential_refs TEXT,
|
excluded_credential_refs TEXT,
|
||||||
last_used_at TEXT,
|
last_used_at TEXT,
|
||||||
user_id INTEGER,
|
user_id INTEGER REFERENCES users(id),
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
)",
|
)",
|
||||||
@@ -998,6 +1017,23 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("downstream keys table");
|
.expect("downstream keys table");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE invitations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
org_id INTEGER NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
invited_by INTEGER REFERENCES users(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("invitations table");
|
||||||
|
|
||||||
AppState::new(
|
AppState::new(
|
||||||
AppConfig {
|
AppConfig {
|
||||||
server: ServerConfig {
|
server: ServerConfig {
|
||||||
@@ -1079,6 +1115,70 @@ mod tests {
|
|||||||
assert_eq!(role, "admin");
|
assert_eq!(role, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_user_clears_owned_keys_and_nullable_references() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Delete Me")
|
||||||
|
.bind("delete-me@example.test")
|
||||||
|
.bind("hash")
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id)
|
||||||
|
VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind("default")
|
||||||
|
.bind("sk-delete-me")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert key");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO invitations (org_id, email, token, invited_by, expires_at)
|
||||||
|
VALUES (1, 'invitee@example.test', 'tok-delete-me', ?, datetime('now', '+1 day'))",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert invitation");
|
||||||
|
|
||||||
|
let Json(value) = delete_user(State(state.clone()), Path(user_id))
|
||||||
|
.await
|
||||||
|
.expect("delete user");
|
||||||
|
|
||||||
|
assert_eq!(value["data"]["deleted"], true);
|
||||||
|
|
||||||
|
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("count users");
|
||||||
|
assert_eq!(user_count, 0);
|
||||||
|
|
||||||
|
let key_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys WHERE user_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("count keys");
|
||||||
|
assert_eq!(key_count, 0);
|
||||||
|
|
||||||
|
let invited_by: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT invited_by FROM invitations WHERE token = 'tok-delete-me'")
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("read invitation");
|
||||||
|
assert_eq!(invited_by, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn adjust_user_balance_writes_admin_adjust_ledger() {
|
async fn adjust_user_balance_writes_admin_adjust_ledger() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
@@ -1109,7 +1209,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert key");
|
.expect("insert key");
|
||||||
|
|
||||||
adjust_user_balance(
|
let _ = adjust_user_balance(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
Path(user_id),
|
Path(user_id),
|
||||||
Json(AdjustBalanceRequest { delta: 5.0 }),
|
Json(AdjustBalanceRequest { delta: 5.0 }),
|
||||||
|
|||||||
+132
-1
@@ -32,7 +32,7 @@ use crate::services::agent_workbench::{
|
|||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{DefaultBodyLimit, State},
|
extract::{DefaultBodyLimit, Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::sse::{Event, Sse},
|
response::sse::{Event, Sse},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -73,6 +73,9 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/api/agent/chat", post(chat_handler))
|
.route("/api/agent/chat", post(chat_handler))
|
||||||
.route("/api/agent/approve", post(approve_handler))
|
.route("/api/agent/approve", post(approve_handler))
|
||||||
.route("/api/agent/answer", post(answer_handler))
|
.route("/api/agent/answer", post(answer_handler))
|
||||||
|
.route("/api/agent/models", get(models_handler))
|
||||||
|
.route("/api/agent/sessions", get(list_sessions_handler))
|
||||||
|
.route("/api/agent/sessions/{id}", get(get_session_handler))
|
||||||
.route("/api/agent/status", get(status_handler))
|
.route("/api/agent/status", get(status_handler))
|
||||||
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,12 @@ struct AnswerRequest {
|
|||||||
answer: String,
|
answer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SessionListQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolved caller identity for an agent request.
|
/// Resolved caller identity for an agent request.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct AgentAuth {
|
struct AgentAuth {
|
||||||
@@ -363,6 +372,16 @@ async fn chat_handler(
|
|||||||
{
|
{
|
||||||
tracing::warn!(%e, "failed to ensure agent session row");
|
tracing::warn!(%e, "failed to ensure agent session row");
|
||||||
}
|
}
|
||||||
|
if let Err(e) = session_store::set_title_if_empty(
|
||||||
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
auth.user_id,
|
||||||
|
&body.message,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%e, "failed to set agent session title");
|
||||||
|
}
|
||||||
|
|
||||||
// Load any prior conversation for this session so the agent sees the full
|
// Load any prior conversation for this session so the agent sees the full
|
||||||
// multi-turn history (scoped to the caller — never another tenant's).
|
// multi-turn history (scoped to the caller — never another tenant's).
|
||||||
@@ -624,6 +643,118 @@ async fn answer_handler(
|
|||||||
Ok(Json(json!({ "ok": delivered })))
|
Ok(Json(json!({ "ok": delivered })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_sessions_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<SessionListQuery>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let sessions = session_store::list_sessions(&state.db, auth.user_id, query.limit.unwrap_or(30))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Json(json!({ "ok": true, "sessions": sessions })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let session = session_store::get_session(&state.db, &id, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": "Agent session not found" })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let transcript = session_store::load_transcript(&state.db, &id, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"session": session,
|
||||||
|
"transcript": transcript,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_routable_models(state: &AppState) -> Result<Vec<Value>, sqlx::Error> {
|
||||||
|
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT
|
||||||
|
tr.model_pattern,
|
||||||
|
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||||
|
FROM token_routes tr
|
||||||
|
WHERE tr.enabled = 1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM route_channels rc
|
||||||
|
JOIN accounts a ON a.id = rc.account_id
|
||||||
|
JOIN sites s ON s.id = a.site_id
|
||||||
|
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||||
|
WHERE rc.route_id = tr.id
|
||||||
|
AND rc.enabled = 1
|
||||||
|
AND a.status = 'active'
|
||||||
|
AND s.status = 'active'
|
||||||
|
AND s.url IS NOT NULL
|
||||||
|
AND s.url <> ''
|
||||||
|
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||||
|
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||||
|
)
|
||||||
|
ORDER BY tr.id",
|
||||||
|
)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, model)| {
|
||||||
|
json!({
|
||||||
|
"id": model,
|
||||||
|
"object": "model",
|
||||||
|
"created": 0,
|
||||||
|
"owned_by": name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn models_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let _auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let models = load_routable_models(&state).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"object": "list",
|
||||||
|
"data": models,
|
||||||
|
"default_model": state.config.comfyui.agent_model,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn status_handler(
|
async fn status_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|||||||
+16
-12
@@ -20,11 +20,10 @@ use std::net::{IpAddr, SocketAddr};
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// Default spending cap (USD) on a newly created user's auto-generated default
|
/// Default spending cap (USD) on a newly created user's auto-generated default
|
||||||
/// API key. Set to `0.0` so a freshly registered user has **zero** spend
|
/// API key. This must always be bound explicitly on INSERT: a NULL `max_cost`
|
||||||
/// allowance until they top up or an admin raises the cap. This must always be
|
/// is treated as *unlimited* by the proxy, so leaving it unbound silently grants
|
||||||
/// bound explicitly on INSERT: a NULL `max_cost` is treated as *unlimited* by
|
/// free unlimited usage.
|
||||||
/// the proxy, so leaving it unbound silently grants free unlimited usage.
|
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
||||||
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 0.0;
|
|
||||||
|
|
||||||
// ── Request / Response types ─────────────────────────────────────────
|
// ── Request / Response types ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1422,15 +1421,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_user_default_quota_is_zero() {
|
fn new_user_default_quota_matches_starter_credit() {
|
||||||
// New users must start with zero spend allowance, not unlimited.
|
// New users receive a small starter credit, while still avoiding NULL
|
||||||
assert_eq!(DEFAULT_NEW_USER_MAX_COST_USD, 0.0);
|
// (NULL means unlimited in the proxy path).
|
||||||
|
assert_eq!(DEFAULT_NEW_USER_MAX_COST_USD, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn registration_default_key_has_non_null_zero_max_cost() {
|
async fn registration_default_key_has_non_null_starter_max_cost() {
|
||||||
// Regression guard for the billing leak: the default downstream key
|
// Regression guard for the billing leak: the default downstream key
|
||||||
// created at registration must persist max_cost = 0.0, never NULL
|
// created at registration must persist a finite max_cost, never NULL
|
||||||
// (NULL is treated as unlimited by the proxy). This runs the exact
|
// (NULL is treated as unlimited by the proxy). This runs the exact
|
||||||
// INSERT the register handler uses.
|
// INSERT the register handler uses.
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
@@ -1462,7 +1462,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert default key");
|
.expect("insert default key");
|
||||||
|
|
||||||
// max_cost must be present (non-NULL) and exactly zero.
|
// max_cost must be present (non-NULL) and match starter credit.
|
||||||
let max_cost: Option<f64> =
|
let max_cost: Option<f64> =
|
||||||
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE key = ?")
|
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE key = ?")
|
||||||
.bind("sk-user-quota-test")
|
.bind("sk-user-quota-test")
|
||||||
@@ -1470,6 +1470,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("select max_cost");
|
.expect("select max_cost");
|
||||||
assert!(max_cost.is_some(), "max_cost must not be NULL (NULL = unlimited)");
|
assert!(max_cost.is_some(), "max_cost must not be NULL (NULL = unlimited)");
|
||||||
assert_eq!(max_cost.unwrap(), 0.0, "new user default key must have zero quota");
|
assert_eq!(
|
||||||
|
max_cost.unwrap(),
|
||||||
|
DEFAULT_NEW_USER_MAX_COST_USD,
|
||||||
|
"new user default key must receive starter quota",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+277
-17
@@ -15,6 +15,7 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use mime_guess::MimeGuess;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -140,6 +141,18 @@ fn pixelle_backend_label(state: &AppState, provider: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_pixelle_media_workflow_for_api(value: &str) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if lower == "runninghub/af_scail.json"
|
||||||
|
|| lower == "runninghub/af_scail_2_action_transfer.json"
|
||||||
|
{
|
||||||
|
"runninghub/video_SCAIL-2-_.json".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pixelle_base_url(state: &AppState) -> AppResult<String> {
|
fn pixelle_base_url(state: &AppState) -> AppResult<String> {
|
||||||
state
|
state
|
||||||
.config
|
.config
|
||||||
@@ -237,6 +250,36 @@ fn pixelle_project_root(state: &AppState) -> PathBuf {
|
|||||||
PathBuf::from("/opt/Pixelle-Video")
|
PathBuf::from("/opt/Pixelle-Video")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workflow_media_prefix(media_type: &str) -> &'static str {
|
||||||
|
match media_type.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"video" => "video_",
|
||||||
|
"action_transfer" | "action-transfer" | "af" => "af_",
|
||||||
|
_ => "image_",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_pixelle_workflow_source(value: &str) -> Option<&'static str> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"runninghub" | "running_hub" | "rh" => Some("runninghub"),
|
||||||
|
"selfhost" | "self_host" | "comfy" | "comfyui" | "local" => Some("selfhost"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_workflow_filename(file_name: &str, media_type: &str) -> String {
|
||||||
|
let stem = FsPath::new(file_name)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.map(safe_pixelle_filename)
|
||||||
|
.unwrap_or_else(|| "custom".to_string());
|
||||||
|
let mut stem = stem.trim_end_matches(".json").to_string();
|
||||||
|
let prefix = workflow_media_prefix(media_type);
|
||||||
|
if !stem.starts_with("image_") && !stem.starts_with("video_") && !stem.starts_with("af_") {
|
||||||
|
stem = format!("{prefix}{stem}");
|
||||||
|
}
|
||||||
|
format!("{stem}.json")
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_pixelle_upload_resource(
|
async fn persist_pixelle_upload_resource(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
@@ -357,6 +400,10 @@ pub fn routes() -> Router<AppState> {
|
|||||||
"/api/digital-human/pixelle/workflows/media",
|
"/api/digital-human/pixelle/workflows/media",
|
||||||
get(pixelle_list_media_workflows),
|
get(pixelle_list_media_workflows),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/digital-human/pixelle/workflows/upload",
|
||||||
|
post(pixelle_upload_workflow),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/digital-human/pixelle/templates",
|
"/api/digital-human/pixelle/templates",
|
||||||
get(pixelle_list_templates),
|
get(pixelle_list_templates),
|
||||||
@@ -2031,7 +2078,35 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
|||||||
"videoUrl",
|
"videoUrl",
|
||||||
"video_url",
|
"video_url",
|
||||||
],
|
],
|
||||||
);
|
)
|
||||||
|
.map(validate_pixelle_input_media_reference)
|
||||||
|
.transpose()?;
|
||||||
|
let reference_image_url = optional_string(
|
||||||
|
body,
|
||||||
|
&[
|
||||||
|
"referenceImageUrl",
|
||||||
|
"reference_image_url",
|
||||||
|
"imagePath",
|
||||||
|
"image_path",
|
||||||
|
"imageUrl",
|
||||||
|
"image_url",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map(validate_pixelle_input_media_reference)
|
||||||
|
.transpose()?;
|
||||||
|
let reference_video_url = optional_string(
|
||||||
|
body,
|
||||||
|
&[
|
||||||
|
"referenceVideoUrl",
|
||||||
|
"reference_video_url",
|
||||||
|
"videoPath",
|
||||||
|
"video_path",
|
||||||
|
"videoUrl",
|
||||||
|
"video_url",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map(validate_pixelle_input_media_reference)
|
||||||
|
.transpose()?;
|
||||||
let reference_audio_url = optional_string(
|
let reference_audio_url = optional_string(
|
||||||
body,
|
body,
|
||||||
&[
|
&[
|
||||||
@@ -2049,6 +2124,16 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
|||||||
let mut template_params = Map::new();
|
let mut template_params = Map::new();
|
||||||
template_params.insert("source".to_string(), json!("boosapi-digital-human"));
|
template_params.insert("source".to_string(), json!("boosapi-digital-human"));
|
||||||
template_params.insert("avatar_url".to_string(), json!(avatar_url.clone()));
|
template_params.insert("avatar_url".to_string(), json!(avatar_url.clone()));
|
||||||
|
template_params.insert("image_url".to_string(), json!(reference_image_url.clone()));
|
||||||
|
template_params.insert(
|
||||||
|
"reference_image_url".to_string(),
|
||||||
|
json!(reference_image_url.clone()),
|
||||||
|
);
|
||||||
|
template_params.insert("video_url".to_string(), json!(reference_video_url.clone()));
|
||||||
|
template_params.insert(
|
||||||
|
"reference_video_url".to_string(),
|
||||||
|
json!(reference_video_url.clone()),
|
||||||
|
);
|
||||||
template_params.insert(
|
template_params.insert(
|
||||||
"reference_audio_url".to_string(),
|
"reference_audio_url".to_string(),
|
||||||
json!(reference_audio_url.clone()),
|
json!(reference_audio_url.clone()),
|
||||||
@@ -2076,6 +2161,15 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
|||||||
let bgm_path = optional_string(body, &["bgmPath", "bgm_path"])
|
let bgm_path = optional_string(body, &["bgmPath", "bgm_path"])
|
||||||
.map(validate_pixelle_input_media_reference)
|
.map(validate_pixelle_input_media_reference)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
let tts_inference_mode = optional_string(body, &["ttsInferenceMode", "tts_inference_mode", "inferenceMode", "inference_mode"])
|
||||||
|
.map(|value| {
|
||||||
|
if value.eq_ignore_ascii_case("comfyui") || value.eq_ignore_ascii_case("comfy") {
|
||||||
|
"comfyui".to_string()
|
||||||
|
} else {
|
||||||
|
"local".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "local".to_string());
|
||||||
let mut request_body = json!({
|
let mut request_body = json!({
|
||||||
"text": script,
|
"text": script,
|
||||||
"mode": optional_string(body, &["pixelleMode", "pixelle_mode"]).unwrap_or_else(|| state.config.pixelle_video.mode.clone()),
|
"mode": optional_string(body, &["pixelleMode", "pixelle_mode"]).unwrap_or_else(|| state.config.pixelle_video.mode.clone()),
|
||||||
@@ -2087,29 +2181,62 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
|||||||
"frame_template": optional_string(body, &["frameTemplate", "frame_template"]).unwrap_or_else(|| state.config.pixelle_video.frame_template.clone()),
|
"frame_template": optional_string(body, &["frameTemplate", "frame_template"]).unwrap_or_else(|| state.config.pixelle_video.frame_template.clone()),
|
||||||
"template_params": Value::Object(template_params),
|
"template_params": Value::Object(template_params),
|
||||||
"prompt_prefix": prompt_text,
|
"prompt_prefix": prompt_text,
|
||||||
"bgm_volume": body.get("bgmVolume").or_else(|| body.get("bgm_volume")).and_then(Value::as_f64).unwrap_or(0.3).clamp(0.0, 1.0)
|
"bgm_volume": body.get("bgmVolume").or_else(|| body.get("bgm_volume")).and_then(Value::as_f64).unwrap_or(0.3).clamp(0.0, 1.0),
|
||||||
|
"tts_inference_mode": tts_inference_mode,
|
||||||
|
"inference_mode": tts_inference_mode
|
||||||
});
|
});
|
||||||
if let Some(value) = bgm_path {
|
if let Some(value) = bgm_path {
|
||||||
request_body["bgm_path"] = json!(value);
|
request_body["bgm_path"] = json!(value);
|
||||||
}
|
}
|
||||||
|
if let Some(value) = avatar_url {
|
||||||
|
request_body["avatar_url"] = json!(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = reference_image_url {
|
||||||
|
request_body["image_url"] = json!(value);
|
||||||
|
request_body["image_path"] = json!(value);
|
||||||
|
request_body["reference_image_url"] = json!(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = reference_video_url {
|
||||||
|
request_body["video_url"] = json!(value);
|
||||||
|
request_body["video_path"] = json!(value);
|
||||||
|
request_body["reference_video_url"] = json!(value);
|
||||||
|
}
|
||||||
if let Some(value) = optional_string(body, &["mediaWorkflow", "media_workflow"])
|
if let Some(value) = optional_string(body, &["mediaWorkflow", "media_workflow"])
|
||||||
.or_else(|| default_pixelle_media_workflow(state, backend_provider))
|
.or_else(|| default_pixelle_media_workflow(state, backend_provider))
|
||||||
{
|
{
|
||||||
request_body["media_workflow"] = json!(value);
|
request_body["media_workflow"] = json!(normalize_pixelle_media_workflow_for_api(&value));
|
||||||
}
|
}
|
||||||
if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
|
if tts_inference_mode == "local" {
|
||||||
|
request_body["tts_voice"] = json!(
|
||||||
|
optional_string(body, &["ttsVoice", "tts_voice", "voice"])
|
||||||
|
.unwrap_or_else(|| "zh-CN-YunjianNeural".to_string())
|
||||||
|
);
|
||||||
|
request_body["voice"] = request_body["tts_voice"].clone();
|
||||||
|
request_body["voice_id"] = request_body["tts_voice"].clone();
|
||||||
|
let speed = body
|
||||||
|
.get("ttsSpeed")
|
||||||
|
.or_else(|| body.get("tts_speed"))
|
||||||
|
.or_else(|| body.get("speed"))
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.unwrap_or(1.2)
|
||||||
|
.clamp(0.5, 2.0);
|
||||||
|
request_body["tts_speed"] = json!(speed);
|
||||||
|
request_body["speed"] = json!(speed);
|
||||||
|
} else if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
|
||||||
.or_else(|| default_pixelle_tts_workflow(state, backend_provider))
|
.or_else(|| default_pixelle_tts_workflow(state, backend_provider))
|
||||||
{
|
{
|
||||||
request_body["tts_workflow"] = json!(value);
|
request_body["tts_workflow"] = json!(value);
|
||||||
}
|
}
|
||||||
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
||||||
request_body["api_video_workflow"] = json!(value);
|
request_body["api_video_workflow"] = json!(normalize_pixelle_media_workflow_for_api(&value));
|
||||||
}
|
}
|
||||||
if let Some(value) = optional_string(body, &["llmModel", "llm_model"]) {
|
if let Some(value) = optional_string(body, &["llmModel", "llm_model"]) {
|
||||||
request_body["llm_model"] = json!(value);
|
request_body["llm_model"] = json!(value);
|
||||||
}
|
}
|
||||||
if let Some(value) = reference_audio_url {
|
if tts_inference_mode == "comfyui" {
|
||||||
request_body["ref_audio"] = json!(value);
|
if let Some(value) = reference_audio_url {
|
||||||
|
request_body["ref_audio"] = json!(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok((
|
Ok((
|
||||||
request_body
|
request_body
|
||||||
@@ -3145,6 +3272,83 @@ async fn upload_asset(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn pixelle_upload_workflow(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
resolve_auth(&headers, &state).await?;
|
||||||
|
let mut file_name = "workflow.json".to_string();
|
||||||
|
let mut source = "runninghub".to_string();
|
||||||
|
let mut media_type = "image".to_string();
|
||||||
|
let mut bytes: Option<Bytes> = None;
|
||||||
|
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::BadRequest(err.to_string()))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
match name.as_str() {
|
||||||
|
"file" => {
|
||||||
|
file_name = field.file_name().unwrap_or(&file_name).to_string();
|
||||||
|
bytes = Some(
|
||||||
|
field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::BadRequest(err.to_string()))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"source" => source = field.text().await.unwrap_or_else(|_| source.clone()),
|
||||||
|
"media_type" | "mediaType" => {
|
||||||
|
media_type = field.text().await.unwrap_or_else(|_| media_type.clone())
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(bytes) = bytes else {
|
||||||
|
return Err(AppError::BadRequest("file is required".into()));
|
||||||
|
};
|
||||||
|
if filename_extension(&file_name).as_deref() != Some("json") {
|
||||||
|
return Err(AppError::BadRequest("工作流文件必须是 .json".into()));
|
||||||
|
}
|
||||||
|
let json_value: Value = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("工作流 JSON 无效: {err}")))?;
|
||||||
|
if !json_value.is_object() {
|
||||||
|
return Err(AppError::BadRequest("工作流 JSON 必须是对象".into()));
|
||||||
|
}
|
||||||
|
let Some(source) = normalize_pixelle_workflow_source(&source) else {
|
||||||
|
return Err(AppError::BadRequest("source must be runninghub or selfhost".into()));
|
||||||
|
};
|
||||||
|
let file_name = normalize_workflow_filename(&file_name, &media_type);
|
||||||
|
let relative_path = format!("data/workflows/{source}/{file_name}");
|
||||||
|
let absolute_path = pixelle_project_root(&state).join(&relative_path);
|
||||||
|
if let Some(parent) = absolute_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|err| {
|
||||||
|
AppError::Internal(anyhow::anyhow!("Failed to create Pixelle workflow dir: {err}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(&absolute_path, &bytes).await.map_err(|err| {
|
||||||
|
AppError::Internal(anyhow::anyhow!("Failed to persist Pixelle workflow: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let _ = crate::services::pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
|
||||||
|
|
||||||
|
let key = format!("{source}/{file_name}");
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"workflow": {
|
||||||
|
"name": file_name,
|
||||||
|
"display_name": format!("{} - {}", file_name, source_label(source)),
|
||||||
|
"source": source,
|
||||||
|
"path": absolute_path.to_string_lossy(),
|
||||||
|
"key": key,
|
||||||
|
"media_type": if workflow_media_prefix(&media_type) == "image_" { "image" } else { "video" }
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pixelle WebUI parity: thin proxy + auth wrapper ─────────────────────
|
// ── Pixelle WebUI parity: thin proxy + auth wrapper ─────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -3180,15 +3384,23 @@ async fn pixelle_list_media_workflows(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
resolve_auth(&headers, &state).await?;
|
resolve_auth(&headers, &state).await?;
|
||||||
Ok(Json(
|
let value = pixelle_json(
|
||||||
pixelle_json(
|
&state,
|
||||||
&state,
|
Method::GET,
|
||||||
Method::GET,
|
"/api/resources/workflows/media",
|
||||||
"/api/resources/workflows/media",
|
None,
|
||||||
None,
|
)
|
||||||
)
|
.await?;
|
||||||
.await?,
|
Ok(Json(value))
|
||||||
))
|
}
|
||||||
|
|
||||||
|
fn source_label(source: &str) -> &'static str {
|
||||||
|
match source {
|
||||||
|
"runninghub" => "Runninghub",
|
||||||
|
"selfhost" => "Selfhost",
|
||||||
|
"comfy" => "Comfy",
|
||||||
|
_ => "Workflow",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pixelle_list_templates(
|
async fn pixelle_list_templates(
|
||||||
@@ -3254,9 +3466,40 @@ async fn pixelle_preview_image(
|
|||||||
async fn pixelle_preview_tts(
|
async fn pixelle_preview_tts(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(mut body): Json<Value>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
resolve_auth(&headers, &state).await?;
|
resolve_auth(&headers, &state).await?;
|
||||||
|
if let Some(object) = body.as_object_mut() {
|
||||||
|
if !object.contains_key("inference_mode") {
|
||||||
|
if let Some(value) = object.get("tts_inference_mode").cloned() {
|
||||||
|
object.insert("inference_mode".to_string(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !object.contains_key("voice") {
|
||||||
|
if let Some(value) = object.get("tts_voice").cloned() {
|
||||||
|
object.insert("voice".to_string(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !object.contains_key("voice_id") {
|
||||||
|
if let Some(value) = object.get("voice").or_else(|| object.get("tts_voice")).cloned() {
|
||||||
|
object.insert("voice_id".to_string(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if object
|
||||||
|
.get("inference_mode")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case("local"))
|
||||||
|
{
|
||||||
|
object.remove("workflow");
|
||||||
|
object.remove("tts_workflow");
|
||||||
|
object.remove("ref_audio");
|
||||||
|
}
|
||||||
|
if !object.contains_key("speed") {
|
||||||
|
if let Some(value) = object.get("tts_speed").cloned() {
|
||||||
|
object.insert("speed".to_string(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
pixelle_json(&state, Method::POST, "/api/tts/synthesize", Some(&body)).await?,
|
pixelle_json(&state, Method::POST, "/api/tts/synthesize", Some(&body)).await?,
|
||||||
))
|
))
|
||||||
@@ -3303,6 +3546,23 @@ async fn pixelle_proxy_file(
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(AppError::BadRequest("file path is required".into()));
|
return Err(AppError::BadRequest("file path is required".into()));
|
||||||
}
|
}
|
||||||
|
let project_root = pixelle_project_root(&state);
|
||||||
|
let local_path = project_root.join(trimmed);
|
||||||
|
if local_path.starts_with(&project_root) && local_path.is_file() {
|
||||||
|
let bytes = tokio::fs::read(&local_path).await.map_err(|err| {
|
||||||
|
AppError::Internal(anyhow::anyhow!("Failed to read Pixelle local file: {err}"))
|
||||||
|
})?;
|
||||||
|
let content_type = MimeGuess::from_path(&local_path)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.essence_str()
|
||||||
|
.to_string();
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, content_type)],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
// Pixelle path-encodes segments separately; rebuild safely.
|
// Pixelle path-encodes segments separately; rebuild safely.
|
||||||
let encoded = trimmed
|
let encoded = trimmed
|
||||||
.split('/')
|
.split('/')
|
||||||
|
|||||||
@@ -219,11 +219,15 @@ async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<Ev
|
|||||||
l.id AS related_id,
|
l.id AS related_id,
|
||||||
'proxy_log' AS related_type,
|
'proxy_log' AS related_type,
|
||||||
l.created_at AS created_at
|
l.created_at AS created_at
|
||||||
FROM proxy_logs l
|
FROM (
|
||||||
|
SELECT *
|
||||||
|
FROM proxy_logs
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
) l
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
LEFT JOIN sites s ON s.id = a.site_id
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
ORDER BY l.created_at DESC
|
ORDER BY l.created_at DESC, l.id DESC
|
||||||
LIMIT ?
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
|||||||
+85
-17
@@ -248,12 +248,22 @@ async fn is_job_cancelled(state: &AppState, id: &str) -> bool {
|
|||||||
struct ImageJobRequest {
|
struct ImageJobRequest {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
image_urls: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
width: Option<i64>,
|
width: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
height: Option<i64>,
|
height: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
size: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
scale: Option<f64>,
|
scale: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
min_ratio: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_ratio: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
force_single: Option<bool>,
|
force_single: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +272,8 @@ struct VideoJobRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
image_urls: Vec<String>,
|
image_urls: Vec<String>,
|
||||||
#[serde(default = "default_video_frames")]
|
#[serde(default = "default_video_frames")]
|
||||||
frames: i64,
|
frames: i64,
|
||||||
@@ -285,9 +297,14 @@ async fn create_image_job(
|
|||||||
|
|
||||||
let image_req = ImageRequest {
|
let image_req = ImageRequest {
|
||||||
prompt: req.prompt,
|
prompt: req.prompt,
|
||||||
|
model: req.model,
|
||||||
|
image_urls: req.image_urls,
|
||||||
width: req.width,
|
width: req.width,
|
||||||
height: req.height,
|
height: req.height,
|
||||||
|
size: req.size,
|
||||||
scale: req.scale,
|
scale: req.scale,
|
||||||
|
min_ratio: req.min_ratio,
|
||||||
|
max_ratio: req.max_ratio,
|
||||||
force_single: req.force_single,
|
force_single: req.force_single,
|
||||||
};
|
};
|
||||||
// Validate eagerly so the caller gets a 400 instead of a failed job.
|
// Validate eagerly so the caller gets a 400 instead of a failed job.
|
||||||
@@ -301,7 +318,17 @@ async fn create_image_job(
|
|||||||
JimengCapability::Image,
|
JimengCapability::Image,
|
||||||
body,
|
body,
|
||||||
title,
|
title,
|
||||||
json!({ "prompt": image_req.prompt }),
|
json!({
|
||||||
|
"prompt": image_req.prompt,
|
||||||
|
"model": image_req.model,
|
||||||
|
"imageUrls": image_req.image_urls,
|
||||||
|
"width": image_req.width,
|
||||||
|
"height": image_req.height,
|
||||||
|
"size": image_req.size,
|
||||||
|
"scale": image_req.scale,
|
||||||
|
"minRatio": image_req.min_ratio,
|
||||||
|
"maxRatio": image_req.max_ratio,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -319,6 +346,7 @@ async fn create_video_job(
|
|||||||
|
|
||||||
let video_req = VideoRequest {
|
let video_req = VideoRequest {
|
||||||
prompt: req.prompt,
|
prompt: req.prompt,
|
||||||
|
model: req.model,
|
||||||
image_urls: req.image_urls,
|
image_urls: req.image_urls,
|
||||||
frames: req.frames,
|
frames: req.frames,
|
||||||
aspect_ratio: req.aspect_ratio,
|
aspect_ratio: req.aspect_ratio,
|
||||||
@@ -342,8 +370,10 @@ async fn create_video_job(
|
|||||||
title,
|
title,
|
||||||
json!({
|
json!({
|
||||||
"prompt": video_req.prompt,
|
"prompt": video_req.prompt,
|
||||||
|
"model": video_req.model,
|
||||||
"imageUrls": video_req.image_urls,
|
"imageUrls": video_req.image_urls,
|
||||||
"frames": video_req.frames,
|
"frames": video_req.frames,
|
||||||
|
"aspectRatio": video_req.aspect_ratio,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -432,12 +462,17 @@ async fn spawn_job(
|
|||||||
) -> AppResult<String> {
|
) -> AppResult<String> {
|
||||||
let task_id = format!("{}_{}", capability.id_prefix(), uuid::Uuid::new_v4().simple());
|
let task_id = format!("{}_{}", capability.id_prefix(), uuid::Uuid::new_v4().simple());
|
||||||
let task_type = capability.task_type();
|
let task_type = capability.task_type();
|
||||||
|
let model = body
|
||||||
|
.get("req_key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_else(|| capability.req_key())
|
||||||
|
.to_string();
|
||||||
let initial_result = json!({
|
let initial_result = json!({
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
"keyId": auth.key_id,
|
"keyId": auth.key_id,
|
||||||
"keyName": auth.key_name,
|
"keyName": auth.key_name,
|
||||||
"capability": capability.req_key(),
|
"capability": model,
|
||||||
"request": request_snapshot,
|
"request": request_snapshot,
|
||||||
});
|
});
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -450,7 +485,7 @@ async fn spawn_job(
|
|||||||
.bind(if title.trim().is_empty() { "即梦生成任务" } else { title.trim() })
|
.bind(if title.trim().is_empty() { "即梦生成任务" } else { title.trim() })
|
||||||
.bind(format!(
|
.bind(format!(
|
||||||
"jimeng:{}:{}",
|
"jimeng:{}:{}",
|
||||||
capability.req_key(),
|
model,
|
||||||
auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())
|
auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())
|
||||||
))
|
))
|
||||||
.bind(initial_result.to_string())
|
.bind(initial_result.to_string())
|
||||||
@@ -488,17 +523,38 @@ async fn run_worker(
|
|||||||
task_id: String,
|
task_id: String,
|
||||||
base_result: Value,
|
base_result: Value,
|
||||||
) {
|
) {
|
||||||
let model = capability.req_key().to_string();
|
let model = body
|
||||||
|
.get("req_key")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_else(|| capability.req_key())
|
||||||
|
.to_string();
|
||||||
let outcome = drive(&state, &provider, capability, &body, &task_id).await;
|
let outcome = drive(&state, &provider, capability, &body, &task_id).await;
|
||||||
|
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
update_job_progress(&state, &task_id, 3, "生成完成,正在转存到工作空间").await;
|
update_job_progress(&state, &task_id, 3, "生成完成,正在转存到工作空间").await;
|
||||||
let assets = store_outputs(&state, &auth, capability, &output).await;
|
let assets = store_outputs(&state, &auth, capability, &model, &output).await;
|
||||||
let mut final_result = base_result.clone();
|
let mut final_result = base_result.clone();
|
||||||
if let Some(obj) = final_result.as_object_mut() {
|
if let Some(obj) = final_result.as_object_mut() {
|
||||||
obj.insert("assets".into(), json!(assets));
|
obj.insert("assets".into(), json!(assets));
|
||||||
}
|
}
|
||||||
|
if !assets_have_completed(&assets) {
|
||||||
|
let error = "产物转存失败,未生成可用工作空间文件";
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE background_tasks
|
||||||
|
SET status = 'failed', message = '生成完成但转存失败', error = ?,
|
||||||
|
result_json = ?, updated_at = datetime('now'), finished_at = datetime('now')
|
||||||
|
WHERE id = ? AND status IN ('pending', 'running')",
|
||||||
|
)
|
||||||
|
.bind(error)
|
||||||
|
.bind(final_result.to_string())
|
||||||
|
.bind(&task_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await;
|
||||||
|
record_billing(&state, &auth, capability, &model, false, Some(error.to_string()))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE background_tasks
|
"UPDATE background_tasks
|
||||||
SET status = 'succeeded', message = '生成完成', result_json = ?, progress = 4,
|
SET status = 'succeeded', message = '生成完成', result_json = ?, progress = 4,
|
||||||
@@ -583,29 +639,29 @@ async fn store_outputs(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
auth: &JimengAuth,
|
auth: &JimengAuth,
|
||||||
capability: JimengCapability,
|
capability: JimengCapability,
|
||||||
|
model: &str,
|
||||||
output: &JimengOutput,
|
output: &JimengOutput,
|
||||||
) -> Vec<Value> {
|
) -> Vec<Value> {
|
||||||
let model = capability.req_key().to_string();
|
|
||||||
let mut assets = Vec::new();
|
let mut assets = Vec::new();
|
||||||
match output {
|
match output {
|
||||||
JimengOutput::Image { urls, base64 } => {
|
JimengOutput::Image { urls, base64 } => {
|
||||||
for url in urls {
|
for url in urls {
|
||||||
let input = asset_input(auth, capability, &model, Some(url.clone()));
|
let input = asset_input(auth, capability, model, Some(url.clone()));
|
||||||
let stored = record_remote_asset(state, input).await;
|
let stored = record_remote_asset(state, input).await;
|
||||||
assets.push(asset_to_json(stored));
|
assets.push(asset_to_json(stored, auth.user_id.is_some()));
|
||||||
}
|
}
|
||||||
for data in base64 {
|
for data in base64 {
|
||||||
let input = asset_input(auth, capability, &model, None);
|
let input = asset_input(auth, capability, model, None);
|
||||||
match record_base64_asset(state, input, data).await {
|
match record_base64_asset(state, input, data).await {
|
||||||
Ok(stored) => assets.push(asset_to_json(Ok(stored))),
|
Ok(stored) => assets.push(asset_to_json(Ok(stored), auth.user_id.is_some())),
|
||||||
Err(err) => assets.push(json!({ "status": "failed", "error": err.to_string() })),
|
Err(err) => assets.push(json!({ "status": "failed", "error": err.to_string() })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
JimengOutput::Video { url } => {
|
JimengOutput::Video { url } => {
|
||||||
let input = asset_input(auth, capability, &model, Some(url.clone()));
|
let input = asset_input(auth, capability, model, Some(url.clone()));
|
||||||
let stored = record_remote_asset(state, input).await;
|
let stored = record_remote_asset(state, input).await;
|
||||||
assets.push(asset_to_json(stored));
|
assets.push(asset_to_json(stored, auth.user_id.is_some()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assets
|
assets
|
||||||
@@ -625,19 +681,34 @@ fn asset_input(
|
|||||||
prompt: None,
|
prompt: None,
|
||||||
model: Some(model.to_string()),
|
model: Some(model.to_string()),
|
||||||
remote_url,
|
remote_url,
|
||||||
metadata: json!({ "jimeng": { "reqKey": model } }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"jimeng": { "reqKey": model },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assets_have_completed(assets: &[Value]) -> bool {
|
||||||
|
assets
|
||||||
|
.iter()
|
||||||
|
.any(|asset| asset.get("status").and_then(Value::as_str) == Some("completed"))
|
||||||
|
}
|
||||||
|
|
||||||
fn asset_to_json(
|
fn asset_to_json(
|
||||||
stored: Result<crate::services::workspace::StoredWorkspaceAsset, AppError>,
|
stored: Result<crate::services::workspace::StoredWorkspaceAsset, AppError>,
|
||||||
|
is_user_asset: bool,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
match stored {
|
match stored {
|
||||||
Ok(asset) => json!({
|
Ok(asset) if is_user_asset => json!({
|
||||||
"publicId": asset.public_id,
|
"publicId": asset.public_id,
|
||||||
"status": asset.status,
|
"status": asset.status,
|
||||||
"fileUrl": format!("/api/user/workspace/assets/{}/file", asset.public_id),
|
"fileUrl": format!("/api/user/workspace/assets/{}/file", asset.public_id),
|
||||||
}),
|
}),
|
||||||
|
Ok(asset) => json!({
|
||||||
|
"publicId": asset.public_id,
|
||||||
|
"status": asset.status,
|
||||||
|
"fileUrl": format!("/api/workspace/assets/{}/file", asset.public_id),
|
||||||
|
}),
|
||||||
Err(err) => json!({ "status": "failed", "error": err.to_string() }),
|
Err(err) => json!({ "status": "failed", "error": err.to_string() }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,6 +751,3 @@ async fn record_billing(
|
|||||||
tracing::warn!(%err, "jimeng billing record failed");
|
tracing::warn!(%err, "jimeng billing record failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-22
@@ -281,21 +281,26 @@ async fn gemini_list_models(
|
|||||||
let _ = check_gemini_auth(&state, &headers, &query).await?;
|
let _ = check_gemini_auth(&state, &headers, &query).await?;
|
||||||
|
|
||||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
"SELECT DISTINCT
|
"SELECT
|
||||||
tr.model_pattern,
|
tr.model_pattern,
|
||||||
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||||
FROM token_routes tr
|
FROM token_routes tr
|
||||||
JOIN route_channels rc ON rc.route_id = tr.id
|
|
||||||
JOIN accounts a ON a.id = rc.account_id
|
|
||||||
JOIN sites s ON s.id = a.site_id
|
|
||||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
|
||||||
WHERE tr.enabled = 1
|
WHERE tr.enabled = 1
|
||||||
AND rc.enabled = 1
|
AND EXISTS (
|
||||||
AND a.status = 'active'
|
SELECT 1
|
||||||
AND s.status = 'active'
|
FROM route_channels rc
|
||||||
AND TRIM(COALESCE(s.url, '')) <> ''
|
JOIN accounts a ON a.id = rc.account_id
|
||||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
JOIN sites s ON s.id = a.site_id
|
||||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||||
|
WHERE rc.route_id = tr.id
|
||||||
|
AND rc.enabled = 1
|
||||||
|
AND a.status = 'active'
|
||||||
|
AND s.status = 'active'
|
||||||
|
AND s.url IS NOT NULL
|
||||||
|
AND s.url <> ''
|
||||||
|
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||||
|
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||||
|
)
|
||||||
ORDER BY tr.id",
|
ORDER BY tr.id",
|
||||||
)
|
)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
@@ -775,21 +780,26 @@ async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppRe
|
|||||||
let _ = check_auth(&state, &headers).await?;
|
let _ = check_auth(&state, &headers).await?;
|
||||||
|
|
||||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
"SELECT DISTINCT
|
"SELECT
|
||||||
tr.model_pattern,
|
tr.model_pattern,
|
||||||
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||||
FROM token_routes tr
|
FROM token_routes tr
|
||||||
JOIN route_channels rc ON rc.route_id = tr.id
|
|
||||||
JOIN accounts a ON a.id = rc.account_id
|
|
||||||
JOIN sites s ON s.id = a.site_id
|
|
||||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
|
||||||
WHERE tr.enabled = 1
|
WHERE tr.enabled = 1
|
||||||
AND rc.enabled = 1
|
AND EXISTS (
|
||||||
AND a.status = 'active'
|
SELECT 1
|
||||||
AND s.status = 'active'
|
FROM route_channels rc
|
||||||
AND TRIM(COALESCE(s.url, '')) <> ''
|
JOIN accounts a ON a.id = rc.account_id
|
||||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
JOIN sites s ON s.id = a.site_id
|
||||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||||
|
WHERE rc.route_id = tr.id
|
||||||
|
AND rc.enabled = 1
|
||||||
|
AND a.status = 'active'
|
||||||
|
AND s.status = 'active'
|
||||||
|
AND s.url IS NOT NULL
|
||||||
|
AND s.url <> ''
|
||||||
|
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||||
|
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||||
|
)
|
||||||
ORDER BY tr.id",
|
ORDER BY tr.id",
|
||||||
)
|
)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
|
|||||||
@@ -27,6 +27,22 @@ pub struct AgentSession {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight row for the session history list.
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AgentSessionSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: Option<i64>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub model: String,
|
||||||
|
pub permission_mode: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub num_turns: i64,
|
||||||
|
pub message_count: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Token/cost delta produced by one completed agent turn.
|
/// Token/cost delta produced by one completed agent turn.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct UsageDelta {
|
pub struct UsageDelta {
|
||||||
@@ -93,6 +109,33 @@ pub async fn record_turn(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set a human-readable title once, preserving any existing title.
|
||||||
|
pub async fn set_title_if_empty(
|
||||||
|
db: &SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
title: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let trimmed = title.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE agent_sessions
|
||||||
|
SET title = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id IS ?
|
||||||
|
AND (title IS NULL OR trim(title) = '')",
|
||||||
|
)
|
||||||
|
.bind(trimmed.chars().take(80).collect::<String>())
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `user_id` may use `session_id` for a new turn.
|
/// Whether `user_id` may use `session_id` for a new turn.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` when the id is free (no row yet) or already owned by this
|
/// Returns `Ok(true)` when the id is free (no row yet) or already owned by this
|
||||||
@@ -185,6 +228,32 @@ pub async fn get_session(
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List recent sessions scoped to the caller. Admin/global-token callers pass
|
||||||
|
/// `None` and see only admin-owned sessions (`user_id IS NULL`), matching the
|
||||||
|
/// ownership semantics used by chat resume.
|
||||||
|
pub async fn list_sessions(
|
||||||
|
db: &SqlitePool,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<AgentSessionSummary>> {
|
||||||
|
let limit = limit.clamp(1, 100);
|
||||||
|
let rows = sqlx::query_as::<_, AgentSessionSummary>(
|
||||||
|
"SELECT id, user_id, title, model, permission_mode, total_tokens, cost_usd,
|
||||||
|
num_turns, message_count, created_at, updated_at
|
||||||
|
FROM agent_sessions
|
||||||
|
WHERE ((? IS NULL AND user_id IS NULL) OR user_id = ?)
|
||||||
|
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -259,6 +328,25 @@ mod tests {
|
|||||||
assert_eq!(s.message_count, 6);
|
assert_eq!(s.message_count, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn title_is_set_once_and_session_list_is_scoped() {
|
||||||
|
let db = pool().await;
|
||||||
|
ensure_session(&db, "s-user", Some(7), "m", "default").await.unwrap();
|
||||||
|
ensure_session(&db, "s-admin", None, "m", "default").await.unwrap();
|
||||||
|
|
||||||
|
set_title_if_empty(&db, "s-user", Some(7), " first title ").await.unwrap();
|
||||||
|
set_title_if_empty(&db, "s-user", Some(7), "second title").await.unwrap();
|
||||||
|
|
||||||
|
let owned = list_sessions(&db, Some(7), 20).await.unwrap();
|
||||||
|
assert_eq!(owned.len(), 1);
|
||||||
|
assert_eq!(owned[0].id, "s-user");
|
||||||
|
assert_eq!(owned[0].title.as_deref(), Some("first title"));
|
||||||
|
|
||||||
|
let admin = list_sessions(&db, None, 20).await.unwrap();
|
||||||
|
assert_eq!(admin.len(), 1);
|
||||||
|
assert_eq!(admin[0].id, "s-admin");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_session_is_user_scoped() {
|
async fn get_session_is_user_scoped() {
|
||||||
let db = pool().await;
|
let db = pool().await;
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ impl UsageTracker {
|
|||||||
event: ComfyTaskBillingEvent,
|
event: ComfyTaskBillingEvent,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
Self::ensure_schema(&self.db).await?;
|
Self::ensure_schema(&self.db).await?;
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
|
||||||
Self::ensure_proxy_log_task_columns(&self.db).await?;
|
Self::ensure_proxy_log_task_columns(&self.db).await?;
|
||||||
let prompt_tokens = event.prompt_tokens.max(0);
|
let prompt_tokens = event.prompt_tokens.max(0);
|
||||||
let completion_tokens = event.completion_tokens.max(0);
|
let completion_tokens = event.completion_tokens.max(0);
|
||||||
@@ -222,6 +223,33 @@ impl UsageTracker {
|
|||||||
.bind(key_id)
|
.bind(key_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||||
|
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(key_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if let Some((Some(user_id), max_cost, used_cost)) = owner {
|
||||||
|
let used = used_cost.unwrap_or(0.0);
|
||||||
|
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
|
||||||
|
crate::services::billing::ledger::record_ledger_entry(
|
||||||
|
&mut tx,
|
||||||
|
crate::services::billing::ledger::LedgerEntry {
|
||||||
|
user_id,
|
||||||
|
track: "usd",
|
||||||
|
entry_type: "consume",
|
||||||
|
amount: -cost,
|
||||||
|
balance_after: remaining,
|
||||||
|
ref_type: Some("usage_log"),
|
||||||
|
ref_id: None,
|
||||||
|
model: Some(&event.model),
|
||||||
|
operator: Some("system"),
|
||||||
|
note: Some("comfy_task"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1077,12 +1105,14 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn records_comfy_task_success_as_usage_and_proxy_log() {
|
async fn records_comfy_task_success_as_usage_and_proxy_log() {
|
||||||
let pool = pool().await;
|
let pool = billing_test_pool().await;
|
||||||
create_proxy_logs(&pool).await;
|
create_proxy_logs(&pool).await;
|
||||||
let tracker = UsageTracker::new(Arc::new(pool));
|
let tracker = UsageTracker::new(Arc::new(pool));
|
||||||
|
let mut event = test_comfy_event(0.35);
|
||||||
|
event.key_id = Some(7);
|
||||||
|
|
||||||
let billing = tracker
|
let billing = tracker
|
||||||
.record_comfy_task_success(test_comfy_event(0.35))
|
.record_comfy_task_success(event)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1091,7 +1121,7 @@ mod tests {
|
|||||||
assert_eq!(billing["totalCost"], 0.35);
|
assert_eq!(billing["totalCost"], 0.35);
|
||||||
|
|
||||||
let usage: (i64, f64) = sqlx::query_as(
|
let usage: (i64, f64) = sqlx::query_as(
|
||||||
"SELECT COUNT(*), COALESCE(SUM(cost), 0.0) FROM usage_logs WHERE downstream_key_id = 1",
|
"SELECT COUNT(*), COALESCE(SUM(cost), 0.0) FROM usage_logs WHERE downstream_key_id = 7",
|
||||||
)
|
)
|
||||||
.fetch_one(&*tracker.db)
|
.fetch_one(&*tracker.db)
|
||||||
.await
|
.await
|
||||||
@@ -1099,14 +1129,28 @@ mod tests {
|
|||||||
assert_eq!(usage, (1, 0.35));
|
assert_eq!(usage, (1, 0.35));
|
||||||
|
|
||||||
let key_usage: (i64, f64) =
|
let key_usage: (i64, f64) =
|
||||||
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 1")
|
sqlx::query_as("SELECT used_requests, used_cost FROM downstream_api_keys WHERE id = 7")
|
||||||
.fetch_one(&*tracker.db)
|
.fetch_one(&*tracker.db)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(key_usage, (1, 0.35));
|
assert_eq!(key_usage, (1, 2.35));
|
||||||
|
|
||||||
|
let ledger: (String, String, f64, f64, Option<String>, Option<String>) = sqlx::query_as(
|
||||||
|
"SELECT track, entry_type, amount, balance_after, model, note
|
||||||
|
FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&*tracker.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ledger.0, "usd");
|
||||||
|
assert_eq!(ledger.1, "consume");
|
||||||
|
assert_eq!(ledger.2, -0.35);
|
||||||
|
assert_eq!(ledger.3, 7.65);
|
||||||
|
assert_eq!(ledger.4.as_deref(), Some("seedance-comfy"));
|
||||||
|
assert_eq!(ledger.5.as_deref(), Some("comfy_task"));
|
||||||
|
|
||||||
let proxy_log: (String, f64, String, String) = sqlx::query_as(
|
let proxy_log: (String, f64, String, String) = sqlx::query_as(
|
||||||
"SELECT status, estimated_cost, client_app_id, billing_details FROM proxy_logs WHERE downstream_api_key_id = 1",
|
"SELECT status, estimated_cost, client_app_id, billing_details FROM proxy_logs WHERE downstream_api_key_id = 7",
|
||||||
)
|
)
|
||||||
.fetch_one(&*tracker.db)
|
.fetch_one(&*tracker.db)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -343,20 +343,54 @@ pub fn build_image_body(req: &ImageRequest) -> Result<Value, JimengError> {
|
|||||||
if req.prompt.trim().is_empty() {
|
if req.prompt.trim().is_empty() {
|
||||||
return Err(JimengError::Invalid("prompt is required".into()));
|
return Err(JimengError::Invalid("prompt is required".into()));
|
||||||
}
|
}
|
||||||
|
let image_urls = req
|
||||||
|
.image_urls
|
||||||
|
.iter()
|
||||||
|
.map(|url| url.trim())
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if image_urls.len() > 10 {
|
||||||
|
return Err(JimengError::Invalid(
|
||||||
|
"image_urls supports at most 10 images".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if req.size.is_some_and(|size| size <= 0) {
|
||||||
|
return Err(JimengError::Invalid("size must be greater than 0".into()));
|
||||||
|
}
|
||||||
|
for (name, ratio) in [("min_ratio", req.min_ratio), ("max_ratio", req.max_ratio)] {
|
||||||
|
if ratio.is_some_and(|value| value <= 0.0) {
|
||||||
|
return Err(JimengError::Invalid(format!(
|
||||||
|
"{name} must be greater than 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let req_key = types::normalize_req_key(JimengCapability::Image, req.model.as_deref());
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
"req_key": JimengCapability::Image.req_key(),
|
"req_key": req_key,
|
||||||
"prompt": req.prompt,
|
"prompt": req.prompt,
|
||||||
});
|
});
|
||||||
let obj = body.as_object_mut().expect("object");
|
let obj = body.as_object_mut().expect("object");
|
||||||
|
if !image_urls.is_empty() {
|
||||||
|
obj.insert("image_urls".into(), json!(image_urls));
|
||||||
|
}
|
||||||
if let Some(width) = req.width {
|
if let Some(width) = req.width {
|
||||||
obj.insert("width".into(), json!(width));
|
obj.insert("width".into(), json!(width));
|
||||||
}
|
}
|
||||||
if let Some(height) = req.height {
|
if let Some(height) = req.height {
|
||||||
obj.insert("height".into(), json!(height));
|
obj.insert("height".into(), json!(height));
|
||||||
}
|
}
|
||||||
|
if let Some(size) = req.size {
|
||||||
|
obj.insert("size".into(), json!(size));
|
||||||
|
}
|
||||||
if let Some(scale) = req.scale {
|
if let Some(scale) = req.scale {
|
||||||
obj.insert("scale".into(), json!(scale));
|
obj.insert("scale".into(), json!(scale));
|
||||||
}
|
}
|
||||||
|
if let Some(min_ratio) = req.min_ratio {
|
||||||
|
obj.insert("min_ratio".into(), json!(min_ratio));
|
||||||
|
}
|
||||||
|
if let Some(max_ratio) = req.max_ratio {
|
||||||
|
obj.insert("max_ratio".into(), json!(max_ratio));
|
||||||
|
}
|
||||||
if let Some(force_single) = req.force_single {
|
if let Some(force_single) = req.force_single {
|
||||||
obj.insert("force_single".into(), json!(force_single));
|
obj.insert("force_single".into(), json!(force_single));
|
||||||
}
|
}
|
||||||
@@ -390,8 +424,9 @@ pub fn build_video_body(req: &VideoRequest) -> Result<Value, JimengError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let req_key = types::normalize_req_key(JimengCapability::Video, req.model.as_deref());
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
"req_key": JimengCapability::Video.req_key(),
|
"req_key": req_key,
|
||||||
"frames": req.frames,
|
"frames": req.frames,
|
||||||
});
|
});
|
||||||
let obj = body.as_object_mut().expect("object");
|
let obj = body.as_object_mut().expect("object");
|
||||||
@@ -417,10 +452,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn image_body_requires_prompt() {
|
fn image_body_requires_prompt() {
|
||||||
let req = ImageRequest {
|
let req = ImageRequest {
|
||||||
|
model: None,
|
||||||
prompt: " ".into(),
|
prompt: " ".into(),
|
||||||
|
image_urls: vec![],
|
||||||
width: None,
|
width: None,
|
||||||
height: None,
|
height: None,
|
||||||
|
size: None,
|
||||||
scale: None,
|
scale: None,
|
||||||
|
min_ratio: None,
|
||||||
|
max_ratio: None,
|
||||||
force_single: None,
|
force_single: None,
|
||||||
};
|
};
|
||||||
assert!(matches!(build_image_body(&req), Err(JimengError::Invalid(_))));
|
assert!(matches!(build_image_body(&req), Err(JimengError::Invalid(_))));
|
||||||
@@ -429,39 +469,73 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn image_body_includes_optional_fields() {
|
fn image_body_includes_optional_fields() {
|
||||||
let req = ImageRequest {
|
let req = ImageRequest {
|
||||||
|
model: None,
|
||||||
prompt: "a cat".into(),
|
prompt: "a cat".into(),
|
||||||
|
image_urls: vec!["https://x/ref.png".into()],
|
||||||
width: Some(2048),
|
width: Some(2048),
|
||||||
height: Some(2048),
|
height: Some(2048),
|
||||||
|
size: Some(4096),
|
||||||
scale: Some(0.5),
|
scale: Some(0.5),
|
||||||
|
min_ratio: Some(0.33),
|
||||||
|
max_ratio: Some(3.0),
|
||||||
force_single: Some(true),
|
force_single: Some(true),
|
||||||
};
|
};
|
||||||
let body = build_image_body(&req).unwrap();
|
let body = build_image_body(&req).unwrap();
|
||||||
assert_eq!(body["req_key"], "jimeng_t2i_v40");
|
assert_eq!(body["req_key"], "jimeng_t2i_v40");
|
||||||
assert_eq!(body["prompt"], "a cat");
|
assert_eq!(body["prompt"], "a cat");
|
||||||
|
assert_eq!(body["image_urls"][0], "https://x/ref.png");
|
||||||
assert_eq!(body["width"], 2048);
|
assert_eq!(body["width"], 2048);
|
||||||
assert_eq!(body["height"], 2048);
|
assert_eq!(body["height"], 2048);
|
||||||
|
assert_eq!(body["size"], 4096);
|
||||||
assert_eq!(body["scale"], 0.5);
|
assert_eq!(body["scale"], 0.5);
|
||||||
|
assert_eq!(body["min_ratio"], 0.33);
|
||||||
|
assert_eq!(body["max_ratio"], 3.0);
|
||||||
assert_eq!(body["force_single"], true);
|
assert_eq!(body["force_single"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_body_omits_absent_optional_fields() {
|
fn image_body_omits_absent_optional_fields() {
|
||||||
let req = ImageRequest {
|
let req = ImageRequest {
|
||||||
|
model: None,
|
||||||
prompt: "a cat".into(),
|
prompt: "a cat".into(),
|
||||||
|
image_urls: vec![],
|
||||||
width: None,
|
width: None,
|
||||||
height: None,
|
height: None,
|
||||||
|
size: None,
|
||||||
scale: None,
|
scale: None,
|
||||||
|
min_ratio: None,
|
||||||
|
max_ratio: None,
|
||||||
force_single: None,
|
force_single: None,
|
||||||
};
|
};
|
||||||
let body = build_image_body(&req).unwrap();
|
let body = build_image_body(&req).unwrap();
|
||||||
let obj = body.as_object().unwrap();
|
let obj = body.as_object().unwrap();
|
||||||
assert!(!obj.contains_key("width"));
|
assert!(!obj.contains_key("width"));
|
||||||
|
assert!(!obj.contains_key("image_urls"));
|
||||||
|
assert!(!obj.contains_key("size"));
|
||||||
assert!(!obj.contains_key("scale"));
|
assert!(!obj.contains_key("scale"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_body_rejects_too_many_reference_images() {
|
||||||
|
let req = ImageRequest {
|
||||||
|
model: None,
|
||||||
|
prompt: "a cat".into(),
|
||||||
|
image_urls: (0..11).map(|idx| format!("https://x/{idx}.png")).collect(),
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
size: None,
|
||||||
|
scale: None,
|
||||||
|
min_ratio: None,
|
||||||
|
max_ratio: None,
|
||||||
|
force_single: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(build_image_body(&req), Err(JimengError::Invalid(_))));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn video_body_requires_prompt_or_image() {
|
fn video_body_requires_prompt_or_image() {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames: 121,
|
frames: 121,
|
||||||
@@ -474,6 +548,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn video_body_accepts_prompt_only() {
|
fn video_body_accepts_prompt_only() {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: Some("horses".into()),
|
prompt: Some("horses".into()),
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames: 121,
|
frames: 121,
|
||||||
@@ -492,6 +567,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn video_body_accepts_image_only() {
|
fn video_body_accepts_image_only() {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
image_urls: vec!["https://x/a.jpg".into()],
|
image_urls: vec!["https://x/a.jpg".into()],
|
||||||
frames: 241,
|
frames: 241,
|
||||||
@@ -507,6 +583,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn video_body_rejects_bad_frames() {
|
fn video_body_rejects_bad_frames() {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: Some("x".into()),
|
prompt: Some("x".into()),
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames: 100,
|
frames: 100,
|
||||||
@@ -519,6 +596,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn video_body_rejects_bad_aspect_ratio() {
|
fn video_body_rejects_bad_aspect_ratio() {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: Some("x".into()),
|
prompt: Some("x".into()),
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames: 121,
|
frames: 121,
|
||||||
@@ -551,7 +629,8 @@ mod tests {
|
|||||||
fn video_body_accepts_all_allowed_aspect_ratios() {
|
fn video_body_accepts_all_allowed_aspect_ratios() {
|
||||||
for ratio in ALLOWED_ASPECT_RATIOS {
|
for ratio in ALLOWED_ASPECT_RATIOS {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
prompt: Some("x".into()),
|
model: None,
|
||||||
|
prompt: Some("x".into()),
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames: 121,
|
frames: 121,
|
||||||
aspect_ratio: Some(ratio.to_string()),
|
aspect_ratio: Some(ratio.to_string()),
|
||||||
@@ -592,7 +671,8 @@ mod tests {
|
|||||||
fn video_body_accepts_both_frame_counts() {
|
fn video_body_accepts_both_frame_counts() {
|
||||||
for frames in ALLOWED_FRAMES {
|
for frames in ALLOWED_FRAMES {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
prompt: Some("x".into()),
|
model: None,
|
||||||
|
prompt: Some("x".into()),
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames,
|
frames,
|
||||||
aspect_ratio: None,
|
aspect_ratio: None,
|
||||||
@@ -609,6 +689,7 @@ mod tests {
|
|||||||
// Both inputs supplied (image-to-video with a guiding prompt): both end
|
// Both inputs supplied (image-to-video with a guiding prompt): both end
|
||||||
// up in the body.
|
// up in the body.
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: Some("a dragon".into()),
|
prompt: Some("a dragon".into()),
|
||||||
image_urls: vec!["https://x/a.jpg".into(), "https://x/b.jpg".into()],
|
image_urls: vec!["https://x/a.jpg".into(), "https://x/b.jpg".into()],
|
||||||
frames: 241,
|
frames: 241,
|
||||||
@@ -627,6 +708,7 @@ mod tests {
|
|||||||
// A whitespace-only prompt is not a real prompt: it must not be written
|
// A whitespace-only prompt is not a real prompt: it must not be written
|
||||||
// to the body, yet the image alone satisfies the "one of" requirement.
|
// to the body, yet the image alone satisfies the "one of" requirement.
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: Some(" ".into()),
|
prompt: Some(" ".into()),
|
||||||
image_urls: vec!["https://x/a.jpg".into()],
|
image_urls: vec!["https://x/a.jpg".into()],
|
||||||
frames: 121,
|
frames: 121,
|
||||||
@@ -642,6 +724,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn video_body_omits_absent_optional_fields() {
|
fn video_body_omits_absent_optional_fields() {
|
||||||
let req = VideoRequest {
|
let req = VideoRequest {
|
||||||
|
model: None,
|
||||||
prompt: Some("x".into()),
|
prompt: Some("x".into()),
|
||||||
image_urls: vec![],
|
image_urls: vec![],
|
||||||
frames: 121,
|
frames: 121,
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ impl JimengCapability {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn normalize_req_key(capability: JimengCapability, raw: Option<&str>) -> String {
|
||||||
|
let value = raw.unwrap_or("").trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return capability.req_key().to_string();
|
||||||
|
}
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// The `data.status` field returned by the GetResult action.
|
/// The `data.status` field returned by the GetResult action.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TaskStatus {
|
pub enum TaskStatus {
|
||||||
@@ -217,12 +225,23 @@ pub enum JimengOutput {
|
|||||||
pub struct ImageRequest {
|
pub struct ImageRequest {
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Optional reference images for image-to-image / multi-image generation.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub image_urls: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub width: Option<i64>,
|
pub width: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub height: Option<i64>,
|
pub height: Option<i64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<i64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub scale: Option<f64>,
|
pub scale: Option<f64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub min_ratio: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_ratio: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub force_single: Option<bool>,
|
pub force_single: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +250,8 @@ pub struct ImageRequest {
|
|||||||
pub struct VideoRequest {
|
pub struct VideoRequest {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt: Option<String>,
|
pub prompt: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
/// First-frame image URLs for image-to-video (public URLs only in v1).
|
/// First-frame image URLs for image-to-video (public URLs only in v1).
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub image_urls: Vec<String>,
|
pub image_urls: Vec<String>,
|
||||||
|
|||||||
+19
-1
@@ -2048,6 +2048,24 @@ export const api = {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
uploadWorkflow: (file: File, data?: { source?: string; mediaType?: string }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", file);
|
||||||
|
formData.set("source", data?.source || "runninghub");
|
||||||
|
formData.set("media_type", data?.mediaType || "image");
|
||||||
|
return request<{
|
||||||
|
success: boolean;
|
||||||
|
workflow: { name: string; display_name?: string; source?: string; path?: string; key?: string; media_type?: string };
|
||||||
|
}>(
|
||||||
|
"/api/digital-human/pixelle/workflows/upload",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
preserveAuthErrors: true,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
getTemplateParams: (template: string) =>
|
getTemplateParams: (template: string) =>
|
||||||
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
||||||
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
||||||
@@ -2071,7 +2089,7 @@ export const api = {
|
|||||||
timeoutMs: 180_000,
|
timeoutMs: 180_000,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
previewTts: (data: { text: string; inference_mode?: string; workflow?: string; voice?: string; speed?: number; ref_audio?: string }) =>
|
||||||
request<any>(
|
request<any>(
|
||||||
"/api/digital-human/pixelle/preview-tts",
|
"/api/digital-human/pixelle/preview-tts",
|
||||||
{
|
{
|
||||||
|
|||||||
+1278
File diff suppressed because it is too large
Load Diff
+1260
-298
File diff suppressed because it is too large
Load Diff
@@ -595,8 +595,9 @@ export default function Dashboard({
|
|||||||
const siteAvailability = showInactiveSites
|
const siteAvailability = showInactiveSites
|
||||||
? [...activeSites, ...inactiveSites]
|
? [...activeSites, ...inactiveSites]
|
||||||
: activeSites;
|
: activeSites;
|
||||||
|
const proxyFailed24h = Math.max(0, proxy24hTotal - proxy24hSuccess);
|
||||||
const proxySuccessRate =
|
const proxySuccessRate =
|
||||||
proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 0;
|
proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 100;
|
||||||
const activeAccountRate =
|
const activeAccountRate =
|
||||||
totalAccounts > 0 ? (activeAccounts / totalAccounts) * 100 : 0;
|
totalAccounts > 0 ? (activeAccounts / totalAccounts) * 100 : 0;
|
||||||
const siteRiskCount = activeSites.filter((site) => {
|
const siteRiskCount = activeSites.filter((site) => {
|
||||||
@@ -643,15 +644,16 @@ export default function Dashboard({
|
|||||||
(item) => item.health.tone === "danger" || item.health.tone === "warning",
|
(item) => item.health.tone === "danger" || item.health.tone === "warning",
|
||||||
);
|
);
|
||||||
const routeRiskCount = routeRiskItems.length;
|
const routeRiskCount = routeRiskItems.length;
|
||||||
|
const hasDangerRoute = routeHealthCounts.danger > 0;
|
||||||
const overallHealthTone =
|
const overallHealthTone =
|
||||||
proxySuccessRate >= 99 && siteRiskCount === 0 && routeRiskCount === 0
|
proxyFailed24h > 0 || hasDangerRoute
|
||||||
? "success"
|
? "danger"
|
||||||
: proxySuccessRate >= 95 && siteRiskCount <= 2 && routeHealthCounts.danger === 0
|
: proxySuccessRate < 99 || siteRiskCount > 0 || routeRiskCount > 0
|
||||||
? "warning"
|
? "warning"
|
||||||
: "danger";
|
: "success";
|
||||||
const overallHealthLabel =
|
const overallHealthLabel =
|
||||||
overallHealthTone === "success"
|
overallHealthTone === "success"
|
||||||
? t("稳定")
|
? t("健康")
|
||||||
: overallHealthTone === "warning"
|
: overallHealthTone === "warning"
|
||||||
? t("需关注")
|
? t("需关注")
|
||||||
: t("异常");
|
: t("异常");
|
||||||
@@ -665,7 +667,6 @@ export default function Dashboard({
|
|||||||
requestsPerMinute > 0
|
requestsPerMinute > 0
|
||||||
? `${Math.round(requestsPerMinute).toLocaleString()} RPM`
|
? `${Math.round(requestsPerMinute).toLocaleString()} RPM`
|
||||||
: t("暂无流量");
|
: t("暂无流量");
|
||||||
const proxyFailed24h = Math.max(0, proxy24hTotal - proxy24hSuccess);
|
|
||||||
const avgActiveSiteLatency = activeSites.length > 0
|
const avgActiveSiteLatency = activeSites.length > 0
|
||||||
? Math.round(
|
? Math.round(
|
||||||
activeSites.reduce(
|
activeSites.reduce(
|
||||||
|
|||||||
+460
-96
@@ -29,11 +29,16 @@ type DigitalHumanDraft = {
|
|||||||
nScenes?: number;
|
nScenes?: number;
|
||||||
bgmPath?: string;
|
bgmPath?: string;
|
||||||
bgmVolume?: number;
|
bgmVolume?: number;
|
||||||
|
ttsInferenceMode?: TtsInferenceMode;
|
||||||
|
ttsVoice?: string;
|
||||||
|
ttsSpeed?: number;
|
||||||
ttsWorkflow?: string;
|
ttsWorkflow?: string;
|
||||||
refAudioPath?: string;
|
refAudioPath?: string;
|
||||||
previewTtsText?: string;
|
previewTtsText?: string;
|
||||||
imageWorkflow?: string;
|
imageWorkflow?: string;
|
||||||
apiVideoWorkflow?: string;
|
apiVideoWorkflow?: string;
|
||||||
|
referenceImagePath?: string;
|
||||||
|
referenceVideoPath?: string;
|
||||||
llmModel?: string;
|
llmModel?: string;
|
||||||
imageWidth?: number;
|
imageWidth?: number;
|
||||||
imageHeight?: number;
|
imageHeight?: number;
|
||||||
@@ -46,6 +51,19 @@ type DigitalHumanDraft = {
|
|||||||
const DEFAULT_PROMPT_PREFIX =
|
const DEFAULT_PROMPT_PREFIX =
|
||||||
'Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style';
|
'Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style';
|
||||||
const DIGITAL_HUMAN_DRAFT_KEY = 'boosapi:digital-human:draft:v1';
|
const DIGITAL_HUMAN_DRAFT_KEY = 'boosapi:digital-human:draft:v1';
|
||||||
|
type TtsInferenceMode = 'local' | 'comfyui';
|
||||||
|
|
||||||
|
const EDGE_TTS_VOICES = [
|
||||||
|
{ id: 'zh-CN-YunjianNeural', label: '男声-专业(云健)' },
|
||||||
|
{ id: 'zh-CN-XiaoxiaoNeural', label: '女声-自然(晓晓)' },
|
||||||
|
{ id: 'zh-CN-YunxiNeural', label: '男声-活力(云希)' },
|
||||||
|
{ id: 'zh-CN-XiaoyiNeural', label: '女声-明亮(晓伊)' },
|
||||||
|
{ id: 'zh-CN-YunyangNeural', label: '男声-新闻(云扬)' },
|
||||||
|
{ id: 'zh-CN-liaoning-XiaobeiNeural', label: '东北女声(晓北)' },
|
||||||
|
{ id: 'zh-CN-shaanxi-XiaoniNeural', label: '陕西女声(晓妮)' },
|
||||||
|
{ id: 'en-US-JennyNeural', label: 'English - Jenny' },
|
||||||
|
{ id: 'en-US-GuyNeural', label: 'English - Guy' },
|
||||||
|
];
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
if (error instanceof Error && error.message) return error.message;
|
if (error instanceof Error && error.message) return error.message;
|
||||||
@@ -62,6 +80,50 @@ function workflowValue(item: WorkflowItem): string {
|
|||||||
return item.key?.trim() || item.name;
|
return item.key?.trim() || item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVideoWorkflow(item: WorkflowItem): boolean {
|
||||||
|
const key = workflowValue(item).toLowerCase();
|
||||||
|
const name = item.name.toLowerCase();
|
||||||
|
return key.includes('video_') || name.includes('video_') || basename(key).startsWith('af_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowSource(value: string): string {
|
||||||
|
return value.includes('/') ? value.split('/')[0].toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameWorkflowValue(value: string, item: WorkflowItem): boolean {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return [workflowValue(item), item.name, item.display_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((candidate) => String(candidate).trim().toLowerCase() === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAvailableWorkflow(
|
||||||
|
value: string,
|
||||||
|
workflows: WorkflowItem[],
|
||||||
|
predicate?: (item: WorkflowItem) => boolean,
|
||||||
|
): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
const candidates = predicate ? workflows.filter(predicate) : workflows;
|
||||||
|
const exact = candidates.find((item) => isSameWorkflowValue(trimmed, item));
|
||||||
|
if (exact) return workflowValue(exact);
|
||||||
|
|
||||||
|
const source = workflowSource(trimmed);
|
||||||
|
const lowerBase = basename(trimmed).toLowerCase();
|
||||||
|
const sourceCandidates = source
|
||||||
|
? candidates.filter((item) => workflowSource(workflowValue(item)) === source || item.source?.toLowerCase() === source)
|
||||||
|
: candidates;
|
||||||
|
const pool = sourceCandidates.length > 0 ? sourceCandidates : candidates;
|
||||||
|
|
||||||
|
if (lowerBase.includes('scail')) {
|
||||||
|
const scail = pool.find((item) => workflowValue(item).toLowerCase().includes('scail') || item.name.toLowerCase().includes('scail'));
|
||||||
|
if (scail) return workflowValue(scail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function templateValue(item: TemplateItem): string {
|
function templateValue(item: TemplateItem): string {
|
||||||
return item.key?.trim() || item.name;
|
return item.key?.trim() || item.name;
|
||||||
}
|
}
|
||||||
@@ -96,6 +158,10 @@ function writeDigitalHumanDraft(draft: DigitalHumanDraft) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNumber(value: unknown, fallback: number, min?: number, max?: number): number {
|
function normalizeNumber(value: unknown, fallback: number, min?: number, max?: number): number {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric)) return fallback;
|
if (!Number.isFinite(numeric)) return fallback;
|
||||||
@@ -397,11 +463,16 @@ export default function DigitalHuman() {
|
|||||||
const [bgmVolume, setBgmVolume] = useState(() => normalizeNumber(draftRef.current.bgmVolume, 0.3, 0, 1));
|
const [bgmVolume, setBgmVolume] = useState(() => normalizeNumber(draftRef.current.bgmVolume, 0.3, 0, 1));
|
||||||
|
|
||||||
// Middle column — voice & visual
|
// Middle column — voice & visual
|
||||||
|
const [ttsInferenceMode, setTtsInferenceMode] = useState<TtsInferenceMode>(draftRef.current.ttsInferenceMode === 'comfyui' ? 'comfyui' : 'local');
|
||||||
|
const [ttsVoice, setTtsVoice] = useState(draftRef.current.ttsVoice || 'zh-CN-YunjianNeural');
|
||||||
|
const [ttsSpeed, setTtsSpeed] = useState(() => normalizeNumber(draftRef.current.ttsSpeed, 1.2, 0.5, 2));
|
||||||
const [ttsWorkflow, setTtsWorkflow] = useState(draftRef.current.ttsWorkflow || '');
|
const [ttsWorkflow, setTtsWorkflow] = useState(draftRef.current.ttsWorkflow || '');
|
||||||
const [refAudioPath, setRefAudioPath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.refAudioPath) ? '' : draftRef.current.refAudioPath || '');
|
const [refAudioPath, setRefAudioPath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.refAudioPath) ? '' : draftRef.current.refAudioPath || '');
|
||||||
const [previewTtsText, setPreviewTtsText] = useState(draftRef.current.previewTtsText || '你好,欢迎使用 BoosAPI 数字人。');
|
const [previewTtsText, setPreviewTtsText] = useState(draftRef.current.previewTtsText || '你好,欢迎使用 BoosAPI 数字人。');
|
||||||
const [imageWorkflow, setImageWorkflow] = useState(draftRef.current.imageWorkflow || '');
|
const [imageWorkflow, setImageWorkflow] = useState(draftRef.current.imageWorkflow || '');
|
||||||
const [apiVideoWorkflow, setApiVideoWorkflow] = useState(draftRef.current.apiVideoWorkflow || '');
|
const [apiVideoWorkflow, setApiVideoWorkflow] = useState(draftRef.current.apiVideoWorkflow || '');
|
||||||
|
const [referenceImagePath, setReferenceImagePath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.referenceImagePath) ? '' : draftRef.current.referenceImagePath || '');
|
||||||
|
const [referenceVideoPath, setReferenceVideoPath] = useState(isPrivateWorkspaceMediaUrl(draftRef.current.referenceVideoPath) ? '' : draftRef.current.referenceVideoPath || '');
|
||||||
const [llmModel, setLlmModel] = useState(draftRef.current.llmModel || '');
|
const [llmModel, setLlmModel] = useState(draftRef.current.llmModel || '');
|
||||||
const [llmModels, setLlmModels] = useState<MarketplaceModel[]>([]);
|
const [llmModels, setLlmModels] = useState<MarketplaceModel[]>([]);
|
||||||
const [imageWidth, setImageWidth] = useState(() => normalizeNumber(draftRef.current.imageWidth, 1024, 64));
|
const [imageWidth, setImageWidth] = useState(() => normalizeNumber(draftRef.current.imageWidth, 1024, 64));
|
||||||
@@ -418,8 +489,15 @@ export default function DigitalHuman() {
|
|||||||
const [previewError, setPreviewError] = useState('');
|
const [previewError, setPreviewError] = useState('');
|
||||||
const [uploadingAudio, setUploadingAudio] = useState(false);
|
const [uploadingAudio, setUploadingAudio] = useState(false);
|
||||||
const [uploadingBgm, setUploadingBgm] = useState(false);
|
const [uploadingBgm, setUploadingBgm] = useState(false);
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [uploadingVideo, setUploadingVideo] = useState(false);
|
||||||
|
const [uploadingWorkflow, setUploadingWorkflow] = useState(false);
|
||||||
|
const [workflowUploadSource, setWorkflowUploadSource] = useState<'runninghub' | 'selfhost'>('runninghub');
|
||||||
const [uploadMessage, setUploadMessage] = useState('');
|
const [uploadMessage, setUploadMessage] = useState('');
|
||||||
const [bgmUploadMessage, setBgmUploadMessage] = useState('');
|
const [bgmUploadMessage, setBgmUploadMessage] = useState('');
|
||||||
|
const [imageUploadMessage, setImageUploadMessage] = useState('');
|
||||||
|
const [videoUploadMessage, setVideoUploadMessage] = useState('');
|
||||||
|
const [workflowUploadMessage, setWorkflowUploadMessage] = useState('');
|
||||||
|
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [generateError, setGenerateError] = useState('');
|
const [generateError, setGenerateError] = useState('');
|
||||||
@@ -479,6 +557,22 @@ export default function DigitalHuman() {
|
|||||||
setImageWorkflow(workflowValue(imageOne || mediaWorkflows[0]));
|
setImageWorkflow(workflowValue(imageOne || mediaWorkflows[0]));
|
||||||
}
|
}
|
||||||
}, [imageWorkflow, mediaWorkflows]);
|
}, [imageWorkflow, mediaWorkflows]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mediaWorkflows.length) return;
|
||||||
|
if (imageWorkflow && !resolveAvailableWorkflow(imageWorkflow, mediaWorkflows)) {
|
||||||
|
const imageOne = mediaWorkflows.find((w) => !isVideoWorkflow(w) && w.name.toLowerCase().includes('image'));
|
||||||
|
if (imageOne) setImageWorkflow(workflowValue(imageOne));
|
||||||
|
}
|
||||||
|
if (apiVideoWorkflow) {
|
||||||
|
const resolved = resolveAvailableWorkflow(apiVideoWorkflow, mediaWorkflows, isVideoWorkflow);
|
||||||
|
if (resolved && resolved !== apiVideoWorkflow) {
|
||||||
|
setApiVideoWorkflow(resolved);
|
||||||
|
} else if (!resolved) {
|
||||||
|
setApiVideoWorkflow('');
|
||||||
|
setWorkflowUploadMessage(`已清除不存在的视频工作流:${apiVideoWorkflow}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [imageWorkflow, apiVideoWorkflow, mediaWorkflows]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!frameTemplate && templates.length > 0) setFrameTemplate(templateValue(templates[0]));
|
if (!frameTemplate && templates.length > 0) setFrameTemplate(templateValue(templates[0]));
|
||||||
}, [frameTemplate, templates]);
|
}, [frameTemplate, templates]);
|
||||||
@@ -502,11 +596,16 @@ export default function DigitalHuman() {
|
|||||||
nScenes,
|
nScenes,
|
||||||
bgmPath,
|
bgmPath,
|
||||||
bgmVolume,
|
bgmVolume,
|
||||||
|
ttsInferenceMode,
|
||||||
|
ttsVoice,
|
||||||
|
ttsSpeed,
|
||||||
ttsWorkflow,
|
ttsWorkflow,
|
||||||
refAudioPath,
|
refAudioPath,
|
||||||
previewTtsText,
|
previewTtsText,
|
||||||
imageWorkflow,
|
imageWorkflow,
|
||||||
apiVideoWorkflow,
|
apiVideoWorkflow,
|
||||||
|
referenceImagePath,
|
||||||
|
referenceVideoPath,
|
||||||
llmModel,
|
llmModel,
|
||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
@@ -522,11 +621,16 @@ export default function DigitalHuman() {
|
|||||||
nScenes,
|
nScenes,
|
||||||
bgmPath,
|
bgmPath,
|
||||||
bgmVolume,
|
bgmVolume,
|
||||||
|
ttsInferenceMode,
|
||||||
|
ttsVoice,
|
||||||
|
ttsSpeed,
|
||||||
ttsWorkflow,
|
ttsWorkflow,
|
||||||
refAudioPath,
|
refAudioPath,
|
||||||
previewTtsText,
|
previewTtsText,
|
||||||
imageWorkflow,
|
imageWorkflow,
|
||||||
apiVideoWorkflow,
|
apiVideoWorkflow,
|
||||||
|
referenceImagePath,
|
||||||
|
referenceVideoPath,
|
||||||
llmModel,
|
llmModel,
|
||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
@@ -617,17 +721,32 @@ export default function DigitalHuman() {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
setTask(null);
|
setTask(null);
|
||||||
try {
|
try {
|
||||||
|
const resolvedImageWorkflow = resolveAvailableWorkflow(imageWorkflow, mediaWorkflows);
|
||||||
|
const resolvedVideoWorkflow = resolveAvailableWorkflow(apiVideoWorkflow, mediaWorkflows, isVideoWorkflow);
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
prompt: text.trim(),
|
prompt: text.trim(),
|
||||||
mode,
|
mode,
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
n_scenes: nScenes,
|
n_scenes: nScenes,
|
||||||
media_workflow: imageWorkflow || undefined,
|
media_workflow: resolvedImageWorkflow || undefined,
|
||||||
api_video_workflow: apiVideoWorkflow || undefined,
|
api_video_workflow: resolvedVideoWorkflow || undefined,
|
||||||
llm_model: llmModel || undefined,
|
llm_model: llmModel || undefined,
|
||||||
tts_workflow: ttsWorkflow || undefined,
|
tts_inference_mode: ttsInferenceMode,
|
||||||
|
inference_mode: ttsInferenceMode,
|
||||||
|
tts_voice: ttsInferenceMode === 'local' ? ttsVoice : undefined,
|
||||||
|
voice: ttsInferenceMode === 'local' ? ttsVoice : undefined,
|
||||||
|
tts_speed: ttsInferenceMode === 'local' ? ttsSpeed : undefined,
|
||||||
|
speed: ttsInferenceMode === 'local' ? ttsSpeed : undefined,
|
||||||
|
tts_workflow: ttsInferenceMode === 'comfyui' ? (ttsWorkflow || undefined) : undefined,
|
||||||
frame_template: frameTemplate,
|
frame_template: frameTemplate,
|
||||||
|
avatar_url: referenceVideoPath.trim() || referenceImagePath.trim() || undefined,
|
||||||
|
image_url: referenceImagePath.trim() || undefined,
|
||||||
|
image_path: referenceImagePath.trim() || undefined,
|
||||||
|
reference_image_url: referenceImagePath.trim() || undefined,
|
||||||
|
video_url: referenceVideoPath.trim() || undefined,
|
||||||
|
video_path: referenceVideoPath.trim() || undefined,
|
||||||
|
reference_video_url: referenceVideoPath.trim() || undefined,
|
||||||
ratio: selectedTemplateSize.ratio,
|
ratio: selectedTemplateSize.ratio,
|
||||||
resolution: selectedTemplateSize.resolution,
|
resolution: selectedTemplateSize.resolution,
|
||||||
prompt_prefix: promptPrefix.trim() || undefined,
|
prompt_prefix: promptPrefix.trim() || undefined,
|
||||||
@@ -661,8 +780,15 @@ export default function DigitalHuman() {
|
|||||||
title,
|
title,
|
||||||
nScenes,
|
nScenes,
|
||||||
imageWorkflow,
|
imageWorkflow,
|
||||||
|
apiVideoWorkflow,
|
||||||
|
mediaWorkflows,
|
||||||
llmModel,
|
llmModel,
|
||||||
|
ttsInferenceMode,
|
||||||
|
ttsVoice,
|
||||||
|
ttsSpeed,
|
||||||
ttsWorkflow,
|
ttsWorkflow,
|
||||||
|
referenceImagePath,
|
||||||
|
referenceVideoPath,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
videoFps,
|
videoFps,
|
||||||
bgmPath,
|
bgmPath,
|
||||||
@@ -681,8 +807,14 @@ export default function DigitalHuman() {
|
|||||||
setTitle(firstString(payload.title) || '');
|
setTitle(firstString(payload.title) || '');
|
||||||
setNScenes(normalizeNumber(payload.n_scenes ?? payload.nScenes, 5, 1, 20));
|
setNScenes(normalizeNumber(payload.n_scenes ?? payload.nScenes, 5, 1, 20));
|
||||||
setImageWorkflow(firstString(payload.media_workflow, payload.mediaWorkflow, payload.workflow, payload.workflow_key) || '');
|
setImageWorkflow(firstString(payload.media_workflow, payload.mediaWorkflow, payload.workflow, payload.workflow_key) || '');
|
||||||
|
setApiVideoWorkflow(firstString(payload.api_video_workflow, payload.apiVideoWorkflow) || '');
|
||||||
|
setTtsInferenceMode(firstString(payload.tts_inference_mode, payload.inference_mode, payload.ttsInferenceMode) === 'comfyui' ? 'comfyui' : 'local');
|
||||||
|
setTtsVoice(firstString(payload.tts_voice, payload.voice, payload.ttsVoice) || 'zh-CN-YunjianNeural');
|
||||||
|
setTtsSpeed(normalizeNumber(payload.tts_speed ?? payload.speed ?? payload.ttsSpeed, 1.2, 0.5, 2));
|
||||||
setTtsWorkflow(firstString(payload.tts_workflow, payload.ttsWorkflow) || '');
|
setTtsWorkflow(firstString(payload.tts_workflow, payload.ttsWorkflow) || '');
|
||||||
setFrameTemplate(firstString(payload.frame_template, payload.frameTemplate) || '');
|
setFrameTemplate(firstString(payload.frame_template, payload.frameTemplate) || '');
|
||||||
|
setReferenceImagePath(firstString(payload.reference_image_url, payload.image_url, payload.image_path, payload.imagePath, payload.referenceImagePath) || '');
|
||||||
|
setReferenceVideoPath(firstString(payload.reference_video_url, payload.video_url, payload.video_path, payload.videoPath, payload.referenceVideoPath) || '');
|
||||||
setPromptPrefix(firstString(payload.prompt_prefix, payload.promptPrefix) || DEFAULT_PROMPT_PREFIX);
|
setPromptPrefix(firstString(payload.prompt_prefix, payload.promptPrefix) || DEFAULT_PROMPT_PREFIX);
|
||||||
setVideoFps(normalizeNumber(payload.video_fps ?? payload.videoFps, 30, 12, 60));
|
setVideoFps(normalizeNumber(payload.video_fps ?? payload.videoFps, 30, 12, 60));
|
||||||
setBgmPath(firstString(payload.bgm_path, payload.bgmPath) || '');
|
setBgmPath(firstString(payload.bgm_path, payload.bgmPath) || '');
|
||||||
@@ -787,8 +919,11 @@ export default function DigitalHuman() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.pixelle.previewTts({
|
const data = await api.pixelle.previewTts({
|
||||||
text: previewTtsText.trim(),
|
text: previewTtsText.trim(),
|
||||||
workflow: ttsWorkflow || undefined,
|
inference_mode: ttsInferenceMode,
|
||||||
ref_audio: refAudioPath.trim() || undefined,
|
workflow: ttsInferenceMode === 'comfyui' ? (ttsWorkflow || undefined) : undefined,
|
||||||
|
voice: ttsInferenceMode === 'local' ? ttsVoice : undefined,
|
||||||
|
speed: ttsInferenceMode === 'local' ? ttsSpeed : undefined,
|
||||||
|
ref_audio: ttsInferenceMode === 'comfyui' ? (refAudioPath.trim() || undefined) : undefined,
|
||||||
});
|
});
|
||||||
const audioPath = firstMediaPath(data, ['audio_path', 'audioPath', 'audio_url', 'audioUrl', 'url', 'file_url', 'fileUrl', 'path']);
|
const audioPath = firstMediaPath(data, ['audio_path', 'audioPath', 'audio_url', 'audioUrl', 'url', 'file_url', 'fileUrl', 'path']);
|
||||||
if (audioPath) {
|
if (audioPath) {
|
||||||
@@ -801,7 +936,7 @@ export default function DigitalHuman() {
|
|||||||
} finally {
|
} finally {
|
||||||
setPreviewing(null);
|
setPreviewing(null);
|
||||||
}
|
}
|
||||||
}, [previewTtsText, ttsWorkflow, refAudioPath]);
|
}, [previewTtsText, ttsInferenceMode, ttsWorkflow, ttsVoice, ttsSpeed, refAudioPath]);
|
||||||
|
|
||||||
const previewBgm = useMemo(() => {
|
const previewBgm = useMemo(() => {
|
||||||
if (!bgmPath) return '';
|
if (!bgmPath) return '';
|
||||||
@@ -853,7 +988,79 @@ export default function DigitalHuman() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const uploadReferenceImage = useCallback(async (file?: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setPreviewError('');
|
||||||
|
setImageUploadMessage('');
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
setPreviewError('请上传图片文件作为图片工作流输入。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadingImage(true);
|
||||||
|
try {
|
||||||
|
const result = await api.pixelle.uploadAsset(file, 'reference_image');
|
||||||
|
const fileUrl = result?.asset?.pixellePath || result?.asset?.fileUrl;
|
||||||
|
if (!fileUrl) throw new Error('上传成功但未返回文件地址');
|
||||||
|
setReferenceImagePath(fileUrl);
|
||||||
|
setPreviewImageUrl(resolvePixelleMediaUrl(fileUrl));
|
||||||
|
setImageUploadMessage(`已上传 ${file.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
setPreviewError(getErrorMessage(error, '参考图片上传失败'));
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploadReferenceVideo = useCallback(async (file?: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setPreviewError('');
|
||||||
|
setVideoUploadMessage('');
|
||||||
|
if (!file.type.startsWith('video/')) {
|
||||||
|
setPreviewError('请上传视频文件作为视频工作流输入。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadingVideo(true);
|
||||||
|
try {
|
||||||
|
const result = await api.pixelle.uploadAsset(file, 'reference_video');
|
||||||
|
const fileUrl = result?.asset?.pixellePath || result?.asset?.fileUrl;
|
||||||
|
if (!fileUrl) throw new Error('上传成功但未返回文件地址');
|
||||||
|
setReferenceVideoPath(fileUrl);
|
||||||
|
setVideoUploadMessage(`已上传 ${file.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
setPreviewError(getErrorMessage(error, '参考视频上传失败'));
|
||||||
|
} finally {
|
||||||
|
setUploadingVideo(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploadWorkflow = useCallback(async (mediaType: 'image' | 'video', file?: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setPreviewError('');
|
||||||
|
setWorkflowUploadMessage('');
|
||||||
|
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||||
|
setWorkflowUploadMessage('请选择 JSON 工作流文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadingWorkflow(true);
|
||||||
|
try {
|
||||||
|
const result = await api.pixelle.uploadWorkflow(file, {
|
||||||
|
source: workflowUploadSource,
|
||||||
|
mediaType,
|
||||||
|
});
|
||||||
|
const workflow = result.workflow;
|
||||||
|
const key = workflowValue(workflow);
|
||||||
|
setWorkflowUploadMessage(`已上传 ${key}`);
|
||||||
|
await wait(1500);
|
||||||
|
const refreshed = await api.pixelle.listMediaWorkflows();
|
||||||
|
setMediaWorkflows(Array.isArray(refreshed?.workflows) ? refreshed.workflows : []);
|
||||||
|
if (mediaType === 'image') setImageWorkflow(key);
|
||||||
|
else setApiVideoWorkflow(key);
|
||||||
|
} catch (error) {
|
||||||
|
setWorkflowUploadMessage(getErrorMessage(error, '工作流上传失败'));
|
||||||
|
} finally {
|
||||||
|
setUploadingWorkflow(false);
|
||||||
|
}
|
||||||
|
}, [workflowUploadSource]);
|
||||||
|
|
||||||
const taskBadgeTone = task ? statusTone(task.status) : 'neutral';
|
const taskBadgeTone = task ? statusTone(task.status) : 'neutral';
|
||||||
|
|
||||||
@@ -1017,109 +1224,266 @@ export default function DigitalHuman() {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="digital-human-panel">
|
<section className="digital-human-panel digital-human-pipeline-panel">
|
||||||
<div className="digital-human-section-head">
|
<div className="digital-human-section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>语音与画面</h2>
|
<h2>配置合成</h2>
|
||||||
<p>选择 TTS、图像工作流、模板,并试听试看样片。</p>
|
<p>参考 Pixelle 原版,将配音、分镜、插图和素材输入拆开配置。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="digital-human-field">
|
<div className="digital-human-subpanel">
|
||||||
<span>TTS 工作流</span>
|
<div className="digital-human-subpanel-head">
|
||||||
<select value={ttsWorkflow} onChange={(event) => setTtsWorkflow(event.target.value)}>
|
<h3>配音合成</h3>
|
||||||
<option value="">默认</option>
|
<p>{ttsInferenceMode === 'local' ? '本地 Edge TTS 开箱即用,可调音色和语速。' : 'ComfyUI/RunningHub TTS 支持工作流和音色克隆。'}</p>
|
||||||
{ttsWorkflows.map((wf) => (
|
|
||||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="digital-human-field">
|
|
||||||
<span>参考音频路径(可选,用于音色克隆)</span>
|
|
||||||
<input value={refAudioPath} onChange={(event) => setRefAudioPath(event.target.value)} placeholder="例如 resources/voice/sample.wav" />
|
|
||||||
</label>
|
|
||||||
<label className="digital-human-field">
|
|
||||||
<span>上传参考音频</span>
|
|
||||||
<div className="digital-human-file-picker">
|
|
||||||
<input
|
|
||||||
id="digital-human-reference-upload"
|
|
||||||
type="file"
|
|
||||||
accept="audio/*"
|
|
||||||
disabled={uploadingAudio}
|
|
||||||
onChange={(event) => void uploadReferenceAudio(event.target.files?.[0])}
|
|
||||||
/>
|
|
||||||
<label htmlFor="digital-human-reference-upload">
|
|
||||||
{uploadingAudio ? '上传中...' : '选择音频文件'}
|
|
||||||
</label>
|
|
||||||
<span>{uploadMessage ? basename(uploadMessage.replace(/^已上传\s*/, '')) : '用于音色克隆,可选'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<div className="digital-human-mode-grid">
|
||||||
{uploadMessage && <div className="digital-human-upload-note">{uploadMessage}</div>}
|
<button
|
||||||
<label className="digital-human-field">
|
type="button"
|
||||||
<span>语音试听文本</span>
|
className={`digital-human-mode${ttsInferenceMode === 'local' ? ' is-active' : ''}`}
|
||||||
<input value={previewTtsText} onChange={(event) => setPreviewTtsText(event.target.value)} />
|
onClick={() => setTtsInferenceMode('local')}
|
||||||
</label>
|
>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
<strong>本地合成</strong>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void previewTts()} disabled={previewing === 'tts'}>
|
<span>Edge TTS,无需工作流;适合快速生成旁白。</span>
|
||||||
{previewing === 'tts' ? '合成中...' : '试听语音'}
|
</button>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
{previewAudioUrl && <PreviewAudio src={previewAudioUrl} />}
|
className={`digital-human-mode${ttsInferenceMode === 'comfyui' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setTtsInferenceMode('comfyui')}
|
||||||
<label className="digital-human-field">
|
>
|
||||||
<span>图片工作流</span>
|
<strong>ComfyUI 合成</strong>
|
||||||
<select value={imageWorkflow} onChange={(event) => setImageWorkflow(event.target.value)}>
|
<span>使用 TTS 工作流;可上传参考音频做音色克隆。</span>
|
||||||
<option value="">默认</option>
|
</button>
|
||||||
{mediaWorkflows.map((wf) => (
|
</div>
|
||||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
{ttsInferenceMode === 'local' ? (
|
||||||
))}
|
<>
|
||||||
</select>
|
<div className="digital-human-form-grid">
|
||||||
</label>
|
<label className="digital-human-field">
|
||||||
<label className="digital-human-field">
|
<span>音色选择</span>
|
||||||
<span>视频工作流(可选,生成说话视频)</span>
|
<select value={ttsVoice} onChange={(event) => setTtsVoice(event.target.value)}>
|
||||||
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
|
{EDGE_TTS_VOICES.map((voice) => (
|
||||||
<option value="">不生成视频(仅图片合成)</option>
|
<option key={voice.id} value={voice.id}>{voice.label}</option>
|
||||||
{mediaWorkflows
|
))}
|
||||||
.filter((wf) => wf.name.toLowerCase().includes('video'))
|
</select>
|
||||||
.map((wf) => (
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>语速 {ttsSpeed.toFixed(1)}x</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
value={ttsSpeed}
|
||||||
|
onChange={(event) => setTtsSpeed(normalizeNumber(event.target.value, 1.2, 0.5, 2))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>TTS 工作流</span>
|
||||||
|
<select value={ttsWorkflow} onChange={(event) => setTtsWorkflow(event.target.value)}>
|
||||||
|
<option value="">默认</option>
|
||||||
|
{ttsWorkflows.map((wf) => (
|
||||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
<div className="digital-human-form-grid">
|
|
||||||
<label className="digital-human-field">
|
|
||||||
<span>宽度</span>
|
|
||||||
<input type="number" min={64} step={64} value={imageWidth} onChange={(event) => setImageWidth(Math.max(64, Number(event.target.value) || 1024))} />
|
|
||||||
</label>
|
</label>
|
||||||
<label className="digital-human-field">
|
<label className="digital-human-field">
|
||||||
<span>高度</span>
|
<span>参考音频路径(可选,用于音色克隆)</span>
|
||||||
<input type="number" min={64} step={64} value={imageHeight} onChange={(event) => setImageHeight(Math.max(64, Number(event.target.value) || 1024))} />
|
<input value={refAudioPath} onChange={(event) => setRefAudioPath(event.target.value)} placeholder="例如 resources/voice/sample.wav" />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>上传参考音频</span>
|
||||||
|
<div className="digital-human-file-picker">
|
||||||
|
<input
|
||||||
|
id="digital-human-reference-upload"
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
disabled={uploadingAudio}
|
||||||
|
onChange={(event) => void uploadReferenceAudio(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<label htmlFor="digital-human-reference-upload">
|
||||||
|
{uploadingAudio ? '上传中...' : '选择音频文件'}
|
||||||
|
</label>
|
||||||
|
<span>{uploadMessage ? basename(uploadMessage.replace(/^已上传\s*/, '')) : '用于音色克隆,可选'}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{uploadMessage && <div className="digital-human-upload-note">{uploadMessage}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>语音试听文本</span>
|
||||||
|
<input value={previewTtsText} onChange={(event) => setPreviewTtsText(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="digital-human-inline-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void previewTts()} disabled={previewing === 'tts'}>
|
||||||
|
{previewing === 'tts' ? '合成中...' : '试听语音'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{previewAudioUrl && <PreviewAudio src={previewAudioUrl} />}
|
||||||
</div>
|
</div>
|
||||||
<label className="digital-human-field">
|
|
||||||
<span>风格 prompt prefix</span>
|
|
||||||
<textarea rows={2} value={promptPrefix} onChange={(event) => setPromptPrefix(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="digital-human-field">
|
|
||||||
<span>预览镜头描述</span>
|
|
||||||
<input value={previewImagePrompt} onChange={(event) => setPreviewImagePrompt(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void previewImage()} disabled={previewing === 'image'}>
|
|
||||||
{previewing === 'image' ? '生成中...' : '预览风格'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{previewImageUrl && <PreviewImage src={previewImageUrl} />}
|
|
||||||
|
|
||||||
<label className="digital-human-field">
|
<div className="digital-human-subpanel">
|
||||||
<span>视频模板</span>
|
<div className="digital-human-subpanel-head">
|
||||||
<select value={frameTemplate} onChange={(event) => setFrameTemplate(event.target.value)}>
|
<h3>分镜模板</h3>
|
||||||
<option value="">请选择</option>
|
<p>模板决定最终画面排版,尺寸会传给 Pixelle 合成链路。</p>
|
||||||
{templates.map((tpl) => (
|
</div>
|
||||||
<option key={templateValue(tpl)} value={templateValue(tpl)}>{pickItemLabel(tpl)}{tpl.size ? ` (${tpl.size})` : ''}</option>
|
<label className="digital-human-field">
|
||||||
))}
|
<span>视频模板</span>
|
||||||
</select>
|
<select value={frameTemplate} onChange={(event) => setFrameTemplate(event.target.value)}>
|
||||||
</label>
|
<option value="">请选择</option>
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<option key={templateValue(tpl)} value={templateValue(tpl)}>{pickItemLabel(tpl)}{tpl.size ? ` (${tpl.size})` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="digital-human-dimension-strip">
|
||||||
|
<span>最终视频尺寸</span>
|
||||||
|
<strong>{imageWidth} x {imageHeight}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="digital-human-subpanel">
|
||||||
|
<div className="digital-human-subpanel-head">
|
||||||
|
<h3>插图生成</h3>
|
||||||
|
<p>选择图片工作流,也可以上传人物或参考图给工作流使用。</p>
|
||||||
|
</div>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>图片工作流</span>
|
||||||
|
<select value={imageWorkflow} onChange={(event) => setImageWorkflow(event.target.value)}>
|
||||||
|
<option value="">默认</option>
|
||||||
|
{mediaWorkflows.map((wf) => (
|
||||||
|
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="digital-human-form-grid">
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>宽度</span>
|
||||||
|
<input type="number" min={64} step={64} value={imageWidth} onChange={(event) => setImageWidth(Math.max(64, Number(event.target.value) || 1024))} />
|
||||||
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>高度</span>
|
||||||
|
<input type="number" min={64} step={64} value={imageHeight} onChange={(event) => setImageHeight(Math.max(64, Number(event.target.value) || 1024))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>图片工作流输入图(可选)</span>
|
||||||
|
<input
|
||||||
|
value={referenceImagePath}
|
||||||
|
onChange={(event) => setReferenceImagePath(event.target.value)}
|
||||||
|
placeholder="例如 resources/boosapi-uploads/avatar.png"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>上传图片素材</span>
|
||||||
|
<div className="digital-human-file-picker">
|
||||||
|
<input
|
||||||
|
id="digital-human-image-asset-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={uploadingImage}
|
||||||
|
onChange={(event) => void uploadReferenceImage(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<label htmlFor="digital-human-image-asset-upload">
|
||||||
|
{uploadingImage ? '上传中...' : '选择图片文件'}
|
||||||
|
</label>
|
||||||
|
<span>{imageUploadMessage ? basename(imageUploadMessage.replace(/^已上传\s*/, '')) : '用于图片/人物参考,可选'}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{imageUploadMessage && <div className="digital-human-upload-note">{imageUploadMessage}</div>}
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>风格 prompt prefix</span>
|
||||||
|
<textarea rows={2} value={promptPrefix} onChange={(event) => setPromptPrefix(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>预览镜头描述</span>
|
||||||
|
<input value={previewImagePrompt} onChange={(event) => setPreviewImagePrompt(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="digital-human-inline-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void previewImage()} disabled={previewing === 'image'}>
|
||||||
|
{previewing === 'image' ? '生成中...' : '预览风格'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{previewImageUrl && <PreviewImage src={previewImageUrl} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="digital-human-subpanel">
|
||||||
|
<div className="digital-human-subpanel-head">
|
||||||
|
<h3>视频素材与工作流</h3>
|
||||||
|
<p>用于说话视频、动作迁移或图生视频,RunningHub 与 Selfhost 都可以上传。</p>
|
||||||
|
</div>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>视频工作流(可选,生成说话视频)</span>
|
||||||
|
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
|
||||||
|
<option value="">不生成视频(仅图片合成)</option>
|
||||||
|
{mediaWorkflows
|
||||||
|
.filter(isVideoWorkflow)
|
||||||
|
.map((wf) => (
|
||||||
|
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>视频工作流输入视频(可选)</span>
|
||||||
|
<input
|
||||||
|
value={referenceVideoPath}
|
||||||
|
onChange={(event) => setReferenceVideoPath(event.target.value)}
|
||||||
|
placeholder="例如 resources/boosapi-uploads/action.mp4"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>上传视频素材</span>
|
||||||
|
<div className="digital-human-file-picker">
|
||||||
|
<input
|
||||||
|
id="digital-human-video-asset-upload"
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
disabled={uploadingVideo}
|
||||||
|
onChange={(event) => void uploadReferenceVideo(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<label htmlFor="digital-human-video-asset-upload">
|
||||||
|
{uploadingVideo ? '上传中...' : '选择视频文件'}
|
||||||
|
</label>
|
||||||
|
<span>{videoUploadMessage ? basename(videoUploadMessage.replace(/^已上传\s*/, '')) : '用于动作迁移/视频参考,可选'}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{videoUploadMessage && <div className="digital-human-upload-note">{videoUploadMessage}</div>}
|
||||||
|
<div className="digital-human-field">
|
||||||
|
<span>上传自定义图片/视频工作流</span>
|
||||||
|
<div className="digital-human-workflow-upload-grid">
|
||||||
|
<select value={workflowUploadSource} onChange={(event) => setWorkflowUploadSource(event.target.value as 'runninghub' | 'selfhost')}>
|
||||||
|
<option value="runninghub">RunningHub</option>
|
||||||
|
<option value="selfhost">Selfhost</option>
|
||||||
|
</select>
|
||||||
|
<div className="digital-human-file-picker">
|
||||||
|
<input
|
||||||
|
id="digital-human-image-workflow-upload"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
disabled={uploadingWorkflow}
|
||||||
|
onChange={(event) => void uploadWorkflow('image', event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<label htmlFor="digital-human-image-workflow-upload">
|
||||||
|
{uploadingWorkflow ? '上传中...' : '上传图片工作流'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="digital-human-video-workflow-upload"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
disabled={uploadingWorkflow}
|
||||||
|
onChange={(event) => void uploadWorkflow('video', event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<label htmlFor="digital-human-video-workflow-upload">
|
||||||
|
{uploadingWorkflow ? '上传中...' : '上传视频工作流'}
|
||||||
|
</label>
|
||||||
|
<span>{workflowUploadMessage || '自动保存到 Pixelle 工作流目录并刷新列表'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="digital-human-panel">
|
<section className="digital-human-panel">
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ describe('Dashboard mobile layout', () => {
|
|||||||
expect(source).toMatch(/className="dashboard-hero-topline"[\s\S]*?className="dashboard-hero-status"[\s\S]*?className="dashboard-hero-maincopy"/);
|
expect(source).toMatch(/className="dashboard-hero-topline"[\s\S]*?className="dashboard-hero-status"[\s\S]*?className="dashboard-hero-maincopy"/);
|
||||||
expect(css).toContain('.dashboard-hero-topline {');
|
expect(css).toContain('.dashboard-hero-topline {');
|
||||||
expect(css).toContain('.dashboard-hero-maincopy {');
|
expect(css).toContain('.dashboard-hero-maincopy {');
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1200px\) \{[\s\S]*?\.dashboard-hero\.console-hero \{[\s\S]*?grid-template-columns: 1fr;/);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1200px\) \{[\s\S]*?\.dashboard-hero \.console-hero-rail \{[\s\S]*?width: 100%;[\s\S]*?grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1100px\) \{[\s\S]*?\.dashboard-hero-topline \{[\s\S]*?flex-direction: column;/);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1100px\) \{[\s\S]*?\.dashboard-hero-status \{[\s\S]*?width: 100%;/);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1100px\) \{[\s\S]*?\.dashboard-hero-signals \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||||
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-topline \{[\s\S]*?flex-direction: column;/);
|
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-topline \{[\s\S]*?flex-direction: column;/);
|
||||||
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-status \{[\s\S]*?width: 100%;/);
|
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-status \{[\s\S]*?width: 100%;/);
|
||||||
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-signals \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
expect(css).toMatch(/@media \(max-width: 860px\) \{[\s\S]*?\.dashboard-hero-signals \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||||
|
|||||||
@@ -209,4 +209,108 @@ describe('Dashboard and ops log placement', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats an idle healthy platform as healthy on Dashboard', async () => {
|
||||||
|
apiMock.getDashboard.mockResolvedValue({
|
||||||
|
totalBalance: 0,
|
||||||
|
totalUsed: 0,
|
||||||
|
todaySpend: 0,
|
||||||
|
todayReward: 0,
|
||||||
|
activeAccounts: 1,
|
||||||
|
totalAccounts: 1,
|
||||||
|
todayCheckin: { success: 0, total: 0 },
|
||||||
|
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||||
|
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
|
||||||
|
siteAvailability: [],
|
||||||
|
modelAnalysis: null,
|
||||||
|
});
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([]);
|
||||||
|
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/dashboard']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Dashboard />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const dashboardText = collectText(root.root);
|
||||||
|
expect(dashboardText).toContain('健康');
|
||||||
|
expect(dashboardText).toContain('100.0% 成功率');
|
||||||
|
expect(dashboardText).toContain('平台运行平稳,可以继续观察吞吐和成本变化。');
|
||||||
|
expect(dashboardText).not.toContain('平台存在明显异常');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not keep Ops Monitor abnormal after logs are cleared when routes only lack snapshots', async () => {
|
||||||
|
apiMock.getDashboard.mockResolvedValue({
|
||||||
|
totalBalance: 0,
|
||||||
|
totalUsed: 0,
|
||||||
|
todaySpend: 0,
|
||||||
|
todayReward: 0,
|
||||||
|
activeAccounts: 5,
|
||||||
|
totalAccounts: 5,
|
||||||
|
todayCheckin: { success: 0, total: 0 },
|
||||||
|
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||||
|
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
|
||||||
|
siteAvailability: [],
|
||||||
|
modelAnalysis: null,
|
||||||
|
});
|
||||||
|
apiMock.getDashboardInsights.mockResolvedValue({ siteAvailability: [] });
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([{
|
||||||
|
id: 101,
|
||||||
|
modelPattern: 'gpt-5.2-codex',
|
||||||
|
displayName: 'Codex Route',
|
||||||
|
displayIcon: null,
|
||||||
|
modelMapping: null,
|
||||||
|
enabled: true,
|
||||||
|
channelCount: 3,
|
||||||
|
enabledChannelCount: 3,
|
||||||
|
siteNames: ['Main Site'],
|
||||||
|
decisionSnapshot: null,
|
||||||
|
decisionRefreshedAt: null,
|
||||||
|
}]);
|
||||||
|
apiMock.getEvents.mockResolvedValue({ data: [] });
|
||||||
|
apiMock.getTasks.mockResolvedValue({ tasks: [] });
|
||||||
|
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/ops']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<OpsMonitor />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const statusCard = root.root.find((node) => (
|
||||||
|
node.type === 'article'
|
||||||
|
&& node.props.className === 'ops-monitor-kpi'
|
||||||
|
&& collectText(node).includes('运行状态')
|
||||||
|
));
|
||||||
|
expect(statusCard.props['data-tone']).toBe('success');
|
||||||
|
expect(collectText(statusCard)).toContain('健康');
|
||||||
|
expect(collectText(statusCard)).toContain('成功率 100.0%');
|
||||||
|
|
||||||
|
const routeRiskCard = root.root.find((node) => (
|
||||||
|
node.type === 'article'
|
||||||
|
&& node.props.className === 'ops-monitor-kpi'
|
||||||
|
&& collectText(node).includes('路由风险')
|
||||||
|
));
|
||||||
|
expect(routeRiskCard.props['data-tone']).toBe('success');
|
||||||
|
expect(collectText(routeRiskCard)).toContain('路由风险0');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe('RouteCard', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(collectText(root.root)).toContain('m.');
|
expect(collectText(root.root)).toContain('m.');
|
||||||
expect(collectText(root.root)).toContain('注意 76');
|
expect(collectText(root.root)).toContain('健康 90');
|
||||||
|
|
||||||
const regexBadge = root.root.find((node) => (
|
const regexBadge = root.root.find((node) => (
|
||||||
node.type === 'span'
|
node.type === 'span'
|
||||||
|
|||||||
@@ -88,6 +88,33 @@ describe('routeHealth', () => {
|
|||||||
expect(empty.reasons).toContain('没有通道');
|
expect(empty.reasons).toContain('没有通道');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not mark fully routed routes without decision snapshots as risky', () => {
|
||||||
|
const health = buildRouteHealthScore(buildRoute({
|
||||||
|
channelCount: 3,
|
||||||
|
enabledChannelCount: 3,
|
||||||
|
decisionSnapshot: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(health.label).toBe('健康');
|
||||||
|
expect(health.tone).toBe('good');
|
||||||
|
expect(health.score).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(health.reasons).toContain('3/3 通道可用');
|
||||||
|
expect(health.reasons).toContain('暂无选择概率快照');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps partial channel coverage without decision snapshots in attention state', () => {
|
||||||
|
const health = buildRouteHealthScore(buildRoute({
|
||||||
|
channelCount: 4,
|
||||||
|
enabledChannelCount: 2,
|
||||||
|
decisionSnapshot: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(health.label).toBe('注意');
|
||||||
|
expect(health.tone).toBe('warning');
|
||||||
|
expect(health.reasons).toContain('2/4 通道可用');
|
||||||
|
expect(health.reasons).toContain('暂无选择概率快照');
|
||||||
|
});
|
||||||
|
|
||||||
it('penalizes candidate snapshots with recent failures and low eligibility', () => {
|
it('penalizes candidate snapshots with recent failures and low eligibility', () => {
|
||||||
const health = buildRouteHealthScore(buildRoute({
|
const health = buildRouteHealthScore(buildRoute({
|
||||||
channelCount: 3,
|
channelCount: 3,
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ export function buildRouteHealthScore(
|
|||||||
reasons.push(`最高选择概率 ${Math.round(topProbability)}%`);
|
reasons.push(`最高选择概率 ${Math.round(topProbability)}%`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (enabledChannelCount > 0 && channelRatio === 1) {
|
||||||
|
score += 14;
|
||||||
|
}
|
||||||
reasons.push('暂无选择概率快照');
|
reasons.push('暂无选择概率快照');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user