Add native MoneyPrinter short video factory

This commit is contained in:
boos
2026-06-02 15:26:42 +08:00
parent 056e9fb651
commit b29c7ffa7a
6 changed files with 1226 additions and 0 deletions
+2
View File
@@ -23,6 +23,7 @@ mod digital_human;
mod events; mod events;
mod health; mod health;
mod monitor; mod monitor;
mod money_printer;
mod oauth; mod oauth;
mod organizations; mod organizations;
mod picture_agent; mod picture_agent;
@@ -46,6 +47,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(admin::routes(state.clone())) .merge(admin::routes(state.clone()))
.merge(proxy::routes()) .merge(proxy::routes())
.merge(picture_agent::routes()) .merge(picture_agent::routes())
.merge(money_printer::routes())
.merge(comfyui_agent::routes()) .merge(comfyui_agent::routes())
.merge(digital_human::routes()) .merge(digital_human::routes())
.merge(comfyui::routes()) .merge(comfyui::routes())
+723
View File
@@ -0,0 +1,723 @@
use crate::{
auth::jwt::validate_jwt,
db::models::{background_task::BackgroundTask, downstream_api_key::DownstreamApiKey},
error::{AppError, AppResult},
services::workspace::{self, WorkspaceAssetInput},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap},
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value};
use std::time::Duration;
const TASK_TYPE_MONEY_PRINTER: &str = "money-printer-video";
const VIDEO_POLL_INTERVAL_SECS: u64 = 6;
const VIDEO_MAX_POLLS: usize = 120;
#[derive(Debug, Clone)]
struct MoneyPrinterAuth {
user_id: Option<i64>,
role: String,
outbound_key: String,
key_name: String,
}
#[derive(Debug, Deserialize)]
struct JobListQuery {
limit: Option<i64>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/money-printer/status", get(status))
.route("/api/money-printer/jobs", get(list_jobs).post(create_job))
.route("/api/money-printer/jobs/clear", post(clear_jobs))
.route("/api/money-printer/jobs/{id}", get(get_job))
.route("/api/money-printer/jobs/{id}/cancel", post(cancel_job))
}
fn local_v1_base(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
format!("http://{}:{}/v1", host, state.config.server.port)
}
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer ")))
.map(str::trim)
.filter(|token| !token.is_empty())
}
async fn user_downstream_key(state: &AppState, user_id: i64) -> Result<DownstreamApiKey, String> {
sqlx::query_as::<_, DownstreamApiKey>(
"SELECT *
FROM downstream_api_keys
WHERE user_id = ?
AND COALESCE(enabled, 1) = 1
AND (
expires_at IS NULL
OR expires_at = ''
OR datetime(expires_at) > datetime('now')
)
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&*state.db)
.await
.map_err(|err| err.to_string())?
.ok_or_else(|| "当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".to_string())
}
async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrinterAuth, AppError> {
let token = extract_bearer_token(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Ok(MoneyPrinterAuth {
user_id: None,
role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(),
key_name: "global-proxy".to_string(),
});
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role != "admin" && claims.role != "user" {
return Err(AppError::Forbidden("Forbidden".into()));
}
let user_id = claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
match user_downstream_key(state, user_id).await {
Ok(key) => Ok(MoneyPrinterAuth {
user_id: Some(user_id),
role: claims.role,
outbound_key: key.key,
key_name: key.name,
}),
Err(_) if claims.role == "admin" => Ok(MoneyPrinterAuth {
user_id: Some(user_id),
role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(),
key_name: "global-proxy".to_string(),
}),
Err(message) => Err(AppError::Forbidden(message)),
}
}
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
Ok(Json(json!({
"success": true,
"mode": "boosapi-native",
"keyName": auth.key_name,
"capabilities": [
"topic_to_script",
"boosapi_chat_completion",
"boosapi_text_to_video",
"workspace_archive",
"background_jobs"
]
})))
}
fn string_from(body: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| body.get(*key).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn task_visible_to_auth(task: &BackgroundTask, auth: &MoneyPrinterAuth) -> bool {
if auth.role == "admin" && auth.user_id.is_none() {
return true;
}
let result = task
.result_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
result.get("userId").and_then(Value::as_i64) == auth.user_id
}
fn terminal_status(status: &str) -> bool {
matches!(status, "succeeded" | "failed" | "cancelled")
}
fn task_to_json(task: BackgroundTask) -> Value {
let result = task
.result_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
json!({
"id": task.id,
"type": task.task_type,
"title": task.title,
"status": task.status,
"message": task.message,
"error": task.error,
"progress": task.progress.unwrap_or(0),
"progressTotal": task.progress_total.unwrap_or(5),
"result": result,
"createdAt": task.created_at,
"updatedAt": task.updated_at,
"startedAt": task.started_at,
"finishedAt": task.finished_at,
})
}
async fn load_job(state: &AppState, id: &str, auth: &MoneyPrinterAuth) -> AppResult<Value> {
let task: BackgroundTask =
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
.bind(id)
.bind(TASK_TYPE_MONEY_PRINTER)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("MoneyPrinter job not found".into()))?;
if !task_visible_to_auth(&task, auth) {
return Err(AppError::NotFound("MoneyPrinter job not found".into()));
}
Ok(task_to_json(task))
}
async fn update_progress(state: &AppState, id: &str, progress: i64, message: &str) -> AppResult<()> {
sqlx::query(
"UPDATE background_tasks
SET progress = ?, message = ?, updated_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(progress)
.bind(message)
.bind(id)
.execute(&*state.db)
.await?;
Ok(())
}
async fn is_cancelled(state: &AppState, id: &str) -> AppResult<bool> {
let status: Option<String> =
sqlx::query_scalar("SELECT status FROM background_tasks WHERE id = ? LIMIT 1")
.bind(id)
.fetch_optional(&*state.db)
.await?;
Ok(status.as_deref() == Some("cancelled"))
}
fn http_client(timeout_secs: u64) -> Client {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.unwrap_or_else(|_| Client::new())
}
fn script_prompt(topic: &str, body: &Value) -> String {
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
let style = string_from(body, &["style"]).unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
format!(
"你是 MoneyPrinterTurbo 风格的短视频编导。请基于主题生成一个可直接用于 AI 文生视频的成片方案。\n\
主题:{topic}\n语言:{language}\n目标受众:{audience}\n时长:约 {duration} 秒\n风格:{style}\n额外要求:{extra}\n\n\
输出 JSON,字段必须包含:title、hook、script、video_prompt、hashtags、scenes。\n\
script 是完整口播文案;video_prompt 是英文或中英混合的视频生成提示词,包含镜头、场景、人物/产品、光线、运动、质感;scenes 是 3-6 个分镜对象数组。不要输出 Markdown。"
)
}
fn extract_json_object(text: &str) -> Option<Value> {
if let Ok(value) = serde_json::from_str::<Value>(text) {
return Some(value);
}
let start = text.find('{')?;
let end = text.rfind('}')?;
serde_json::from_str::<Value>(&text[start..=end]).ok()
}
async fn generate_script(
state: &AppState,
auth: &MoneyPrinterAuth,
body: &Value,
) -> AppResult<Value> {
let topic = string_from(body, &["topic", "prompt"]).ok_or_else(|| {
AppError::BadRequest("topic is required".into())
})?;
let script_model = string_from(body, &["scriptModel", "script_model"])
.unwrap_or_else(|| "gpt-5.4".to_string());
let payload = json!({
"model": script_model,
"messages": [
{ "role": "system", "content": "你是短视频自动化生产助手,只输出严格 JSON。" },
{ "role": "user", "content": script_prompt(&topic, body) }
],
"temperature": 0.7,
"stream": false
});
let response = http_client(120)
.post(format!("{}/chat/completions", local_v1_base(state)))
.bearer_auth(&auth.outbound_key)
.json(&payload)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("生成短视频脚本失败: {err}")))?;
let status = response.status();
let value: Value = response
.json()
.await
.map_err(|err| AppError::UpstreamError(format!("解析脚本响应失败: {err}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
let content = value
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
.unwrap_or_default();
let parsed = extract_json_object(content).unwrap_or_else(|| {
json!({
"title": topic,
"hook": "",
"script": content,
"video_prompt": content,
"hashtags": [],
"scenes": []
})
});
Ok(json!({
"model": script_model,
"raw": value,
"content": content,
"plan": parsed,
}))
}
fn video_url_from_response(value: &Value) -> Option<String> {
let keys = ["url", "video", "video_url", "output_url", "content_url"];
for key in keys {
if let Some(url) = value.get(key).and_then(Value::as_str) {
if url.starts_with("http") || url.starts_with('/') {
return Some(url.to_string());
}
}
}
if let Some(data) = value.get("data").and_then(Value::as_array) {
for item in data {
if let Some(url) = video_url_from_response(item) {
return Some(url);
}
}
}
if let Some(output) = value.get("output") {
if let Some(url) = video_url_from_response(output) {
return Some(url);
}
}
None
}
fn video_task_id_from_response(value: &Value) -> Option<String> {
let keys = ["id", "task_id", "taskId", "request_id", "job_id"];
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
value
.get("data")
.and_then(Value::as_array)
.and_then(|data| data.first())
.and_then(video_task_id_from_response)
})
}
async fn submit_video(
state: &AppState,
auth: &MoneyPrinterAuth,
body: &Value,
plan: &Value,
) -> AppResult<Value> {
let model = string_from(body, &["videoModel", "video_model", "model"])
.unwrap_or_else(|| "seedance-1-0-lite-t2v".to_string());
let prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
plan.get("video_prompt")
.and_then(Value::as_str)
.or_else(|| plan.get("script").and_then(Value::as_str))
.unwrap_or_default()
.to_string()
});
if prompt.trim().is_empty() {
return Err(AppError::BadRequest("video prompt is empty".into()));
}
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"]).unwrap_or_else(|| "9:16".to_string());
let duration = body
.get("duration")
.and_then(Value::as_i64)
.or_else(|| string_from(body, &["duration"]).and_then(|value| value.parse::<i64>().ok()))
.unwrap_or(30);
let payload = json!({
"model": model,
"prompt": prompt,
"size": size,
"ratio": ratio,
"duration": duration,
"metadata": {
"source": "money-printer",
"script": plan.get("script").cloned().unwrap_or(Value::Null),
"title": plan.get("title").cloned().unwrap_or(Value::Null)
}
});
let response = http_client(180)
.post(format!("{}/videos", local_v1_base(state)))
.bearer_auth(&auth.outbound_key)
.json(&payload)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("提交视频生成失败: {err}")))?;
let status = response.status();
let value: Value = response
.json()
.await
.map_err(|err| AppError::UpstreamError(format!("解析视频响应失败: {err}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(json!({
"model": model,
"request": payload,
"response": value,
}))
}
async fn poll_video(
state: &AppState,
auth: &MoneyPrinterAuth,
model: &str,
task_id: &str,
) -> AppResult<Value> {
for _ in 0..VIDEO_MAX_POLLS {
let response = http_client(60)
.get(format!("{}/videos/{}", local_v1_base(state), task_id))
.query(&[("model", model)])
.bearer_auth(&auth.outbound_key)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("查询视频任务失败: {err}")))?;
let status = response.status();
let value: Value = response
.json()
.await
.map_err(|err| AppError::UpstreamError(format!("解析视频任务失败: {err}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
if video_url_from_response(&value).is_some() {
return Ok(value);
}
let state_text = value
.get("status")
.or_else(|| value.get("state"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(state_text.as_str(), "failed" | "error" | "cancelled" | "canceled") {
return Err(AppError::UpstreamError(value.to_string()));
}
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
}
Err(AppError::GatewayTimeout("视频生成任务等待超时".into()))
}
async fn archive_video(
state: &AppState,
auth: &MoneyPrinterAuth,
title: &str,
prompt: &str,
model: &str,
video_url: &str,
metadata: Value,
) -> Option<Value> {
let input = WorkspaceAssetInput {
user_id: auth.user_id,
source: "money-printer".to_string(),
kind: "video".to_string(),
title: Some(title.to_string()),
prompt: Some(prompt.to_string()),
model: Some(model.to_string()),
remote_url: Some(video_url.to_string()),
metadata,
};
match workspace::record_remote_asset(state, input).await {
Ok(asset) => Some(json!({
"publicId": asset.public_id,
"kind": "video",
"status": asset.status,
"remoteUrl": asset.remote_url,
"fileName": asset.file_name,
})),
Err(err) => Some(json!({
"kind": "video",
"status": "failed",
"remoteUrl": video_url,
"error": err.to_string(),
})),
}
}
async fn create_job(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let topic = string_from(&body, &["topic", "prompt"])
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
let task_id = format!("mp_{}", uuid::Uuid::new_v4().simple());
let title = topic.chars().take(40).collect::<String>();
let initial_result = json!({
"userId": auth.user_id,
"role": auth.role,
"keyName": auth.key_name,
"topic": topic,
"request": body,
});
sqlx::query(
"INSERT INTO background_tasks
(id, task_type, title, status, message, dedupe_key, result_json, progress, progress_total, started_at)
VALUES (?, ?, ?, 'running', '短视频任务已创建', ?, ?, 1, 5, datetime('now'))",
)
.bind(&task_id)
.bind(TASK_TYPE_MONEY_PRINTER)
.bind(if title.trim().is_empty() { "短视频工厂任务" } else { &title })
.bind(format!("money-printer:{}", auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())))
.bind(initial_result.to_string())
.execute(&*state.db)
.await?;
let state_for_task = state.clone();
let task_id_for_spawn = task_id.clone();
let auth_for_task = auth.clone();
tokio::spawn(async move {
let result = async {
update_progress(&state_for_task, &task_id_for_spawn, 2, "正在生成脚本和分镜").await?;
let script = generate_script(&state_for_task, &auth_for_task, &body).await?;
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
return Ok(());
}
let plan = script.get("plan").cloned().unwrap_or(Value::Null);
let mid_result = json!({
"userId": auth_for_task.user_id,
"role": auth_for_task.role,
"keyName": auth_for_task.key_name,
"topic": topic,
"request": body,
"script": script,
});
sqlx::query("UPDATE background_tasks SET result_json = ?, updated_at = datetime('now') WHERE id = ?")
.bind(mid_result.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await?;
update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 BoosAPI 视频模型").await?;
let submitted = submit_video(&state_for_task, &auth_for_task, &body, &plan).await?;
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
return Ok(());
}
let model = submitted
.get("model")
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
let first_response = submitted.get("response").cloned().unwrap_or(Value::Null);
let final_video_response = if video_url_from_response(&first_response).is_some() {
first_response
} else if let Some(video_task_id) = video_task_id_from_response(&first_response) {
update_progress(&state_for_task, &task_id_for_spawn, 4, "视频生成中,正在轮询上游任务").await?;
poll_video(&state_for_task, &auth_for_task, &model, &video_task_id).await?
} else {
first_response
};
let video_url = video_url_from_response(&final_video_response);
let prompt = submitted
.get("request")
.and_then(|request| request.get("prompt"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let title = plan
.get("title")
.and_then(Value::as_str)
.unwrap_or("短视频工厂成片")
.to_string();
let mut assets = Vec::new();
if let Some(url) = video_url.as_deref() {
update_progress(&state_for_task, &task_id_for_spawn, 5, "视频完成,正在归档到工作空间").await?;
if let Some(asset) = archive_video(
&state_for_task,
&auth_for_task,
&title,
&prompt,
&model,
url,
json!({ "moneyPrinter": { "topic": topic, "plan": plan, "response": final_video_response } }),
)
.await
{
assets.push(asset);
}
}
let final_result = json!({
"userId": auth_for_task.user_id,
"role": auth_for_task.role,
"keyName": auth_for_task.key_name,
"topic": topic,
"request": body,
"script": script,
"video": submitted,
"finalVideoResponse": final_video_response,
"videoUrl": video_url,
"assets": assets,
});
sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = '短视频生成完成', result_json = ?, progress = 5,
updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(final_result.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await?;
Ok::<(), AppError>(())
}
.await;
if let Err(err) = result {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', message = '短视频生成失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(err.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await;
}
});
let job = load_job(&state, &task_id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn get_job(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let job = load_job(&state, &id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn list_jobs(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<JobListQuery>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT ?",
)
.bind(TASK_TYPE_MONEY_PRINTER)
.bind(limit * 3)
.fetch_all(&*state.db)
.await?;
let jobs = tasks
.into_iter()
.filter(|task| task_visible_to_auth(task, &auth))
.take(limit as usize)
.map(task_to_json)
.collect::<Vec<_>>();
Ok(Json(json!({ "success": true, "jobs": jobs })))
}
async fn cancel_job(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let task: BackgroundTask =
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
.bind(&id)
.bind(TASK_TYPE_MONEY_PRINTER)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("MoneyPrinter job not found".into()))?;
if !task_visible_to_auth(&task, &auth) {
return Err(AppError::NotFound("MoneyPrinter job not found".into()));
}
if terminal_status(&task.status) {
return Ok(Json(json!({ "success": true, "job": task_to_json(task) })));
}
sqlx::query(
"UPDATE background_tasks
SET status = 'cancelled', message = '已取消', updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND task_type = ? AND status IN ('pending', 'running')",
)
.bind(&id)
.bind(TASK_TYPE_MONEY_PRINTER)
.execute(&*state.db)
.await?;
let job = load_job(&state, &id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE task_type = ? ORDER BY created_at DESC",
)
.bind(TASK_TYPE_MONEY_PRINTER)
.fetch_all(&*state.db)
.await?;
let mut cleared = 0_i64;
for task in tasks {
if !task_visible_to_auth(&task, &auth) || !terminal_status(&task.status) {
continue;
}
let affected = sqlx::query("DELETE FROM background_tasks WHERE id = ? AND task_type = ?")
.bind(&task.id)
.bind(TASK_TYPE_MONEY_PRINTER)
.execute(&*state.db)
.await?
.rows_affected();
cleared += i64::try_from(affected).unwrap_or(0);
}
Ok(Json(json!({ "success": true, "cleared": cleared })))
}
+6
View File
@@ -39,6 +39,7 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js')); const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js')); const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js')); const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
const Workspace = lazy(() => import('./pages/Workspace.js')); const Workspace = lazy(() => import('./pages/Workspace.js'));
const Workflows = lazy(() => import('./pages/Workflows.js')); const Workflows = lazy(() => import('./pages/Workflows.js'));
const Monitors = lazy(() => import('./pages/Monitors.js')); const Monitors = lazy(() => import('./pages/Monitors.js'));
@@ -387,6 +388,7 @@ export const sidebarGroups = [
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> }, { to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> }, { to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> }, { to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> }, { to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
@@ -419,6 +421,7 @@ export const userSidebarGroups = [
label: '创作应用', label: '创作应用',
items: [ items: [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> }, { to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> }, { to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
@@ -430,6 +433,7 @@ const topNavItems = [
{ label: '控制台', to: '/' }, { label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' }, { label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' }, { label: '生图助手', to: '/picture-agent' },
{ label: '短视频工厂', to: '/money-printer' },
{ label: '工作流', to: '/workflows' }, { label: '工作流', to: '/workflows' },
{ label: '视频助手', to: '/comfyui-agent' }, { label: '视频助手', to: '/comfyui-agent' },
{ label: '工作空间', to: '/workspace' }, { label: '工作空间', to: '/workspace' },
@@ -987,6 +991,7 @@ function AppShell() {
<Route path="/settings/notify" element={<NotificationSettings />} /> <Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} /> <Route path="/models" element={<Models />} />
<Route path="/picture-agent" element={<PictureAgent />} /> <Route path="/picture-agent" element={<PictureAgent />} />
<Route path="/money-printer" element={<MoneyPrinter />} />
<Route path="/digital-human" element={<DigitalHuman />} /> <Route path="/digital-human" element={<DigitalHuman />} />
<Route path="/workflows" element={<Workflows />} /> <Route path="/workflows" element={<Workflows />} />
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} /> <Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
@@ -1006,6 +1011,7 @@ function AppShell() {
<Route path="/user/usage" element={<UsageRecords />} /> <Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/announcements" element={<UserAnnouncements />} /> <Route path="/user/announcements" element={<UserAnnouncements />} />
<Route path="/user/picture-agent" element={<PictureAgent />} /> <Route path="/user/picture-agent" element={<PictureAgent />} />
<Route path="/user/money-printer" element={<MoneyPrinter />} />
<Route path="/user/digital-human" element={<DigitalHuman />} /> <Route path="/user/digital-human" element={<DigitalHuman />} />
<Route path="/user/workflows" element={<Workflows />} /> <Route path="/user/workflows" element={<Workflows />} />
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} /> <Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
+40
View File
@@ -1184,6 +1184,18 @@ export type DigitalHumanJobsResponse = {
jobs: DigitalHumanJob[]; jobs: DigitalHumanJob[];
}; };
export type MoneyPrinterJob = DigitalHumanJob;
export type MoneyPrinterJobResponse = {
success: boolean;
job: MoneyPrinterJob;
};
export type MoneyPrinterJobsResponse = {
success: boolean;
jobs: MoneyPrinterJob[];
};
export const api = { export const api = {
// Sites // Sites
getSites: async () => { getSites: async () => {
@@ -2081,6 +2093,20 @@ export const api = {
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }), request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () => clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }), request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
createMoneyPrinterJob: (data: any) =>
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getMoneyPrinterJobs: (params?: { limit?: number }) =>
request<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
getMoneyPrinterJob: (id: string) =>
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
cancelMoneyPrinterJob: (id: string) =>
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
}; };
export const userApi = { export const userApi = {
register: (data: { register: (data: {
@@ -2170,4 +2196,18 @@ export const userApi = {
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }), userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () => clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }), userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
createMoneyPrinterJob: (data: any) =>
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getMoneyPrinterJobs: (params?: { limit?: number }) =>
userRequest<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
getMoneyPrinterJob: (id: string) =>
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
cancelMoneyPrinterJob: (id: string) =>
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
}; };
+193
View File
@@ -9250,6 +9250,199 @@ textarea {
font-size: 11px; font-size: 11px;
} }
.money-printer-page {
max-width: 1440px;
margin: 0 auto;
}
.money-printer-layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(340px, 0.95fr);
gap: 16px;
align-items: start;
}
.money-printer-panel {
border: 1px solid var(--color-border-light);
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 18px;
box-shadow: var(--shadow-card);
}
.money-printer-hero {
display: grid;
gap: 8px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-card));
}
.money-printer-hero span {
color: var(--color-text-muted);
font-size: 12px;
font-weight: 720;
text-transform: uppercase;
}
.money-printer-hero strong {
color: var(--color-text-primary);
font-size: 18px;
line-height: 1.35;
}
.money-printer-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.money-printer-field {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
.money-printer-field input,
.money-printer-field select,
.money-printer-field textarea {
width: 100%;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
padding: 9px 11px;
font-size: 13px;
}
.money-printer-field textarea {
min-height: 112px;
resize: vertical;
}
.money-printer-section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.money-printer-section-head h2 {
margin: 0 0 4px;
font-size: 14px;
}
.money-printer-section-head p {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.money-printer-job-list {
display: grid;
gap: 10px;
}
.money-printer-job-card {
display: grid;
gap: 10px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 66%, var(--color-bg-card));
padding: 12px;
}
.money-printer-job-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.money-printer-job-head div {
display: grid;
gap: 3px;
min-width: 0;
}
.money-printer-job-head strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.money-printer-job-head span,
.money-printer-job-head em,
.money-printer-job-meta,
.money-printer-hook {
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
}
.money-printer-job-head em {
flex: none;
font-style: normal;
}
.money-printer-progress {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 58%, transparent);
}
.money-printer-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-primary);
transition: width .25s ease;
}
.money-printer-job-meta,
.money-printer-assets,
.money-printer-job-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.money-printer-job-meta span,
.money-printer-job-meta a {
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg) 80%, var(--color-bg-card));
border: 1px solid var(--color-border-light);
padding: 3px 7px;
color: var(--color-text-muted);
text-decoration: none;
}
.money-printer-hook {
margin: 0;
padding: 9px 10px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 78%, var(--color-bg-card));
}
.money-printer-job-error {
color: var(--color-danger);
font-size: 12px;
line-height: 1.45;
}
.money-printer-assets a {
color: var(--color-primary);
font-size: 12px;
text-decoration: none;
}
.workspace-page .page-header { .workspace-page .page-header {
margin-bottom: 16px; margin-bottom: 16px;
} }
+262
View File
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api, userApi, type MoneyPrinterJob } from '../api.js';
import type { SessionAudience } from '../authSession.js';
const STYLE_PRESETS = [
'爆款短视频,节奏紧凑,适合口播',
'商品种草,真实体验,强转化',
'知识科普,信息密度高,画面干净',
'电影感广告,镜头运动丰富,高级质感',
];
function terminal(status: string) {
return ['succeeded', 'failed', 'cancelled'].includes(status);
}
function statusLabel(status: string) {
if (status === 'succeeded') return '完成';
if (status === 'failed') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'running') return '生成中';
return '排队中';
}
function progress(job: MoneyPrinterJob) {
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
}
function formatTime(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', { hour12: false });
}
function jobAssets(job: MoneyPrinterJob) {
return Array.isArray(job.result?.assets) ? job.result.assets : [];
}
function planValue(job: MoneyPrinterJob, key: string) {
return job.result?.script?.plan?.[key] || '';
}
export default function MoneyPrinter() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
const [scriptModel, setScriptModel] = useState('gpt-5.4');
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
const [style, setStyle] = useState(STYLE_PRESETS[0]);
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
const [duration, setDuration] = useState(30);
const [ratio, setRatio] = useState('9:16');
const [resolution, setResolution] = useState('1080p');
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const loadJobs = useCallback(async () => {
const payload = await client.getMoneyPrinterJobs({ limit: 30 });
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
}, [client]);
useEffect(() => {
loadJobs().catch((err) => setError(err?.message || '短视频任务加载失败'));
}, [loadJobs]);
useEffect(() => {
if (!jobs.some((job) => !terminal(job.status))) return;
const timer = window.setInterval(() => {
client.getMoneyPrinterJobs({ limit: 30 })
.then((payload) => setJobs(payload.jobs || []))
.catch(() => {});
}, 5000);
return () => window.clearInterval(timer);
}, [client, jobs]);
const submit = async () => {
if (!topic.trim() || submitting) return;
setSubmitting(true);
setError('');
try {
const payload = await client.createMoneyPrinterJob({
topic: topic.trim(),
scriptModel: scriptModel.trim(),
videoModel: videoModel.trim(),
style,
audience: audienceText,
duration,
ratio,
resolution,
requirements,
language: '中文',
});
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
} catch (err: any) {
setError(err?.message || '短视频任务创建失败');
} finally {
setSubmitting(false);
}
};
const cancel = async (id: string) => {
const payload = await client.cancelMoneyPrinterJob(id);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
};
const clear = async () => {
await client.clearMoneyPrinterJobs();
await loadJobs();
};
const retry = (job: MoneyPrinterJob) => {
const request = job.result?.request || {};
setTopic(request.topic || job.result?.topic || topic);
setScriptModel(request.scriptModel || scriptModel);
setVideoModel(request.videoModel || request.model || videoModel);
setStyle(request.style || style);
setAudienceText(request.audience || audienceText);
setDuration(Number(request.duration || duration));
setRatio(request.ratio || ratio);
setResolution(request.resolution || resolution);
setRequirements(request.requirements || requirements);
};
return (
<div className="money-printer-page animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">短视频工厂</h1>
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BoosAPI 视频模型生产成片。</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting || !topic.trim()}>
{submitting ? '提交中...' : '生成短视频'}
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div className="money-printer-layout">
<section className="money-printer-panel">
<div className="money-printer-hero">
<span>MoneyPrinterTurbo inside</span>
<strong>一句主题,自动拆成脚本、分镜、视频提示词和成片任务。</strong>
</div>
<label className="money-printer-field">
<span>短视频主题</span>
<textarea value={topic} onChange={(event) => setTopic(event.target.value)} rows={4} />
</label>
<div className="money-printer-form-grid">
<label className="money-printer-field">
<span>脚本模型</span>
<input value={scriptModel} onChange={(event) => setScriptModel(event.target.value)} />
</label>
<label className="money-printer-field">
<span>视频模型</span>
<input value={videoModel} onChange={(event) => setVideoModel(event.target.value)} />
</label>
<label className="money-printer-field">
<span>时长</span>
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} />
</label>
<label className="money-printer-field">
<span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select>
</label>
<label className="money-printer-field">
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
</label>
</div>
<label className="money-printer-field">
<span>风格</span>
<select value={style} onChange={(event) => setStyle(event.target.value)}>
{STYLE_PRESETS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="money-printer-field">
<span>目标受众</span>
<input value={audienceText} onChange={(event) => setAudienceText(event.target.value)} />
</label>
<label className="money-printer-field">
<span>额外要求</span>
<textarea value={requirements} onChange={(event) => setRequirements(event.target.value)} rows={4} />
</label>
</section>
<section className="money-printer-panel">
<div className="money-printer-section-head">
<div>
<h2>任务队列</h2>
<p>脚本、视频生成和工作空间归档都会在后台执行。</p>
</div>
<button type="button" className="btn btn-ghost" onClick={() => void clear()}>清空已完成</button>
</div>
<div className="money-printer-job-list">
{jobs.length === 0 ? (
<div className="empty-state" style={{ minHeight: 220 }}>
<div className="empty-state-title">暂无短视频任务</div>
<div className="empty-state-desc">提交主题后,系统会自动调用 BoosAPI 内置模型生成脚本和视频。</div>
</div>
) : jobs.map((job) => {
const percent = progress(job);
const assets = jobAssets(job);
return (
<article key={job.id} className="money-printer-job-card">
<div className="money-printer-job-head">
<div>
<strong>{job.title || '短视频工厂任务'}</strong>
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
</div>
<em>{statusLabel(job.status)}</em>
</div>
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
<div className="money-printer-job-meta">
<span>进度 {percent}%</span>
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
</div>
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
{job.error && <div className="money-printer-job-error">{job.error}</div>}
{assets.length > 0 && (
<div className="money-printer-assets">
{assets.map((asset: any) => (
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
成片 · {asset.publicId || asset.status}
</a>
))}
</div>
)}
<div className="money-printer-job-buttons">
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)}>取消</button>}
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
</div>
</article>
);
})}
</div>
</section>
</div>
</div>
);
}