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:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
+658
View File
@@ -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 &sections.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 &sections.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) = &sections.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)))
}