Add native MoneyPrinter short video factory
This commit is contained in:
@@ -23,6 +23,7 @@ mod digital_human;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
mod money_printer;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
mod picture_agent;
|
||||
@@ -46,6 +47,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
.merge(picture_agent::routes())
|
||||
.merge(money_printer::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(digital_human::routes())
|
||||
.merge(comfyui::routes())
|
||||
|
||||
@@ -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, "正在提交 BossAI 视频模型").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 })))
|
||||
}
|
||||
@@ -39,6 +39,7 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
||||
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
||||
const Workflows = lazy(() => import('./pages/Workflows.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: '/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: '/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: '/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> },
|
||||
@@ -419,6 +421,7 @@ export const userSidebarGroups = [
|
||||
label: '创作应用',
|
||||
items: [
|
||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/user/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/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> },
|
||||
@@ -430,6 +433,7 @@ const topNavItems = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
{ label: '生图助手', to: '/picture-agent' },
|
||||
{ label: '短视频工厂', to: '/money-printer' },
|
||||
{ label: '工作流', to: '/workflows' },
|
||||
{ label: '视频助手', to: '/comfyui-agent' },
|
||||
{ label: '工作空间', to: '/workspace' },
|
||||
@@ -987,6 +991,7 @@ function AppShell() {
|
||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/money-printer" element={<MoneyPrinter />} />
|
||||
<Route path="/digital-human" element={<DigitalHuman />} />
|
||||
<Route path="/workflows" element={<Workflows />} />
|
||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||
@@ -1006,6 +1011,7 @@ function AppShell() {
|
||||
<Route path="/user/usage" element={<UsageRecords />} />
|
||||
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
||||
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/user/money-printer" element={<MoneyPrinter />} />
|
||||
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
||||
<Route path="/user/workflows" element={<Workflows />} />
|
||||
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
||||
|
||||
@@ -1184,6 +1184,18 @@ export type DigitalHumanJobsResponse = {
|
||||
jobs: DigitalHumanJob[];
|
||||
};
|
||||
|
||||
export type MoneyPrinterJob = DigitalHumanJob;
|
||||
|
||||
export type MoneyPrinterJobResponse = {
|
||||
success: boolean;
|
||||
job: MoneyPrinterJob;
|
||||
};
|
||||
|
||||
export type MoneyPrinterJobsResponse = {
|
||||
success: boolean;
|
||||
jobs: MoneyPrinterJob[];
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -2081,6 +2093,20 @@ export const api = {
|
||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||
clearDigitalHumanJobs: () =>
|
||||
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 = {
|
||||
register: (data: {
|
||||
@@ -2170,4 +2196,18 @@ export const userApi = {
|
||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||
clearDigitalHumanJobs: () =>
|
||||
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" }),
|
||||
};
|
||||
|
||||
@@ -9250,6 +9250,199 @@ textarea {
|
||||
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 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -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 思路:主题生成脚本和分镜,再用 BossAI 视频模型生产成片。</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">提交主题后,系统会自动调用 BossAI 内置模型生成脚本和视频。</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user