refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, Sse},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||
use tracing::info;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
||||
|
||||
## 规则
|
||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
||||
|
||||
## 结构化标记格式
|
||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
||||
|
||||
【CHARACTER】
|
||||
name: 林澈
|
||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
||||
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
|
||||
【/CHARACTER】
|
||||
|
||||
【SCENE】
|
||||
id: 1
|
||||
description: 飞船驾驶舱内景
|
||||
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
|
||||
【/SCENE】
|
||||
|
||||
【TTS】
|
||||
character: 林澈
|
||||
text: 我们准备出舱检查
|
||||
emotion: 平静
|
||||
【/TTS】
|
||||
|
||||
【WORKFLOW】
|
||||
```json
|
||||
{"comfyui_workflow": {}}
|
||||
```
|
||||
【/WORKFLOW】
|
||||
|
||||
## 关键要求
|
||||
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
|
||||
- 不要一次输出太多,先输角色和场景,下次再输工作流
|
||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||
"#;
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedSections {
|
||||
characters: Vec<CharacterSection>,
|
||||
scenes: Vec<SceneSection>,
|
||||
tts_list: Vec<TtsSection>,
|
||||
workflow_json: Option<String>,
|
||||
text_content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CharacterSection {
|
||||
name: String,
|
||||
description: String,
|
||||
prompt_front: String,
|
||||
prompt_back: String,
|
||||
prompt_left: String,
|
||||
prompt_right: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SceneSection {
|
||||
id: u32,
|
||||
description: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TtsSection {
|
||||
character: String,
|
||||
text: String,
|
||||
emotion: String,
|
||||
}
|
||||
|
||||
fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let mut sections = ParsedSections::default();
|
||||
sections.text_content = full_text.to_string();
|
||||
|
||||
// Parse CHARACTER blocks
|
||||
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
|
||||
for cap in char_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut char_section = CharacterSection::default();
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
|
||||
char_section.name = val.trim().to_string();
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
char_section.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_front = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_back = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_left = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_right = val.to_string();
|
||||
}
|
||||
}
|
||||
if !char_section.name.is_empty() {
|
||||
sections.characters.push(char_section);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SCENE blocks
|
||||
let scene_re = Regex::new(r"(?s)【SCENE】\s*(.*?)【/SCENE】").unwrap();
|
||||
for cap in scene_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut scene = SceneSection {
|
||||
id: 0,
|
||||
description: String::new(),
|
||||
prompt: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.id = val.parse().unwrap_or(0);
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
scene.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.prompt = val.to_string();
|
||||
}
|
||||
}
|
||||
if !scene.prompt.is_empty() {
|
||||
sections.scenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TTS blocks
|
||||
let tts_re = Regex::new(r"(?s)【TTS】\s*(.*?)【/TTS】").unwrap();
|
||||
for cap in tts_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut tts = TtsSection {
|
||||
character: String::new(),
|
||||
text: String::new(),
|
||||
emotion: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.character = val.to_string();
|
||||
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.text = val.to_string();
|
||||
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.emotion = val.to_string();
|
||||
}
|
||||
}
|
||||
if !tts.text.is_empty() {
|
||||
sections.tts_list.push(tts);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse WORKFLOW blocks
|
||||
let wf_re = Regex::new(r"(?s)【WORKFLOW】\s*```(?:json)?\s*(.*?)```\s*【/WORKFLOW】").unwrap();
|
||||
if let Some(cap) = wf_re.captures(full_text) {
|
||||
let wf = cap.get(1).unwrap().as_str().trim().to_string();
|
||||
sections.workflow_json = Some(wf);
|
||||
}
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("image request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("image response parse failed: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("image upstream returned {}: {}", status, data));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("TTS request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("TTS upstream returned {}: {}", status, body));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("TTS response read failed: {}", e))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||
}
|
||||
|
||||
async fn chat_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
|
||||
{
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_header.strip_prefix("bearer "))
|
||||
.unwrap_or_default();
|
||||
|
||||
if token != state.config.auth.auth_token {
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "Unauthorized" })),
|
||||
));
|
||||
}
|
||||
|
||||
let messages = body
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
for message in messages {
|
||||
if message.get("role").and_then(Value::as_str).is_none()
|
||||
|| message.get("content").and_then(Value::as_str).is_none()
|
||||
{
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let messages = messages.clone();
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
|
||||
|
||||
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
|
||||
upstream_messages.push(json!({
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
}));
|
||||
upstream_messages.extend(messages);
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
info!("comfyui-agent: sending chat request to upstream");
|
||||
|
||||
let response = match client
|
||||
.post(&upstream_url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": upstream_messages,
|
||||
"temperature": 0.7,
|
||||
"stream": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("服务暂时不可用,请稍后重试。({})", e),
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "服务暂时不可用,请稍后重试",
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Phase 1: Stream the LLM text response
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
_ = tx.closed() => {
|
||||
return;
|
||||
}
|
||||
chunk = stream.next() => chunk,
|
||||
};
|
||||
|
||||
let Some(chunk) = chunk else {
|
||||
break;
|
||||
};
|
||||
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n\n[错误: {}]", error),
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let mut line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
|
||||
|
||||
if line_bytes.last() == Some(&b'\n') {
|
||||
line_bytes.pop();
|
||||
}
|
||||
if line_bytes.last() == Some(&b'\r') {
|
||||
line_bytes.pop();
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
let line = line.trim().to_string();
|
||||
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = line.trim_start_matches("data: ").trim().to_string();
|
||||
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let content = parsed
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("delta"))
|
||||
.and_then(|delta| delta.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if !content.is_empty() {
|
||||
accumulated_text.push_str(&content);
|
||||
if !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": content,
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Parse structured sections from accumulated text
|
||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||
let sections = parse_sections(&accumulated_text);
|
||||
|
||||
let has_structured_data = !sections.characters.is_empty()
|
||||
|| !sections.scenes.is_empty()
|
||||
|| !sections.tts_list.is_empty()
|
||||
|| sections.workflow_json.is_some();
|
||||
|
||||
if !has_structured_data {
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send a processing indicator
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "\n\n🔄 正在处理结构化数据..."
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate images for each character (4 angles each)
|
||||
for (char_idx, character) in sections.characters.iter().enumerate() {
|
||||
// Send character_prompts event
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "character_prompts",
|
||||
"character": character.name,
|
||||
}),
|
||||
);
|
||||
|
||||
let angles = [
|
||||
("正面", &character.prompt_front, "front"),
|
||||
("背面", &character.prompt_back, "back"),
|
||||
("左侧", &character.prompt_left, "left"),
|
||||
("右侧", &character.prompt_right, "right"),
|
||||
];
|
||||
|
||||
for (angle_label, prompt, angle_key) in &angles {
|
||||
if prompt.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"comfyui-agent: generating image for {} - {}",
|
||||
character.name, angle_label
|
||||
);
|
||||
|
||||
// Add a short delay between image gen requests to avoid rate limiting
|
||||
if char_idx > 0 || *angle_key != "front" {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match call_image_gen(prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "image",
|
||||
"url": url,
|
||||
"character": character.name,
|
||||
"angle": angle_key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} [{}] 图片生成失败,请重试", character.name, angle_label),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate images for each scene
|
||||
for scene in §ions.scenes {
|
||||
info!("comfyui-agent: generating scene image for scene {}", scene.id);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
match call_image_gen(&scene.prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Still send scene info even without image
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TTS for each dialogue
|
||||
for tts in §ions.tts_list {
|
||||
info!("comfyui-agent: generating TTS for {}", tts.character);
|
||||
|
||||
// Choose voice based on emotion
|
||||
let voice = match tts.emotion.as_str() {
|
||||
"激动" | "愤怒" => "nova",
|
||||
"悲伤" | "恐惧" => "shimmer",
|
||||
_ => "alloy",
|
||||
};
|
||||
|
||||
match call_tts(&tts.text, voice).await {
|
||||
Ok(audio_bytes) => {
|
||||
// For SSE, we'll send a data URL for small audio, or a placeholder
|
||||
// Since SSE can't easily send binary, we store to file and send URL
|
||||
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
|
||||
tts.character.replace(' ', "_"),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
);
|
||||
if let Ok(()) = tokio::fs::write(&audio_path, &audio_bytes).await {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "tts",
|
||||
"character": tts.character,
|
||||
"text": tts.text,
|
||||
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("comfyui-agent: TTS generation failed: {}", e);
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} 的语音生成失败", tts.character),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Send ComfyUI workflow if present
|
||||
if let Some(wf_json) = §ions.workflow_json {
|
||||
info!("comfyui-agent: sending workflow JSON");
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "comfyui_workflow",
|
||||
"content": wf_json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Signal done with summary
|
||||
let mut summary = String::from("\n\n✅ **处理完成!**");
|
||||
if !sections.characters.is_empty() {
|
||||
summary.push_str(&format!("\n- 角色: {} 个", sections.characters.len()));
|
||||
}
|
||||
if !sections.scenes.is_empty() {
|
||||
summary.push_str(&format!("\n- 场景: {} 个", sections.scenes.len()));
|
||||
}
|
||||
if !sections.tts_list.is_empty() {
|
||||
summary.push_str(&format!("\n- TTS: {} 段", sections.tts_list.len()));
|
||||
}
|
||||
if sections.workflow_json.is_some() {
|
||||
summary.push_str("\n- ComfyUI 工作流: ✅ 已生成");
|
||||
}
|
||||
send_sse(&tx, json!({ "type": "text", "content": summary }));
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
Reference in New Issue
Block a user