feat: polish workflow studio and workspace assets

This commit is contained in:
boos
2026-05-25 20:42:38 +08:00
parent cb35d5fc6e
commit 1ba293c262
29 changed files with 2299 additions and 158 deletions
+41
View File
@@ -5,6 +5,7 @@ use crate::{
auth::{jwt::generate_jwt, password::verify_password},
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
services::register_captcha::CaptchaVerifyError,
AppState,
};
use axum::{
@@ -25,6 +26,10 @@ pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
#[serde(rename = "captchaId", alias = "captcha_id")]
pub captcha_id: String,
#[serde(rename = "captchaCode", alias = "captcha_code")]
pub captcha_code: String,
}
#[derive(Debug, Deserialize)]
@@ -59,6 +64,8 @@ pub struct UserProfile {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/auth/register-captcha", get(register_captcha))
.route("/api/users/register-captcha", get(register_captcha))
.route("/api/auth/register", post(register))
.route("/api/users/register", post(register))
.route("/api/auth/login", post(login))
@@ -73,6 +80,27 @@ pub fn routes() -> Router<AppState> {
// ── Handlers ─────────────────────────────────────────────────────────
/// GET /api/users/register-captcha
async fn register_captcha(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> AppResult<Json<Value>> {
let challenge = state.register_captcha.issue(Some(addr.ip()));
let id = challenge.id;
let image = challenge.image;
let expires_in_seconds = challenge.expires_in_seconds;
Ok(Json(json!({
"id": id,
"image": image,
"expiresInSeconds": expires_in_seconds,
"data": {
"id": id,
"image": image,
"expiresInSeconds": expires_in_seconds,
}
})))
}
/// POST /api/auth/register
async fn register(
State(state): State<AppState>,
@@ -93,6 +121,19 @@ async fn register(
}
let client_ip = Some(addr.ip());
if let Err(err) =
state
.register_captcha
.verify(&payload.captcha_id, &payload.captcha_code, client_ip)
{
let message = match err {
CaptchaVerifyError::Missing => "请输入图像验证码",
CaptchaVerifyError::Expired => "图像验证码已过期,请刷新后重试",
CaptchaVerifyError::Mismatch => "图像验证码错误,请重新输入",
};
return Err(AppError::BadRequest(message.into()));
}
if let Err(limit) = state
.registration_limiter
.check_and_record_attempt(client_ip, &payload.email)
+324 -1
View File
@@ -11,7 +11,7 @@ use crate::{
};
use axum::{
body::Bytes,
extract::{Path, Query, State},
extract::{ws::Message, Path, Query, State, WebSocketUpgrade},
http::{HeaderMap, Method, StatusCode, Uri},
response::{Html, IntoResponse, Response},
routing::{any, get, post},
@@ -20,7 +20,9 @@ use axum::{
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use tokio_tungstenite::{connect_async, tungstenite};
use tracing::info;
use tungstenite::client::IntoClientRequest;
/// Shared HTTP client
fn http_client() -> Client {
@@ -85,11 +87,186 @@ pub fn routes() -> Router<AppState> {
// Public ComfyUI reverse proxy for the embedded iframe UI.
.route("/api/comfyui/proxy", any(proxy_static_root))
.route("/api/comfyui/proxy/", any(proxy_static_root))
.route("/api/comfyui/proxy/ws", get(proxy_ws))
.route(
"/api/comfyui/proxy/api/experiment/models",
get(proxy_model_folders),
)
.route(
"/api/comfyui/proxy/api/experiment/models/{folder}",
get(proxy_models_in_folder),
)
.route(
"/api/comfyui/proxy/api/view_metadata/{folder}",
get(proxy_model_metadata),
)
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
// HTML page (public)
.route("/comfyui", get(comfyui_page))
}
async fn proxy_model_folders(State(state): State<AppState>) -> AppResult<Response> {
let mut folders = proxy_to_comfyui_json(&state, "GET", "/api/experiment/models", None).await?;
if let Some(items) = folders.as_array_mut() {
if !items
.iter()
.any(|item| item.get("name").and_then(Value::as_str) == Some("boosapi_models"))
{
items.insert(0, serde_json::json!({ "name": "boosapi_models" }));
}
}
Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
folders.to_string(),
)
.into_response())
}
async fn proxy_models_in_folder(
State(state): State<AppState>,
Path(folder): Path<String>,
) -> AppResult<Response> {
if folder == "boosapi_models" {
let models = list_boosapi_models(&state).await?;
let items: Vec<Value> = models
.into_iter()
.map(|name| serde_json::json!({ "name": name, "pathIndex": 0 }))
.collect();
return Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
Value::Array(items).to_string(),
)
.into_response());
}
let path = format!("/api/experiment/models/{}", urlencoding(&folder));
proxy_to_comfyui_response(&state, "GET", &path, None).await
}
async fn proxy_model_metadata(
State(state): State<AppState>,
Path(folder): Path<String>,
uri: Uri,
) -> AppResult<Response> {
if folder == "boosapi_models" {
let filename = uri
.query()
.and_then(|query| {
query.split('&').find_map(|part| {
let (key, value) = part.split_once('=')?;
(key == "filename").then_some(value.replace("%2F", "/").replace("%20", " "))
})
})
.unwrap_or_else(|| "BoosAPI model".to_string());
let body = serde_json::json!({
"title": filename,
"author": "BoosAPI",
"description": "BoosAPI 后台模型路由。双击可创建 BoosAPI 节点,实际调用本地 /v1 模型网关。",
"modelspec.tags": "boosapi,api,model",
});
return Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body.to_string(),
)
.into_response());
}
let path = match uri.query() {
Some(query) => format!("/api/view_metadata/{}?{}", urlencoding(&folder), query),
None => format!("/api/view_metadata/{}", urlencoding(&folder)),
};
proxy_to_comfyui_response(&state, "GET", &path, None).await
}
async fn proxy_ws(
State(state): State<AppState>,
ws: WebSocketUpgrade,
uri: Uri,
) -> AppResult<Response> {
let mut target = comfyui_base(&state)
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1);
target.push_str("/ws");
if let Some(query) = uri.query() {
target.push('?');
target.push_str(query);
}
Ok(ws.on_upgrade(move |socket| proxy_ws_bridge(socket, target)))
}
async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target: String) {
let request = match target.as_str().into_client_request() {
Ok(request) => request,
Err(err) => {
let _ = client_socket
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into()))
.await;
return;
}
};
let Ok((upstream_socket, _)) = connect_async(request).await else {
let _ = client_socket
.send(Message::Text("ComfyUI websocket connect failed".into()))
.await;
return;
};
use futures::{SinkExt, StreamExt};
let (mut upstream_write, mut upstream_read) = upstream_socket.split();
loop {
tokio::select! {
from_upstream = upstream_read.next() => {
match from_upstream {
Some(Ok(message)) => {
let outgoing = match message {
tungstenite::Message::Text(text) => Message::Text(text.to_string().into()),
tungstenite::Message::Binary(bytes) => Message::Binary(bytes.to_vec().into()),
tungstenite::Message::Ping(bytes) => Message::Ping(bytes.to_vec().into()),
tungstenite::Message::Pong(bytes) => Message::Pong(bytes.to_vec().into()),
tungstenite::Message::Close(frame) => {
let _ = client_socket.send(Message::Close(frame.map(|frame| axum::extract::ws::CloseFrame {
code: frame.code.into(),
reason: frame.reason.to_string().into(),
}))).await;
break;
}
tungstenite::Message::Frame(_) => continue,
};
if client_socket.send(outgoing).await.is_err() {
break;
}
}
Some(Err(_)) | None => break,
}
}
from_client = client_socket.recv() => {
match from_client {
Some(Ok(message)) => {
let outgoing = match message {
Message::Text(text) => tungstenite::Message::Text(text.to_string().into()),
Message::Binary(bytes) => tungstenite::Message::Binary(bytes.to_vec().into()),
Message::Ping(bytes) => tungstenite::Message::Ping(bytes.to_vec().into()),
Message::Pong(bytes) => tungstenite::Message::Pong(bytes.to_vec().into()),
Message::Close(frame) => {
let _ = upstream_write.send(tungstenite::Message::Close(frame.map(|frame| tungstenite::protocol::CloseFrame {
code: frame.code.into(),
reason: frame.reason.to_string().into(),
}))).await;
break;
}
};
if upstream_write.send(outgoing).await.is_err() {
break;
}
}
Some(Err(_)) | None => break,
}
}
}
}
}
// ── Business route handlers (require auth) ───────────────────────────
/// POST /api/comfyui/prompt
@@ -243,6 +420,7 @@ async fn proxy_static_request(
.replace("comfyui", "BoosAPI");
// Rewrite URLs
let rewritten = rewrite_proxy_urls(&rebranded);
let rewritten = inject_boosapi_model_library_patch(&rewritten);
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
@@ -274,6 +452,96 @@ async fn proxy_static_request(
}
}
async fn proxy_to_comfyui_json(
state: &AppState,
method: &str,
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let client = http_client();
let url = format!("{}{}", comfyui_base(state), path);
let req = match method {
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
_ => client.get(&url),
};
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !status.is_success() {
return Err(AppError::UpstreamError(text));
}
serde_json::from_str(&text).map_err(|e| AppError::UpstreamError(e.to_string()))
}
async fn proxy_to_comfyui_response(
state: &AppState,
method: &str,
path: &str,
body: Option<Value>,
) -> AppResult<Response> {
let client = http_client();
let url = format!("{}{}", comfyui_base(state), path);
let req = match method {
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
_ => client.get(&url),
};
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[(axum::http::header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
async fn list_boosapi_models(state: &AppState) -> AppResult<Vec<String>> {
let rows: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT COALESCE(NULLIF(TRIM(model_mapping), ''), model_pattern)
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
AND TRIM(COALESCE(model_pattern, '')) <> ''
ORDER BY 1 COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await?;
let mut seen = std::collections::HashSet::new();
let mut models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert(value.clone()))
.collect();
if models.is_empty() {
models.extend([
"gpt-5.5".to_string(),
"gpt-5.4".to_string(),
"gpt-image-2".to_string(),
]);
}
Ok(models)
}
/// GET /comfyui — HTML page with iframe
async fn comfyui_page() -> Html<&'static str> {
Html(
@@ -335,6 +603,61 @@ fn rewrite_proxy_urls(html: &str) -> String {
result
}
fn inject_boosapi_model_library_patch(html: &str) -> String {
let patch = r#"<script>
(() => {
const isBoosapiModelText = (text) => {
const value = String(text || '').replace(/\s+/g, ' ').trim();
return value && !value.includes('boosapi_models') && !value.endsWith('_models');
};
const isImageModel = (name) => /image|dall|flux|sd|midjourney|stable|wan|seedream/i.test(name);
const addBoosapiNode = (model) => {
try {
const graph = window.app?.graph || window.LiteGraph?.LGraphCanvas?.active_canvas?.graph;
const canvas = window.app?.canvas || window.LiteGraph?.LGraphCanvas?.active_canvas;
if (!graph || !window.LiteGraph?.createNode) return false;
const nodeType = isImageModel(model) ? 'BoosAPITextToImage' : 'BoosAPILLM';
const node = window.LiteGraph.createNode(nodeType, nodeType);
if (!node) return false;
node.pos = canvas?.convertEventToCanvasOffset
? canvas.convertEventToCanvasOffset({ clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 })
: [120, 120];
graph.add(node);
const modelWidget = node.widgets?.find((item) => item.name === 'model');
if (modelWidget) modelWidget.value = model;
graph.setDirtyCanvas?.(true, true);
canvas?.selectNode?.(node);
return true;
} catch (err) {
console.warn('[BoosAPI] failed to add model node', err);
return false;
}
};
const patchModelClicks = () => {
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
if (el.__boosapiModelPatch) return;
el.__boosapiModelPatch = true;
el.addEventListener('dblclick', (event) => {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
if (!isBoosapiModelText(text)) return;
const hasBoosapiAncestor = Array.from(el.parentElement?.querySelectorAll?.('*') || [])
.some((child) => String(child.textContent || '').includes('boosapi_models'));
if (!hasBoosapiAncestor && !text.match(/gpt|claude|gemini|image|dall|flux|seed|deepseek|qwen|glm|doubao|wan/i)) return;
if (addBoosapiNode(text)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
});
};
document.addEventListener('click', () => setTimeout(patchModelClicks, 100), true);
setInterval(patchModelClicks, 1200);
setTimeout(patchModelClicks, 500);
})();
</script>"#;
html.replace("</head>", &format!("{patch}\n</head>"))
}
/// Build query string from params
fn build_query_string(params: &HashMap<String, String>) -> String {
if params.is_empty() {
+4 -1
View File
@@ -1,6 +1,6 @@
use crate::{auth::jwt::validate_jwt, db::models::downstream_api_key::DownstreamApiKey, AppState};
use axum::{
extract::State,
extract::{DefaultBodyLimit, State},
http::HeaderMap,
response::sse::{Event, Sse},
routing::{get, post},
@@ -59,12 +59,15 @@ emotion: 平静
- 如果没有用户明确要求,切勿自作主张输出全部内容
"#;
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/comfyui-agent/status", get(status_handler))
.route("/api/comfyui-agent/models", get(models_handler))
.route("/api/comfyui-agent/image-models", get(image_models_handler))
.route("/api/comfyui-agent/chat", post(chat_handler))
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
}
#[derive(Debug, Deserialize)]
+927 -107
View File
File diff suppressed because it is too large Load Diff