Integrate ComfyUI agents and video workflows

This commit is contained in:
boos
2026-05-22 04:46:15 +08:00
parent 3c12738138
commit c1074ddf71
35 changed files with 2527 additions and 213 deletions
BIN
View File
Binary file not shown.
+97 -11
View File
@@ -14,12 +14,95 @@ import os
import requests import requests
# ── Config ─────────────────────────────────────────────────────────────── # ── Config ───────────────────────────────────────────────────────────────
BOOSAPI_BASE = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000") BOOSAPI_BASE_RAW = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000")
BOOSAPI_KEY = os.environ.get("BOOSAPI_KEY", "sk-boos4721") BOOSAPI_KEY = os.environ.get("BOOSAPI_KEY", "sk-boos4721")
HEADERS = { HEADERS = {
"Authorization": f"Bearer {BOOSAPI_KEY}", "Authorization": f"Bearer {BOOSAPI_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
FALLBACK_CHAT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"]
FALLBACK_IMAGE_MODELS = ["gpt-image-2", "gpt-image-1", "dall-e-3", "dall-e-2"]
FALLBACK_ALL_MODELS = FALLBACK_IMAGE_MODELS + FALLBACK_CHAT_MODELS
def normalize_boosapi_root(base):
base = str(base or "http://127.0.0.1:4000").strip().rstrip("/")
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
return base or "http://127.0.0.1:4000"
BOOSAPI_ROOT = normalize_boosapi_root(BOOSAPI_BASE_RAW)
BOOSAPI_V1 = f"{BOOSAPI_ROOT}/v1"
def raise_for_status_with_body(resp):
try:
resp.raise_for_status()
except requests.HTTPError as e:
body = (resp.text or "").strip()
if body:
print(f"[BoosAPI] HTTP {resp.status_code} response: {body[:2000]}")
raise requests.HTTPError(f"{e}; response body: {body[:1000]}", response=resp)
raise
def _ordered_unique(values):
seen = set()
result = []
for value in values:
value = str(value or "").strip()
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result
def fetch_boosapi_models():
"""Fetch models exposed by BoosAPI's own /v1/models route."""
try:
resp = requests.get(f"{BOOSAPI_V1}/models", headers=HEADERS, timeout=8)
resp.raise_for_status()
data = resp.json()
models = []
for item in data.get("data", []):
if isinstance(item, dict):
models.append(item.get("id") or item.get("name"))
elif isinstance(item, str):
models.append(item)
return _ordered_unique(models)
except Exception as e:
print(f"[BoosAPI] model list fallback: {e}")
return None
def model_choices(kind="all"):
models = fetch_boosapi_models()
if models is None:
if kind == "image":
return FALLBACK_IMAGE_MODELS
if kind == "chat":
return FALLBACK_CHAT_MODELS
return FALLBACK_ALL_MODELS
if kind == "image":
image_like = [
model for model in models
if any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(image_like + models) or FALLBACK_IMAGE_MODELS
if kind == "chat":
chat_like = [
model for model in models
if not any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(chat_like + models) or FALLBACK_CHAT_MODELS
return _ordered_unique(models) or FALLBACK_ALL_MODELS
def default_model(kind="all"):
choices = model_choices(kind)
return choices[0] if choices else FALLBACK_ALL_MODELS[0]
# ── Node Mappings ──────────────────────────────────────────────────────── # ── Node Mappings ────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {} NODE_CLASS_MAPPINGS = {}
@@ -37,7 +120,7 @@ class BoosAPIImageGen:
return { return {
"required": { "required": {
"prompt": ("STRING", {"multiline": True, "default": ""}), "prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (["dall-e-3", "dall-e-2", "gpt-image-2"], {"default": "gpt-image-2"}), "model": (model_choices("image"), {"default": default_model("image")}),
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}), "size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
"quality": (["standard", "hd"], {"default": "standard"}), "quality": (["standard", "hd"], {"default": "standard"}),
"n": ("INT", {"default": 1, "min": 1, "max": 4}), "n": ("INT", {"default": 1, "min": 1, "max": 4}),
@@ -50,10 +133,11 @@ class BoosAPIImageGen:
RETURN_TYPES = ("IMAGE", "STRING") RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "metadata") RETURN_NAMES = ("image", "metadata")
FUNCTION = "generate" FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI" CATEGORY = "BoosAPI"
def generate(self, prompt, model, size, quality, n, style="vivid"): def generate(self, prompt, model, size, quality, n, style="vivid"):
url = f"{BOOSAPI_BASE}/v1/images/generations" url = f"{BOOSAPI_V1}/images/generations"
payload = { payload = {
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
@@ -65,7 +149,7 @@ class BoosAPIImageGen:
} }
try: try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120) resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status() raise_for_status_with_body(resp)
data = resp.json() data = resp.json()
images = data.get("data", []) images = data.get("data", [])
# Return first image as b64 # Return first image as b64
@@ -104,7 +188,7 @@ class BoosAPILLM:
return { return {
"required": { "required": {
"prompt": ("STRING", {"multiline": True, "default": ""}), "prompt": ("STRING", {"multiline": True, "default": ""}),
"model": ("STRING", {"default": "gpt-5.5"}), "model": (model_choices("chat"), {"default": default_model("chat")}),
"system_prompt": ("STRING", {"multiline": True, "default": ""}), "system_prompt": ("STRING", {"multiline": True, "default": ""}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}), "temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}), "max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}),
@@ -114,10 +198,11 @@ class BoosAPILLM:
RETURN_TYPES = ("STRING",) RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",) RETURN_NAMES = ("text",)
FUNCTION = "chat" FUNCTION = "chat"
OUTPUT_NODE = True
CATEGORY = "BoosAPI" CATEGORY = "BoosAPI"
def chat(self, prompt, model, system_prompt, temperature, max_tokens): def chat(self, prompt, model, system_prompt, temperature, max_tokens):
url = f"{BOOSAPI_BASE}/v1/chat/completions" url = f"{BOOSAPI_V1}/chat/completions"
messages = [] messages = []
if system_prompt: if system_prompt:
messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "system", "content": system_prompt})
@@ -132,10 +217,10 @@ class BoosAPILLM:
} }
try: try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120) resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status() raise_for_status_with_body(resp)
data = resp.json() data = resp.json()
text = data["choices"][0]["message"]["content"] text = data["choices"][0]["message"]["content"]
return (text,) return {"ui": {"text": (text,)}, "result": (text,)}
except Exception as e: except Exception as e:
print(f"[BoosAPI] LLM chat failed: {e}") print(f"[BoosAPI] LLM chat failed: {e}")
raise raise
@@ -153,7 +238,7 @@ class BoosAPITextToImage:
return { return {
"required": { "required": {
"positive": ("STRING", {"multiline": True, "default": ""}), "positive": ("STRING", {"multiline": True, "default": ""}),
"model": (["gpt-image-2", "dall-e-3"], {"default": "gpt-image-2"}), "model": (model_choices("image"), {"default": default_model("image")}),
}, },
"optional": { "optional": {
"negative": ("STRING", {"multiline": True, "default": ""}), "negative": ("STRING", {"multiline": True, "default": ""}),
@@ -163,6 +248,7 @@ class BoosAPITextToImage:
RETURN_TYPES = ("IMAGE",) RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate" FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI" CATEGORY = "BoosAPI"
def generate(self, positive, model, negative="", size="1024x1024"): def generate(self, positive, model, negative="", size="1024x1024"):
@@ -171,7 +257,7 @@ class BoosAPITextToImage:
if negative: if negative:
prompt = f"{positive}\n避免: {negative}" prompt = f"{positive}\n避免: {negative}"
url = f"{BOOSAPI_BASE}/v1/images/generations" url = f"{BOOSAPI_V1}/images/generations"
payload = { payload = {
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
@@ -181,7 +267,7 @@ class BoosAPITextToImage:
} }
try: try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120) resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status() raise_for_status_with_body(resp)
data = resp.json() data = resp.json()
import base64 import base64
+2 -2
View File
@@ -12,7 +12,7 @@ ssl = false
[auth] [auth]
jwt_secret = "boosapi-dev-secret-change-in-production" jwt_secret = "boosapi-dev-secret-change-in-production"
jwt_expiry_hours = 72 jwt_expiry_hours = 72
auth_token = "change-me-admin-token" auth_token = "boos4721"
proxy_token = "sk-boos4721" proxy_token = "sk-boos4721"
[proxy] [proxy]
@@ -22,7 +22,7 @@ max_retries = 3
proxy_log_retention_days = 30 proxy_log_retention_days = 30
[comfyui] [comfyui]
enabled = true enabled = false
python_bin = "python3" python_bin = "python3"
port = 8188 port = 8188
extra_args = [] extra_args = []
+238 -6
View File
@@ -28,6 +28,126 @@ fn http_client() -> Client {
.expect("Failed to build HTTP client") .expect("Failed to build HTTP client")
} }
fn parse_upstream_json(
text: &str,
upstream_url: &str,
status: reqwest::StatusCode,
) -> AppResult<Value> {
serde_json::from_str(text).map_err(|e| {
let snippet = text.chars().take(500).collect::<String>();
AppError::UpstreamError(format!(
"Invalid JSON from upstream {upstream_url} ({status}): {e}; body: {snippet:?}"
))
})
}
fn parse_chat_completion_response(
text: &str,
upstream_url: &str,
status: reqwest::StatusCode,
fallback_model: &str,
) -> AppResult<Value> {
match serde_json::from_str(text) {
Ok(json) => Ok(json),
Err(json_error) if looks_like_sse(text) => aggregate_chat_sse(text, fallback_model)
.ok_or_else(|| {
AppError::UpstreamError(format!(
"Invalid SSE chat response from upstream {upstream_url} ({status}); JSON parse error: {json_error}; body: {:?}",
text.chars().take(500).collect::<String>()
))
}),
Err(json_error) => {
let snippet = text.chars().take(500).collect::<String>();
Err(AppError::UpstreamError(format!(
"Invalid JSON from upstream {upstream_url} ({status}): {json_error}; body: {snippet:?}"
)))
}
}
}
fn looks_like_sse(text: &str) -> bool {
text.trim_start().starts_with("data:")
}
fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
let mut id: Option<String> = None;
let mut created: Option<i64> = None;
let mut model: Option<String> = None;
let mut role: Option<String> = None;
let mut content = String::new();
let mut finish_reason: Option<Value> = None;
for line in text.lines() {
let Some(payload) = line.trim().strip_prefix("data:") else {
continue;
};
let payload = payload.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let Ok(chunk) = serde_json::from_str::<Value>(payload) else {
continue;
};
if id.is_none() {
id = chunk.get("id").and_then(Value::as_str).map(str::to_string);
}
if created.is_none() {
created = chunk.get("created").and_then(Value::as_i64);
}
if model.is_none() {
model = chunk
.get("model")
.and_then(Value::as_str)
.map(str::to_string);
}
let Some(choice) = chunk
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
else {
continue;
};
if let Some(delta) = choice.get("delta") {
if role.is_none() {
role = delta
.get("role")
.and_then(Value::as_str)
.map(str::to_string);
}
if let Some(piece) = delta.get("content").and_then(Value::as_str) {
content.push_str(piece);
}
}
if finish_reason.is_none()
&& !choice
.get("finish_reason")
.unwrap_or(&Value::Null)
.is_null()
{
finish_reason = choice.get("finish_reason").cloned();
}
}
if id.is_none() && content.is_empty() {
return None;
}
Some(serde_json::json!({
"id": id.unwrap_or_else(|| "chatcmpl-sse-aggregated".to_string()),
"object": "chat.completion",
"created": created.unwrap_or(0),
"model": model.unwrap_or_else(|| fallback_model.to_string()),
"choices": [{
"index": 0,
"message": {
"role": role.unwrap_or_else(|| "assistant".to_string()),
"content": content,
},
"finish_reason": finish_reason.unwrap_or_else(|| Value::String("stop".to_string())),
}],
}))
}
/// Execute a non-streaming chat completions request. /// Execute a non-streaming chat completions request.
pub async fn execute_chat_completions( pub async fn execute_chat_completions(
_state: AppState, _state: AppState,
@@ -58,8 +178,7 @@ pub async fn execute_chat_completions(
))); )));
} }
let json: Value = serde_json::from_str(&text) let json = parse_chat_completion_response(&text, &upstream_url, status, &route.model)?;
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
Ok(json) Ok(json)
} }
@@ -165,12 +284,126 @@ pub async fn execute_proxy_request(
))); )));
} }
let json: Value = serde_json::from_str(&text) let json = parse_upstream_json(&text, &upstream_url, status)?;
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
Ok(json) Ok(json)
} }
/// Generic GET proxy request to an upstream endpoint path.
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!(
"{}/{}",
route.base_url.trim_end_matches('/'),
path_suffix.trim_start_matches('/')
);
let resp = client
.get(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
parse_upstream_json(&text, &upstream_url, status)
}
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
/// task endpoints as a first-class compatibility target.
pub async fn execute_video_generation(
state: AppState,
route: UpstreamRoute,
body: Value,
) -> AppResult<Value> {
if is_volcengine_ark_base(&route.base_url) {
let body = volcengine_video_create_body(body, &route.model)?;
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
}
execute_proxy_request(state, route, "/videos", body).await
}
fn is_volcengine_ark_base(base_url: &str) -> bool {
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
}
fn volcengine_video_create_body(mut body: Value, fallback_model: &str) -> AppResult<Value> {
if body.get("content").is_some() {
return Ok(body);
}
let prompt = body
.get("prompt")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::BadRequest("prompt is required for Volcengine video tasks".into())
})?
.to_string();
let model = body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(fallback_model)
.to_string();
let mut text = prompt;
for (field, flag) in [
("ratio", "--ratio"),
("fps", "--fps"),
("duration", "--dur"),
("dur", "--dur"),
("resolution", "--resolution"),
] {
if let Some(value) = body.get(field).and_then(scalar_to_string) {
let marker = format!("{flag} ");
if !text.contains(&marker) {
text.push(' ');
text.push_str(flag);
text.push(' ');
text.push_str(&value);
}
}
}
if let Some(object) = body.as_object_mut() {
object.remove("prompt");
object.insert("model".to_string(), Value::String(model));
object.insert(
"content".to_string(),
Value::Array(vec![serde_json::json!({
"type": "text",
"text": text,
})]),
);
}
Ok(body)
}
fn scalar_to_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.trim().to_string()).filter(|s| !s.is_empty()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(v) => Some(v.to_string()),
_ => None,
}
}
/// Execute an image generation request. /// Execute an image generation request.
pub async fn execute_image_generation( pub async fn execute_image_generation(
_state: AppState, _state: AppState,
@@ -204,8 +437,7 @@ pub async fn execute_image_generation(
))); )));
} }
let json: Value = serde_json::from_str(&text) let json = parse_upstream_json(&text, &upstream_url, status)?;
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
Ok(json) Ok(json)
} }
+24 -1
View File
@@ -232,15 +232,22 @@ impl TokenRouter {
Ok(()) Ok(())
} }
pub async fn record_failure(&self, channel_id: i64, _info: FailureInfo) -> AppResult<()> { pub async fn record_failure(&self, channel_id: i64, info: FailureInfo) -> AppResult<()> {
let cooldown_seconds = cooldown_seconds_for_failure(&info);
sqlx::query( sqlx::query(
"UPDATE route_channels "UPDATE route_channels
SET fail_count = COALESCE(fail_count, 0) + 1, SET fail_count = COALESCE(fail_count, 0) + 1,
consecutive_fail_count = consecutive_fail_count + 1, consecutive_fail_count = consecutive_fail_count + 1,
cooldown_level = cooldown_level + 1, cooldown_level = cooldown_level + 1,
cooldown_until = CASE
WHEN ? > 0 THEN datetime('now', '+' || ? || ' seconds')
ELSE cooldown_until
END,
last_fail_at = datetime('now') last_fail_at = datetime('now')
WHERE id = ?", WHERE id = ?",
) )
.bind(cooldown_seconds)
.bind(cooldown_seconds)
.bind(channel_id) .bind(channel_id)
.execute(&*self.db) .execute(&*self.db)
.await?; .await?;
@@ -248,6 +255,22 @@ impl TokenRouter {
} }
} }
fn cooldown_seconds_for_failure(info: &FailureInfo) -> i64 {
match info.action.as_str() {
"retry_same_channel" => 10,
"refresh_auth" => 60,
"failover" => {
if matches!(info.status, Some(429 | 500..=599) | None) {
300
} else {
60
}
}
"terminal" => 900,
_ => 60,
}
}
trait IfEmptyThen { trait IfEmptyThen {
fn if_empty_then(self, fallback: String) -> String; fn if_empty_then(self, fallback: String) -> String;
} }
+2 -1
View File
@@ -457,7 +457,8 @@ pub async fn get_account_token_value(
if is_masked_token(&token.token) || token.value_status.eq_ignore_ascii_case("masked") { if is_masked_token(&token.token) || token.value_status.eq_ignore_ascii_case("masked") {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。".into(), "当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。"
.into(),
)); ));
} }
+34 -12
View File
@@ -170,15 +170,23 @@ pub async fn list_accounts(
.unwrap_or_default(); .unwrap_or_default();
let health_by_account: std::collections::HashMap< let health_by_account: std::collections::HashMap<
i64, i64,
(String, Option<i64>, Option<String>, Option<i64>, Option<String>), (
String,
Option<i64>,
Option<String>,
Option<i64>,
Option<String>,
),
> = latest_health_rows > = latest_health_rows
.into_iter() .into_iter()
.map(|(account_id, status, http_status, error_message, latency_ms, created_at)| { .map(
|(account_id, status, http_status, error_message, latency_ms, created_at)| {
( (
account_id, account_id,
(status, http_status, error_message, latency_ms, created_at), (status, http_status, error_message, latency_ms, created_at),
) )
}) },
)
.collect(); .collect();
let enriched: Vec<Value> = accounts let enriched: Vec<Value> = accounts
@@ -200,7 +208,13 @@ pub async fn list_accounts(
fn account_response_value( fn account_response_value(
account: &Account, account: &Account,
site: Option<&Site>, site: Option<&Site>,
runtime_health: Option<&(String, Option<i64>, Option<String>, Option<i64>, Option<String>)>, runtime_health: Option<&(
String,
Option<i64>,
Option<String>,
Option<i64>,
Option<String>,
)>,
) -> Value { ) -> Value {
let credential_mode = resolve_account_credential_mode(account); let credential_mode = resolve_account_credential_mode(account);
let mut row = serde_json::to_value(account).unwrap_or(json!({})); let mut row = serde_json::to_value(account).unwrap_or(json!({}));
@@ -220,7 +234,9 @@ fn account_response_value(
serde_json::to_value(site).unwrap_or(Value::Null), serde_json::to_value(site).unwrap_or(Value::Null),
); );
} }
let runtime_health_json = if account.status == "disabled" || site.is_some_and(|s| s.status == "disabled") { let runtime_health_json = if account.status == "disabled"
|| site.is_some_and(|s| s.status == "disabled")
{
json!({ json!({
"state": "disabled", "state": "disabled",
"reason": "账号或站点已禁用", "reason": "账号或站点已禁用",
@@ -230,7 +246,9 @@ fn account_response_value(
"state": "unhealthy", "state": "unhealthy",
"reason": "连接凭证已过期,请更新凭证", "reason": "连接凭证已过期,请更新凭证",
}) })
} else if let Some((status, http_status, error_message, latency_ms, created_at)) = runtime_health { } else if let Some((status, http_status, error_message, latency_ms, created_at)) =
runtime_health
{
let ok = status == "success"; let ok = status == "success";
json!({ json!({
"state": if ok { "healthy" } else { "unhealthy" }, "state": if ok { "healthy" } else { "unhealthy" },
@@ -597,7 +615,10 @@ pub async fn update_account(
// the previous value and decide whether routing needs to be rebuilt. // the previous value and decide whether routing needs to be rebuilt.
// `rebuild_routes` filters on `accounts.status = 'active'`, so any flip // `rebuild_routes` filters on `accounts.status = 'active'`, so any flip
// into or out of `active` changes which channels should exist. // into or out of `active` changes which channels should exist.
let new_status = payload.status.clone().unwrap_or_else(|| existing.status.clone()); let new_status = payload
.status
.clone()
.unwrap_or_else(|| existing.status.clone());
let status_changed = new_status != existing.status; let status_changed = new_status != existing.status;
sqlx::query( sqlx::query(
@@ -747,12 +768,10 @@ pub async fn batch_accounts(
Err(e) => Err(format!("DB error: {}", e)), Err(e) => Err(format!("DB error: {}", e)),
} }
} }
"refreshBalance" => { "refreshBalance" => match mark_account_balance_refreshed(&state, *id).await {
match mark_account_balance_refreshed(&state, *id).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(e), Err(e) => Err(e),
} },
}
other => Err(format!("不支持的批量操作: {}", other)), other => Err(format!("不支持的批量操作: {}", other)),
}; };
@@ -1088,7 +1107,10 @@ pub async fn refresh_account_models(
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
AppError::NotFound(format!("Site {} not found for account {}", account.site_id, id)) AppError::NotFound(format!(
"Site {} not found for account {}",
account.site_id, id
))
})?; })?;
let base_url = resolve_site_api_base_url(&state, &site).await?; let base_url = resolve_site_api_base_url(&state, &site).await?;
+2 -6
View File
@@ -208,9 +208,7 @@ pub async fn update_downstream_key(
// The agent's outbound calls in Local mode bear this key — disabling it // The agent's outbound calls in Local mode bear this key — disabling it
// would silently break image / TTS / chat for every user. // would silently break image / TTS / chat for every user.
if is_system_key(&existing.name, existing.user_id) && payload.enabled == Some(false) { if is_system_key(&existing.name, existing.user_id) && payload.enabled == Some(false) {
return Err(AppError::Forbidden( return Err(AppError::Forbidden("system key cannot be disabled".into()));
"system key cannot be disabled".into(),
));
} }
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models { let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
@@ -270,9 +268,7 @@ pub async fn delete_downstream_key(
})?; })?;
if is_system_key(&existing.name, existing.user_id) { if is_system_key(&existing.name, existing.user_id) {
return Err(AppError::Forbidden( return Err(AppError::Forbidden("system key cannot be deleted".into()));
"system key cannot be deleted".into(),
));
} }
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?") let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
+2 -5
View File
@@ -71,8 +71,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
) )
.route( .route(
"/api/sites/{id}/disabled-models", "/api/sites/{id}/disabled-models",
get(sites::list_site_disabled_models) get(sites::list_site_disabled_models).put(sites::update_site_disabled_models),
.put(sites::update_site_disabled_models),
); );
let accounts_routes = Router::new() let accounts_routes = Router::new()
@@ -299,9 +298,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
sqlx::query( sqlx::query("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
"CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
)
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
+4 -1
View File
@@ -147,7 +147,10 @@ pub async fn token_candidates(State(state): State<AppState>) -> AppResult<Json<V
if !seen.insert(row.account_id) { if !seen.insert(row.account_id) {
continue; continue;
} }
models_without_token.entry(model_name).or_default().push(json!({ models_without_token
.entry(model_name)
.or_default()
.push(json!({
"accountId": row.account_id, "accountId": row.account_id,
"username": row.username, "username": row.username,
"siteId": row.site_id, "siteId": row.site_id,
+64 -23
View File
@@ -64,9 +64,7 @@ pub async fn get_marketplace(
/// `AppState` and axum extractors so unit tests can drive it with an /// `AppState` and axum extractors so unit tests can drive it with an
/// in-memory SQLite pool (mirrors the `rebuild_routes_inner` pattern in /// in-memory SQLite pool (mirrors the `rebuild_routes_inner` pattern in
/// `services::route_rebuild`). /// `services::route_rebuild`).
pub(crate) async fn marketplace_inner( pub(crate) async fn marketplace_inner(pool: &sqlx::Pool<sqlx::Sqlite>) -> AppResult<Value> {
pool: &sqlx::Pool<sqlx::Sqlite>,
) -> AppResult<Value> {
// 1. Active (account, model) pairs. Filtering on accounts.status = // 1. Active (account, model) pairs. Filtering on accounts.status =
// 'active' here keeps the rest of the aggregation small. // 'active' here keeps the rest of the aggregation small.
let avail_rows: Vec<AvailRow> = sqlx::query_as( let avail_rows: Vec<AvailRow> = sqlx::query_as(
@@ -137,10 +135,8 @@ pub(crate) async fn marketplace_inner(
.get(&acc.site_id) .get(&acc.site_id)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let acc_tokens: &[TokenRow] = tokens_by_account let acc_tokens: &[TokenRow] =
.get(aid) tokens_by_account.get(aid).map(Vec::as_slice).unwrap_or(&[]);
.map(Vec::as_slice)
.unwrap_or(&[]);
token_count += acc_tokens.len(); token_count += acc_tokens.len();
let token_json: Vec<Value> = acc_tokens let token_json: Vec<Value> = acc_tokens
.iter() .iter()
@@ -253,26 +249,41 @@ mod tests {
let pool = fixture_pool().await; let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'site-a')") sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'site-a')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES "INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 10.0, 'active'), (1, 1, 'alice', 10.0, 'active'),
(2, 1, 'bob', 5.0, 'disabled'), (2, 1, 'bob', 5.0, 'disabled'),
(3, 1, 'carol', 7.0, 'active')", (3, 1, 'carol', 7.0, 'active')",
).execute(&pool).await.unwrap(); )
.execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES "INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1), (1, 'gpt-x', 1),
(2, 'gpt-x', 1), (2, 'gpt-x', 1),
(3, 'gpt-x', 0)", (3, 'gpt-x', 0)",
).execute(&pool).await.unwrap(); )
.execute(&pool)
.await
.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok"); let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value.get("models").and_then(Value::as_array).unwrap(); let models = value.get("models").and_then(Value::as_array).unwrap();
assert_eq!(models.len(), 1, "exactly one model should survive filtering"); assert_eq!(
models.len(),
1,
"exactly one model should survive filtering"
);
let m = &models[0]; let m = &models[0];
assert_eq!(m["name"], "gpt-x"); assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 1, "only account 1 (active + available) counts"); assert_eq!(
m["accountCount"], 1,
"only account 1 (active + available) counts"
);
let accounts = m["accounts"].as_array().unwrap(); let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1); assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0]["username"], "alice"); assert_eq!(accounts[0]["username"], "alice");
@@ -288,18 +299,28 @@ mod tests {
async fn aggregates_accounts_and_tokens_per_model() { async fn aggregates_accounts_and_tokens_per_model() {
let pool = fixture_pool().await; let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'openai-mirror'), (2, 'anthropic-mirror')") sqlx::query(
.execute(&pool).await.unwrap(); "INSERT INTO sites (id, name) VALUES (1, 'openai-mirror'), (2, 'anthropic-mirror')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES "INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 100.5, 'active'), (1, 1, 'alice', 100.5, 'active'),
(2, 2, 'bob', 50.0, 'active')", (2, 2, 'bob', 50.0, 'active')",
).execute(&pool).await.unwrap(); )
.execute(&pool)
.await
.unwrap();
sqlx::query( sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES "INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1), (1, 'gpt-x', 1),
(2, 'gpt-x', 1)", (2, 'gpt-x', 1)",
).execute(&pool).await.unwrap(); )
.execute(&pool)
.await
.unwrap();
// Account 1: two enabled tokens (one default), one disabled token (must // Account 1: two enabled tokens (one default), one disabled token (must
// be excluded). Account 2: one enabled non-default token. // be excluded). Account 2: one enabled non-default token.
sqlx::query( sqlx::query(
@@ -308,7 +329,10 @@ mod tests {
(11, 1, 'backup', 1, 0), (11, 1, 'backup', 1, 0),
(12, 1, 'retired', 0, 0), (12, 1, 'retired', 0, 0),
(20, 2, 'only-one', 1, 0)", (20, 2, 'only-one', 1, 0)",
).execute(&pool).await.unwrap(); )
.execute(&pool)
.await
.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok"); let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value["models"].as_array().unwrap(); let models = value["models"].as_array().unwrap();
@@ -316,7 +340,10 @@ mod tests {
let m = &models[0]; let m = &models[0];
assert_eq!(m["name"], "gpt-x"); assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 2); assert_eq!(m["accountCount"], 2);
assert_eq!(m["tokenCount"], 3, "2 from account 1 + 1 from account 2; disabled token excluded"); assert_eq!(
m["tokenCount"], 3,
"2 from account 1 + 1 from account 2; disabled token excluded"
);
// Stage 1 placeholders. // Stage 1 placeholders.
assert!(m["avgLatency"].is_null()); assert!(m["avgLatency"].is_null());
assert!(m["successRate"].is_null()); assert!(m["successRate"].is_null());
@@ -330,12 +357,19 @@ mod tests {
// Find each account by id rather than relying on insertion order in // Find each account by id rather than relying on insertion order in
// the HashMap-driven account_ids vec. // the HashMap-driven account_ids vec.
let alice = accounts.iter().find(|a| a["id"] == 1).expect("alice present"); let alice = accounts
.iter()
.find(|a| a["id"] == 1)
.expect("alice present");
assert_eq!(alice["site"], "openai-mirror"); assert_eq!(alice["site"], "openai-mirror");
assert_eq!(alice["username"], "alice"); assert_eq!(alice["username"], "alice");
assert_eq!(alice["balance"], 100.5); assert_eq!(alice["balance"], 100.5);
let alice_tokens = alice["tokens"].as_array().unwrap(); let alice_tokens = alice["tokens"].as_array().unwrap();
assert_eq!(alice_tokens.len(), 2, "disabled 'retired' token must not be exposed"); assert_eq!(
alice_tokens.len(),
2,
"disabled 'retired' token must not be exposed"
);
let primary = alice_tokens.iter().find(|t| t["id"] == 10).unwrap(); let primary = alice_tokens.iter().find(|t| t["id"] == 10).unwrap();
assert_eq!(primary["name"], "primary"); assert_eq!(primary["name"], "primary");
assert_eq!(primary["isDefault"], true); assert_eq!(primary["isDefault"], true);
@@ -356,16 +390,23 @@ mod tests {
let pool = fixture_pool().await; let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 's')") sqlx::query("INSERT INTO sites (id, name) VALUES (1, 's')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, status) VALUES (1, 1, 'active')") sqlx::query("INSERT INTO accounts (id, site_id, status) VALUES (1, 1, 'active')")
.execute(&pool).await.unwrap(); .execute(&pool)
.await
.unwrap();
// Insert in scrambled order; expect output sorted lexicographically. // Insert in scrambled order; expect output sorted lexicographically.
sqlx::query( sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES "INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-z', 1), (1, 'gpt-z', 1),
(1, 'gpt-a', 1), (1, 'gpt-a', 1),
(1, 'gpt-m', 1)", (1, 'gpt-m', 1)",
).execute(&pool).await.unwrap(); )
.execute(&pool)
.await
.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok"); let value = marketplace_inner(&pool).await.expect("inner ok");
let names: Vec<&str> = value["models"] let names: Vec<&str> = value["models"]
+10 -7
View File
@@ -369,8 +369,7 @@ pub async fn list_site_disabled_models(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
// Validate the site exists so we return a clean 404 instead of an empty list // Validate the site exists so we return a clean 404 instead of an empty list
// for unknown ids. // for unknown ids.
let exists: Option<i64> = let exists: Option<i64> = sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id) .bind(id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await?; .await?;
@@ -378,8 +377,9 @@ pub async fn list_site_disabled_models(
return Err(AppError::NotFound("site not found".into())); return Err(AppError::NotFound("site not found".into()));
} }
let models: Vec<String> = let models: Vec<String> = sqlx::query_scalar(
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ? ORDER BY model_name") "SELECT model_name FROM site_disabled_models WHERE site_id = ? ORDER BY model_name",
)
.bind(id) .bind(id)
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
@@ -393,8 +393,7 @@ pub async fn update_site_disabled_models(
Path(id): Path<i64>, Path(id): Path<i64>,
Json(payload): Json<UpdateDisabledModelsRequest>, Json(payload): Json<UpdateDisabledModelsRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let exists: Option<i64> = let exists: Option<i64> = sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id) .bind(id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await?; .await?;
@@ -428,7 +427,11 @@ pub async fn update_site_disabled_models(
} }
tx.commit().await?; tx.commit().await?;
info!(site_id = id, count = normalised.len(), "updated site disabled models"); info!(
site_id = id,
count = normalised.len(),
"updated site disabled models"
);
Ok(Json(json!({ "models": normalised }))) Ok(Json(json!({ "models": normalised })))
} }
+6 -5
View File
@@ -127,10 +127,7 @@ fn comfy_session_cookie(token: &str) -> String {
// ── HTTP forwarder ───────────────────────────────────────────────────── // ── HTTP forwarder ─────────────────────────────────────────────────────
async fn http_handler( async fn http_handler(State(state): State<AppState>, req: Request<Body>) -> Response<Body> {
State(state): State<AppState>,
req: Request<Body>,
) -> Response<Body> {
let query_token = token_from_query(req.uri().query()); let query_token = token_from_query(req.uri().query());
let user_id = user_id_from_headers(&state, req.headers()) let user_id = user_id_from_headers(&state, req.headers())
.or_else(|| user_id_from_cookie(&state, req.headers())) .or_else(|| user_id_from_cookie(&state, req.headers()))
@@ -159,7 +156,11 @@ async fn http_handler(
let path = req.uri().path(); let path = req.uri().path();
let upstream_path = path.strip_prefix("/comfy").unwrap_or(path); let upstream_path = path.strip_prefix("/comfy").unwrap_or(path);
let upstream_path = if upstream_path.is_empty() { "/" } else { upstream_path }; let upstream_path = if upstream_path.is_empty() {
"/"
} else {
upstream_path
};
let query = query_without_token(req.uri().query()); let query = query_without_token(req.uri().query());
let port = match state.comfyui_pool.entry(user_id).await { let port = match state.comfyui_pool.entry(user_id).await {
+4 -1
View File
@@ -618,7 +618,10 @@ fn agent_message_to_upstream(message: AgentMessage) -> Value {
.map(str::trim) .map(str::trim)
.filter(|value| value.starts_with("data:image/")) .filter(|value| value.starts_with("data:image/"))
{ {
if !content_parts.iter().any(|part: &Value| part["type"] == "text") { if !content_parts
.iter()
.any(|part: &Value| part["type"] == "text")
{
content_parts.push(json!({ content_parts.push(json!({
"type": "text", "type": "text",
"text": text, "text": text,
+2
View File
@@ -22,6 +22,7 @@ mod health;
mod monitor; mod monitor;
mod oauth; mod oauth;
mod organizations; mod organizations;
mod picture_agent;
mod proxy; mod proxy;
mod search; mod search;
mod site_announcements; mod site_announcements;
@@ -47,6 +48,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/healthz", get(health::health_check)) .route("/healthz", get(health::health_check))
.merge(admin::routes(state.clone())) .merge(admin::routes(state.clone()))
.merge(proxy::routes()) .merge(proxy::routes())
.merge(picture_agent::routes())
.merge(comfyui_agent::routes()) .merge(comfyui_agent::routes())
.merge(comfyui::routes()); .merge(comfyui::routes());
+417
View File
@@ -0,0 +1,417 @@
use crate::{
auth::jwt::validate_jwt,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
body::Body,
extract::{Multipart, Query, State},
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value};
use std::time::Duration;
const TEMPLATE_GALLERY_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
#[derive(Debug, Clone)]
struct PictureAgentAuth {
user_id: Option<i64>,
role: String,
outbound_key: String,
key_name: String,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/picture-agent/status", get(status_handler))
.route("/api/picture-agent/models", get(models_handler))
.route("/api/picture-agent/templates", get(templates_handler))
.route(
"/api/picture-agent/images/generations",
post(images_generations),
)
.route("/api/picture-agent/images/edits", post(images_edits))
}
#[derive(Debug, Deserialize)]
struct TemplateQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct GalleryItem {
post_number: Option<i64>,
username: Option<String>,
prompt: Option<String>,
images: Option<Vec<String>>,
image_host_url: Option<String>,
}
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> {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.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_picture_agent_auth(
headers: &HeaderMap,
state: &AppState,
) -> Result<PictureAgentAuth, 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(PictureAgentAuth {
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(PictureAgentAuth {
user_id: Some(user_id),
role: claims.role,
outbound_key: key.key,
key_name: key.name,
}),
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
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)),
}
}
fn is_exact_model_pattern(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& !normalized.to_ascii_lowercase().starts_with("re:")
&& !normalized.contains('*')
&& !normalized.contains('?')
}
async fn status_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
Ok(Json(json!({
"success": true,
"mode": "local",
"role": auth.role,
"userId": auth.user_id,
"keyName": auth.key_name,
"upstreamLabel": "BoosAPI 本地图片路由",
})))
}
async fn models_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let rows: Vec<String> = sqlx::query_scalar(
"SELECT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await?;
let mut seen = std::collections::HashSet::new();
let models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_model_pattern(value))
.filter(|value| seen.insert(value.clone()))
.collect();
Ok(Json(json!({
"success": true,
"models": models,
"defaultModel": models
.iter()
.find(|model| model.to_ascii_lowercase().contains("image"))
.or_else(|| models.first())
.cloned()
.unwrap_or_else(|| "gpt-image-2".to_string()),
})))
}
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
let clean = prompt
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
let mut title = clean.chars().take(18).collect::<String>();
if title.trim().is_empty() {
title = post_number
.map(|value| format!("#{value}"))
.unwrap_or_else(|| "参考模板".to_string());
}
if clean.chars().count() > 18 {
title.push('…');
}
title
}
fn infer_template_tag(prompt: &str) -> &'static str {
let lower = prompt.to_ascii_lowercase();
if prompt.contains("海报") || lower.contains("poster") {
"海报"
} else if prompt.contains("漫画") || lower.contains("comic") {
"漫画"
} else if prompt.contains("图鉴") || prompt.contains("信息图") || lower.contains("infographic")
{
"图鉴"
} else if prompt.contains("贴纸") || lower.contains("sticker") || lower.contains("mascot") {
"贴纸"
} else if prompt.contains("分镜") || lower.contains("storyboard") {
"分镜"
} else {
"参考"
}
}
fn infer_template_size(prompt: &str) -> &'static str {
let lower = prompt.to_ascii_lowercase();
if lower.contains("16:9") || prompt.contains("横版") {
"1536x1024"
} else if lower.contains("9:16") || lower.contains("--ar 9:16") || prompt.contains("竖版") {
"1024x1536"
} else if lower.contains("1:1") || prompt.contains("方图") {
"1024x1024"
} else {
"auto"
}
}
async fn templates_handler(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<TemplateQuery>,
) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(48).clamp(1, 96);
let items = proxy_client()
.get(TEMPLATE_GALLERY_URL)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {err}")))?
.error_for_status()
.map_err(|err| AppError::UpstreamError(format!("template gallery returned {err}")))?
.json::<Vec<GalleryItem>>()
.await
.map_err(|err| AppError::UpstreamError(format!("failed to parse templates: {err}")))?;
let templates: Vec<Value> = items
.into_iter()
.filter_map(|item| {
let prompt = item.prompt?.trim().to_string();
if prompt.chars().count() < 20 {
return None;
}
let image = item
.images
.unwrap_or_default()
.into_iter()
.find(|value| value.starts_with("http"))
.or_else(|| {
item.image_host_url
.filter(|value| value.starts_with("http"))
})?;
let post_number = item.post_number;
Some(json!({
"id": post_number
.map(|value| format!("linux-do-{value}"))
.unwrap_or_else(|| format!("linux-do-{}", prompt_title(&prompt, None))),
"title": prompt_title(&prompt, post_number),
"tag": infer_template_tag(&prompt),
"size": infer_template_size(&prompt),
"image": image,
"prompt": prompt,
"source": {
"name": "Linux Do GPT Image Gallery",
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"postNumber": post_number,
"username": item.username,
},
}))
})
.take(limit)
.collect();
Ok(Json(json!({
"success": true,
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"templates": templates,
})))
}
fn proxy_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(180))
.build()
.unwrap_or_else(|_| Client::new())
}
async fn upstream_response_to_axum(response: reqwest::Response) -> AppResult<Response<Body>> {
let status = StatusCode::from_u16(response.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.unwrap_or_else(|| "application/json; charset=utf-8".to_string());
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
Ok(Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.body(Body::from(bytes))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
}
async fn images_generations(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
let url = format!("{}/images/generations", local_v1_base(&state));
let response = proxy_client()
.post(url)
.bearer_auth(auth.outbound_key)
.json(&body)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
upstream_response_to_axum(response).await
}
async fn images_edits(
State(state): State<AppState>,
headers: HeaderMap,
mut multipart: Multipart,
) -> AppResult<Response<Body>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
let mut form = reqwest::multipart::Form::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::BadRequest(format!("invalid multipart body: {err}")))?
{
let name = field
.name()
.ok_or_else(|| AppError::BadRequest("multipart field missing name".into()))?
.to_string();
let filename = field.file_name().map(str::to_string);
let content_type = field.content_type().map(str::to_string);
let is_file = filename.is_some()
|| content_type
.as_deref()
.map(|ct| !ct.starts_with("text/") && ct != "application/x-www-form-urlencoded")
.unwrap_or(false);
if is_file {
let bytes = field
.bytes()
.await
.map_err(|err| AppError::BadRequest(format!("failed to read upload: {err}")))?;
let mut part = reqwest::multipart::Part::bytes(bytes.to_vec());
if let Some(filename) = filename {
part = part.file_name(filename);
}
if let Some(content_type) = content_type {
part = part
.mime_str(&content_type)
.map_err(|err| AppError::BadRequest(format!("invalid content type: {err}")))?;
}
form = form.part(name, part);
} else {
let text = field
.text()
.await
.map_err(|err| AppError::BadRequest(format!("failed to read field: {err}")))?;
form = form.text(name, text);
}
}
let url = format!("{}/images/edits", local_v1_base(&state));
let response = proxy_client()
.post(url)
.bearer_auth(auth.outbound_key)
.multipart(form)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
upstream_response_to_axum(response).await
}
+107 -18
View File
@@ -17,7 +17,7 @@ use crate::{
AppState, AppState,
}; };
use axum::{ use axum::{
extract::{Multipart, State}, extract::{Multipart, Query, State},
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Response, Sse}, response::{IntoResponse, Response, Sse},
routing::{get, post}, routing::{get, post},
@@ -60,6 +60,7 @@ pub fn routes() -> Router<AppState> {
.route("/v1/images/generations", post(images_generations)) .route("/v1/images/generations", post(images_generations))
.route("/v1/images/edits", post(images_edits)) .route("/v1/images/edits", post(images_edits))
.route("/v1/videos", post(videos_handler)) .route("/v1/videos", post(videos_handler))
.route("/v1/videos/{id}", get(video_task_handler))
.route("/v1/models", get(list_models)) .route("/v1/models", get(list_models))
// ── ComfyUI shim aliases ────────────────────────────────────── // ── ComfyUI shim aliases ──────────────────────────────────────
// ComfyUI's OpenAI nodes hit /proxy/openai/... paths (see // ComfyUI's OpenAI nodes hit /proxy/openai/... paths (see
@@ -115,8 +116,7 @@ async fn responses_handler(
.unwrap_or(false); .unwrap_or(false);
if stream { if stream {
let chunks = let chunks = execute_responses_stream_with_conductor(state, key_id, model, body).await?;
execute_responses_stream_with_conductor(state, key_id, model, body).await?;
let sse = let sse =
Sse::new(stream::iter(chunks).map(|chunk| { Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk)) Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
@@ -276,8 +276,7 @@ async fn audio_translations(
let route = resolve_route(&state, &model).await?; let route = resolve_route(&state, &model).await?;
let result = let result =
executors::execute_proxy_request(state.clone(), route, "/audio/translations", body) executors::execute_proxy_request(state.clone(), route, "/audio/translations", body).await?;
.await?;
record_billing(&state, key_id, &model, 0, 0, 0.0).await; record_billing(&state, key_id, &model, 0, 0, 0.0).await;
Ok(Json(result)) Ok(Json(result))
} }
@@ -299,7 +298,33 @@ async fn videos_handler(
.to_string(); .to_string();
let route = resolve_route(&state, &model).await?; let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state.clone(), route, "/videos", body).await?; let result = executors::execute_video_generation(state.clone(), route, body).await?;
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
Ok(Json(result))
}
/// GET /v1/videos/{id} — inspect video generation task.
async fn video_task_handler(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(id): axum::extract::Path<String>,
Query(query): Query<std::collections::HashMap<String, String>>,
) -> AppResult<Json<Value>> {
info!("GET /v1/videos/{id}");
let key_id = check_auth(&state, &headers).await?;
let model = query
.get("model")
.map(String::as_str)
.unwrap_or("default")
.to_string();
let route = resolve_route(&state, &model).await?;
let suffix = if route.base_url.contains("ark.cn-beijing.volces.com") {
format!("/contents/generations/tasks/{id}")
} else {
format!("/videos/{id}")
};
let result = executors::execute_proxy_get(route, &suffix).await?;
record_billing(&state, key_id, &model, 0, 0, 0.0).await; record_billing(&state, key_id, &model, 0, 0, 0.0).await;
Ok(Json(result)) Ok(Json(result))
} }
@@ -426,7 +451,22 @@ async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppRe
let _ = check_auth(&state, &headers).await?; let _ = check_auth(&state, &headers).await?;
let rows: Vec<(String, String)> = sqlx::query_as( let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT tr.model_pattern, COALESCE(tr.model_mapping, tr.model_pattern) FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.id", "SELECT DISTINCT
tr.model_pattern,
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
FROM token_routes tr
JOIN route_channels rc ON rc.route_id = tr.id
JOIN accounts a ON a.id = rc.account_id
JOIN sites s ON s.id = a.site_id
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
WHERE tr.enabled = 1
AND rc.enabled = 1
AND a.status = 'active'
AND s.status = 'active'
AND TRIM(COALESCE(s.url, '')) <> ''
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
ORDER BY tr.id",
) )
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
@@ -671,6 +711,17 @@ fn build_conductor(state: &AppState) -> ProxyConductor {
} }
} }
fn upstream_status_from_error(err: &AppError) -> Option<u16> {
let message = err.to_string();
let marker = "Upstream returned ";
let start = message.find(marker)? + marker.len();
message
.get(start..)?
.split_whitespace()
.next()
.and_then(|value| value.parse::<u16>().ok())
}
async fn execute_chat_completions_with_conductor( async fn execute_chat_completions_with_conductor(
state: AppState, state: AppState,
key_id: Option<i64>, key_id: Option<i64>,
@@ -703,7 +754,7 @@ async fn execute_chat_completions_with_conductor(
}, },
Err(err) => AttemptResult::Failure { Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover, action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None, status: upstream_status_from_error(&err),
raw_error_text: Some(err.to_string()), raw_error_text: Some(err.to_string()),
}, },
} }
@@ -734,7 +785,15 @@ async fn execute_chat_completions_with_conductor(
false, false,
) )
.await; .await;
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await; record_billing(
&billing_state,
key_id,
&billing_model,
prompt,
completion,
0.0,
)
.await;
Ok(response) Ok(response)
} }
ExecuteResult::Failure { ExecuteResult::Failure {
@@ -797,7 +856,7 @@ async fn execute_chat_completions_stream_with_conductor(
}, },
Err(err) => AttemptResult::Failure { Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover, action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None, status: upstream_status_from_error(&err),
raw_error_text: Some(err.to_string()), raw_error_text: Some(err.to_string()),
}, },
} }
@@ -814,14 +873,20 @@ async fn execute_chat_completions_stream_with_conductor(
response, response,
attempts, attempts,
} => match response { } => match response {
Value::Array(chunks) => (selected, attempts, chunks Value::Array(chunks) => (
selected,
attempts,
chunks
.into_iter() .into_iter()
.map(|chunk| { .map(|chunk| {
chunk.as_str().map(str::to_string).ok_or_else(|| { chunk.as_str().map(str::to_string).ok_or_else(|| {
AppError::UpstreamError("Invalid streaming response from conductor".into()) AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
)
}) })
}) })
.collect::<AppResult<Vec<String>>>()?), .collect::<AppResult<Vec<String>>>()?,
),
_ => { _ => {
return Err(AppError::UpstreamError( return Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(), "Invalid streaming response from conductor".into(),
@@ -888,7 +953,15 @@ async fn execute_chat_completions_stream_with_conductor(
true, true,
) )
.await; .await;
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await; record_billing(
&billing_state,
key_id,
&billing_model,
prompt,
completion,
0.0,
)
.await;
Ok(chunks) Ok(chunks)
} }
@@ -941,7 +1014,7 @@ async fn execute_responses_with_conductor(
}, },
Err(err) => AttemptResult::Failure { Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover, action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None, status: upstream_status_from_error(&err),
raw_error_text: Some(err.to_string()), raw_error_text: Some(err.to_string()),
}, },
} }
@@ -954,7 +1027,15 @@ async fn execute_responses_with_conductor(
let response = conductor_result_to_app_result(result)?; let response = conductor_result_to_app_result(result)?;
let (prompt, completion) = extract_usage(&response); let (prompt, completion) = extract_usage(&response);
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await; record_billing(
&billing_state,
key_id,
&billing_model,
prompt,
completion,
0.0,
)
.await;
Ok(response) Ok(response)
} }
@@ -988,7 +1069,7 @@ async fn execute_responses_stream_with_conductor(
}, },
Err(err) => AttemptResult::Failure { Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover, action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None, status: upstream_status_from_error(&err),
raw_error_text: Some(err.to_string()), raw_error_text: Some(err.to_string()),
}, },
} }
@@ -1037,7 +1118,15 @@ async fn execute_responses_stream_with_conductor(
} }
}) })
.unwrap_or((0, 0)); .unwrap_or((0, 0));
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await; record_billing(
&billing_state,
key_id,
&billing_model,
prompt,
completion,
0.0,
)
.await;
Ok(chunks) Ok(chunks)
} }
+4 -4
View File
@@ -67,8 +67,7 @@ async fn dashboard(
) )
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let total_balance: f64 = let total_balance: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0.0) FROM accounts")
sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0.0) FROM accounts")
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let total_used: f64 = let total_used: f64 =
@@ -93,8 +92,9 @@ async fn dashboard(
sqlx::query_scalar("SELECT COUNT(*) FROM accounts WHERE status = 'active'") sqlx::query_scalar("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let today_checkin_total: i64 = let today_checkin_total: i64 = sqlx::query_scalar(
sqlx::query_scalar("SELECT COUNT(*) FROM checkin_logs WHERE date(created_at) = date('now')") "SELECT COUNT(*) FROM checkin_logs WHERE date(created_at) = date('now')",
)
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let today_checkin_success: i64 = sqlx::query_scalar( let today_checkin_success: i64 = sqlx::query_scalar(
+16 -8
View File
@@ -120,9 +120,7 @@ fn resolve_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ")) .and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| { .ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
AppError::Unauthorized("Missing or invalid Authorization header".into())
})?;
if auth == state.config.auth.auth_token || auth == state.config.auth.proxy_token { if auth == state.config.auth.auth_token || auth == state.config.auth.proxy_token {
return Ok(ADMIN_WORKSPACE_USER_ID); return Ok(ADMIN_WORKSPACE_USER_ID);
@@ -142,9 +140,7 @@ fn resolve_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
/// Map a [`PoolError`] to the appropriate [`AppError`]. /// Map a [`PoolError`] to the appropriate [`AppError`].
fn pool_err_to_app(e: PoolError) -> AppError { fn pool_err_to_app(e: PoolError) -> AppError {
match e { match e {
PoolError::AtCapacity(_) => { PoolError::AtCapacity(_) => AppError::ServiceUnavailable("comfyui pool at capacity".into()),
AppError::ServiceUnavailable("comfyui pool at capacity".into())
}
PoolError::StartTimeout(_) => { PoolError::StartTimeout(_) => {
AppError::GatewayTimeout("comfyui container start timeout".into()) AppError::GatewayTimeout("comfyui container start timeout".into())
} }
@@ -211,7 +207,15 @@ async fn pool_status_json(state: &AppState, user_id: i64) -> Value {
.map(|p| format!("http://127.0.0.1:{}", p)) .map(|p| format!("http://127.0.0.1:{}", p))
.unwrap_or_default(); .unwrap_or_default();
let model_backend = format!("{}/v1", state.comfyui_pool.config().boosapi_base.trim_end_matches("/v1").trim_end_matches('/')); let model_backend = format!(
"{}/v1",
state
.comfyui_pool
.config()
.boosapi_base
.trim_end_matches("/v1")
.trim_end_matches('/')
);
json!({ json!({
"status": status, "status": status,
@@ -288,7 +292,11 @@ async fn restart_comfyui(
// pool uses so there's exactly one source of truth. // pool uses so there's exactly one source of truth.
fn boosapi_v1_base(state: &AppState) -> String { fn boosapi_v1_base(state: &AppState) -> String {
let raw = state.comfyui_pool.config().boosapi_base.trim_end_matches('/'); let raw = state
.comfyui_pool
.config()
.boosapi_base
.trim_end_matches('/');
if raw.ends_with("/v1") { if raw.ends_with("/v1") {
raw.to_string() raw.to_string()
} else { } else {
+25 -15
View File
@@ -230,12 +230,7 @@ impl ComfyUIPool {
let map = self.containers.read().await; let map = self.containers.read().await;
let live = map let live = map
.values() .values()
.filter(|e| { .filter(|e| matches!(e.state, ContainerState::Starting | ContainerState::Ready))
matches!(
e.state,
ContainerState::Starting | ContainerState::Ready
)
})
.count(); .count();
if live >= self.config.max_concurrent { if live >= self.config.max_concurrent {
return Err(PoolError::AtCapacity(live)); return Err(PoolError::AtCapacity(live));
@@ -292,11 +287,7 @@ impl ComfyUIPool {
} }
/// Wait for the container to answer `/system_stats`, then mark Ready. /// Wait for the container to answer `/system_stats`, then mark Ready.
async fn probe_until_ready( async fn probe_until_ready(&self, user_id: i64, entry: PoolEntry) -> PoolResult<PoolEntry> {
&self,
user_id: i64,
entry: PoolEntry,
) -> PoolResult<PoolEntry> {
let url = format!("http://127.0.0.1:{}/system_stats", entry.host_port); let url = format!("http://127.0.0.1:{}/system_stats", entry.host_port);
let deadline = Instant::now() + READINESS_TIMEOUT; let deadline = Instant::now() + READINESS_TIMEOUT;
@@ -464,7 +455,10 @@ impl ComfyUIPool {
"BOOSAPI_BUILTIN_KEY".into(), "BOOSAPI_BUILTIN_KEY".into(),
self.config.builtin_key.clone(), self.config.builtin_key.clone(),
); );
env.insert("BOOSAPI_BASE".into(), self.config.boosapi_base.clone()); env.insert(
"BOOSAPI_BASE".into(),
container_reachable_boosapi_base(&self.config.boosapi_base),
);
env.insert("BOOSAPI_KEY".into(), self.config.builtin_key.clone()); env.insert("BOOSAPI_KEY".into(), self.config.builtin_key.clone());
ContainerSpec { ContainerSpec {
@@ -488,6 +482,24 @@ impl ComfyUIPool {
} }
} }
fn container_reachable_boosapi_base(base: &str) -> String {
if let Ok(override_base) = std::env::var("BOOSAPI_COMFY_CONTAINER_BASE") {
let trimmed = override_base.trim();
if !trimmed.is_empty() {
return trimmed.trim_end_matches('/').to_string();
}
}
let trimmed = base.trim().trim_end_matches('/');
for local_host in ["127.0.0.1", "localhost"] {
let needle = format!("//{}:", local_host);
if let Some((prefix, rest)) = trimmed.split_once(&needle) {
return format!("{}//host.docker.internal:{}", prefix, rest);
}
}
trimmed.to_string()
}
/// Spawn the idle-reaper task. Lives for the life of the process. /// Spawn the idle-reaper task. Lives for the life of the process.
pub fn spawn_reaper(pool: Arc<ComfyUIPool>) { pub fn spawn_reaper(pool: Arc<ComfyUIPool>) {
tokio::spawn(async move { tokio::spawn(async move {
@@ -529,9 +541,7 @@ mod tests {
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use crate::services::container_runtime::{ use crate::services::container_runtime::{ContainerHandle, ContainerListing, RuntimeResult};
ContainerHandle, ContainerListing, RuntimeResult,
};
/// In-memory runtime that succeeds and pretends `/system_stats` is up. /// In-memory runtime that succeeds and pretends `/system_stats` is up.
/// Real readiness probe is tested separately via the live Docker setup. /// Real readiness probe is tested separately via the live Docker setup.
+13 -10
View File
@@ -11,8 +11,7 @@ use tokio::process::Command;
use tracing::{debug, warn}; use tracing::{debug, warn};
use super::container_runtime::{ use super::container_runtime::{
ContainerHandle, ContainerListing, ContainerRuntime, ContainerSpec, RuntimeError, ContainerHandle, ContainerListing, ContainerRuntime, ContainerSpec, RuntimeError, RuntimeResult,
RuntimeResult,
}; };
/// Internal port the ComfyUI process listens on inside the container. /// Internal port the ComfyUI process listens on inside the container.
@@ -74,6 +73,8 @@ impl ContainerRuntime for DockerRuntime {
"127.0.0.1:{}:{}", "127.0.0.1:{}:{}",
spec.host_port, CONTAINER_INTERNAL_PORT spec.host_port, CONTAINER_INTERNAL_PORT
)) ))
.arg("--add-host")
.arg("host.docker.internal:host-gateway")
// Resource limits (§design Decision 5) // Resource limits (§design Decision 5)
.arg(format!("--cpus={}", spec.limits.cpus)) .arg(format!("--cpus={}", spec.limits.cpus))
.arg(format!("--memory={}", spec.limits.memory_bytes)) .arg(format!("--memory={}", spec.limits.memory_bytes))
@@ -173,12 +174,7 @@ impl ContainerRuntime for DockerRuntime {
async fn is_alive(&self, name: &str) -> RuntimeResult<bool> { async fn is_alive(&self, name: &str) -> RuntimeResult<bool> {
let output = self let output = self
.cmd() .cmd()
.args([ .args(["inspect", "-f", "{{.State.Running}}", name])
"inspect",
"-f",
"{{.State.Running}}",
name,
])
.output() .output()
.await .await
.map_err(io_to_runtime)?; .map_err(io_to_runtime)?;
@@ -230,11 +226,18 @@ impl ContainerRuntime for DockerRuntime {
let host_port = match parse_host_port(ports) { let host_port = match parse_host_port(ports) {
Some(p) => p, Some(p) => p,
None => { None => {
warn!(name, ports, "could not parse host port from docker ps; skipping"); warn!(
name,
ports, "could not parse host port from docker ps; skipping"
);
continue; continue;
} }
}; };
out.push(ContainerListing { id, name, host_port }); out.push(ContainerListing {
id,
name,
host_port,
});
} }
Ok(out) Ok(out)
} }
+28 -10
View File
@@ -124,8 +124,7 @@ pub(crate) async fn rebuild_routes_inner(
sqlx::query_as("SELECT model_pattern, id FROM token_routes") sqlx::query_as("SELECT model_pattern, id FROM token_routes")
.fetch_all(&mut *tx) .fetch_all(&mut *tx)
.await?; .await?;
let mut route_id_by_pattern: HashMap<String, i64> = let mut route_id_by_pattern: HashMap<String, i64> = existing_routes.into_iter().collect();
existing_routes.into_iter().collect();
// 4. For each model, ensure a token_routes row, then reconcile channels. // 4. For each model, ensure a token_routes row, then reconcile channels.
for (model_name, account_ids) in &by_model { for (model_name, account_ids) in &by_model {
@@ -169,7 +168,10 @@ pub(crate) async fn rebuild_routes_inner(
let mut auto_by_key: HashMap<AutoKey, i64> = HashMap::new(); let mut auto_by_key: HashMap<AutoKey, i64> = HashMap::new();
let mut manual_keys: HashSet<AutoKey> = HashSet::new(); let mut manual_keys: HashSet<AutoKey> = HashSet::new();
for (ch_id, account_id, source_model, manual) in existing_channels { for (ch_id, account_id, source_model, manual) in existing_channels {
let key = (account_id, source_model.unwrap_or_else(|| model_name.clone())); let key = (
account_id,
source_model.unwrap_or_else(|| model_name.clone()),
);
if manual.unwrap_or(false) { if manual.unwrap_or(false) {
manual_keys.insert(key); manual_keys.insert(key);
summary.skipped_manual_channels += 1; summary.skipped_manual_channels += 1;
@@ -294,10 +296,24 @@ pub(crate) async fn rebuild_routes_inner(
// has no channels (channels will be re-inserted on next refresh). // has no channels (channels will be re-inserted on next refresh).
continue; continue;
} }
let touched_by_operator = display_name.as_deref().map(str::trim).unwrap_or("").is_empty() let touched_by_operator = display_name
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
== false == false
|| display_icon.as_deref().map(str::trim).unwrap_or("").is_empty() == false || display_icon
|| model_mapping.as_deref().map(str::trim).unwrap_or("").is_empty() == false; .as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
== false
|| model_mapping
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
== false;
if touched_by_operator { if touched_by_operator {
continue; continue;
} }
@@ -493,9 +509,8 @@ mod tests {
"route is pinned by a manual_override channel and must survive" "route is pinned by a manual_override channel and must survive"
); );
let manual_count: i64 = sqlx::query_scalar( let manual_count: i64 =
"SELECT COUNT(*) FROM route_channels WHERE manual_override = 1", sqlx::query_scalar("SELECT COUNT(*) FROM route_channels WHERE manual_override = 1")
)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
@@ -539,7 +554,10 @@ mod tests {
route_count, 0, route_count, 0,
"all phantom routes must be swept when no account advertises any model" "all phantom routes must be swept when no account advertises any model"
); );
assert_eq!(summary.deleted_routes, 3, "summary reports the three sweeps"); assert_eq!(
summary.deleted_routes, 3,
"summary reports the three sweeps"
);
assert_eq!(summary.created_routes, 0); assert_eq!(summary.created_routes, 0);
assert_eq!(summary.created_channels, 0); assert_eq!(summary.created_channels, 0);
} }
+12
View File
@@ -120,6 +120,16 @@ const presets = [
recommendedSkipModelFetch: true, recommendedSkipModelFetch: true,
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'], recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
}, },
{
id: 'volcengine-ark-openai',
label: '火山方舟 / 字节豆包 (OpenAI)',
platform: 'openai',
defaultUrl: 'https://ark.cn-beijing.volces.com/api/v3',
initialSegment: 'apikey',
description: '火山方舟通用 OpenAI 兼容入口;模型名通常填写方舟推理接入点 ID。',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
]; ];
const urlMatchers = [ const urlMatchers = [
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' }, { urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
@@ -134,11 +144,13 @@ const urlMatchers = [
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' }, { urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' }, { urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' }, { urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/v3', id: 'volcengine-ark-openai', platform: 'openai', stripTrailingSlash: true },
]; ];
// Canonical root → preset mapping for reselection when platform is known // Canonical root → preset mapping for reselection when platform is known
const rootReselect = [ const rootReselect = [
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' }, { rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' }, { rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
{ rootPrefix: 'https://ark.cn-beijing.volces.com', id: 'volcengine-ark-openai' },
]; ];
export function getSiteInitializationPreset(id) { export function getSiteInitializationPreset(id) {
if (!id) if (!id)
+12
View File
@@ -131,6 +131,16 @@ const presets: SiteInitializationPreset[] = [
recommendedSkipModelFetch: true, recommendedSkipModelFetch: true,
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'], recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
}, },
{
id: 'volcengine-ark-openai',
label: '火山方舟 / 字节豆包 (OpenAI)',
platform: 'openai',
defaultUrl: 'https://ark.cn-beijing.volces.com/api/v3',
initialSegment: 'apikey',
description: '火山方舟通用 OpenAI 兼容入口;模型名通常填写方舟推理接入点 ID。',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
]; ];
const urlMatchers: Array<{ const urlMatchers: Array<{
@@ -151,12 +161,14 @@ const urlMatchers: Array<{
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' }, { urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' }, { urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' }, { urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/v3', id: 'volcengine-ark-openai', platform: 'openai', stripTrailingSlash: true },
]; ];
// Canonical root → preset mapping for reselection when platform is known // Canonical root → preset mapping for reselection when platform is known
const rootReselect: Array<{ rootPrefix: string; id: string }> = [ const rootReselect: Array<{ rootPrefix: string; id: string }> = [
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' }, { rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' }, { rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
{ rootPrefix: 'https://ark.cn-beijing.volces.com', id: 'volcengine-ark-openai' },
]; ];
export function getSiteInitializationPreset(id?: string | null): SiteInitializationPreset | undefined { export function getSiteInitializationPreset(id?: string | null): SiteInitializationPreset | undefined {
+7 -1
View File
@@ -37,6 +37,7 @@ const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js')); const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUI = lazy(() => import('./pages/ComfyUI.js')); const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js')); const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const Monitors = lazy(() => import('./pages/Monitors.js')); const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js')); const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
@@ -252,6 +253,7 @@ export const sidebarGroups = [
{ to: '/checkin', 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/checkin', 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> }, { to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> }, { to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/video-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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> }, { to: '/video-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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
], ],
@@ -276,8 +278,9 @@ export const userSidebarGroups = [
], ],
}, },
{ {
label: '视频应用', label: '创作应用',
items: [ items: [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/video-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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> }, { to: '/user/video-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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
], ],
@@ -286,6 +289,7 @@ export const userSidebarGroups = [
const topNavItems = [ const topNavItems = [
{ label: '控制台', to: '/' }, { label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' }, { label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' },
{ label: '视频工作台', to: '/video-agent' }, { label: '视频工作台', to: '/video-agent' },
{ label: '视频助手', to: '/comfyui-agent' }, { label: '视频助手', to: '/comfyui-agent' },
{ label: '关于', to: '/about' }, { label: '关于', to: '/about' },
@@ -757,6 +761,7 @@ function AppShell() {
<Route path="/settings/import-export" element={<ImportExport />} /> <Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} /> <Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} /> <Route path="/models" element={<Models />} />
<Route path="/picture-agent" element={<PictureAgent />} />
<Route path="/video-agent" element={<ComfyUI />} /> <Route path="/video-agent" element={<ComfyUI />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
@@ -770,6 +775,7 @@ function AppShell() {
<Route path="/user/dashboard/keys" element={<UserKeys />} /> <Route path="/user/dashboard/keys" element={<UserKeys />} />
<Route path="/user/dashboard/profile" element={<UserProfile />} /> <Route path="/user/dashboard/profile" element={<UserProfile />} />
<Route path="/user/usage" element={<UsageRecords />} /> <Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/picture-agent" element={<PictureAgent />} />
<Route path="/user/video-agent" element={<ComfyUI />} /> <Route path="/user/video-agent" element={<ComfyUI />} />
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
+18 -2
View File
@@ -73,6 +73,12 @@ function parseContentDispositionFilename(
return bareMatch?.[1]?.trim() || null; return bareMatch?.[1]?.trim() || null;
} }
function shouldSetJsonContentType(body: BodyInit | null | undefined): boolean {
if (!body) return false;
if (typeof FormData !== "undefined" && body instanceof FormData) return false;
return true;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string { function arrayBufferToBase64(buffer: ArrayBuffer): string {
if (nodeBuffer) { if (nodeBuffer) {
return nodeBuffer.from(buffer).toString("base64"); return nodeBuffer.from(buffer).toString("base64");
@@ -116,7 +122,7 @@ async function fetchAuthenticatedResponse(
const token = requireAuthToken(); const token = requireAuthToken();
const headers = new Headers(fetchOptions.headers ?? {}); const headers = new Headers(fetchOptions.headers ?? {});
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
if (fetchOptions.body && !headers.has("Content-Type")) { if (shouldSetJsonContentType(fetchOptions.body) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
@@ -189,7 +195,7 @@ async function fetchUserResponse(
} }
const headers = new Headers(fetchOptions.headers ?? {}); const headers = new Headers(fetchOptions.headers ?? {});
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
if (fetchOptions.body && !headers.has("Content-Type")) { if (shouldSetJsonContentType(fetchOptions.body) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
@@ -221,6 +227,16 @@ async function fetchUserResponse(
} }
} }
export async function fetchSessionResponse(
audience: "admin" | "user",
url: string,
options: RequestOptions = {},
): Promise<Response> {
return audience === "user"
? fetchUserResponse(url, options)
: fetchAuthenticatedResponse(url, options);
}
async function userRequest<T = any>( async function userRequest<T = any>(
url: string, url: string,
options: RequestOptions = {}, options: RequestOptions = {},
+562
View File
@@ -2846,6 +2846,568 @@ textarea {
justify-content: flex-end; justify-content: flex-end;
} }
.picture-agent-page {
max-width: 1440px;
margin: 0 auto;
}
.picture-agent-status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--color-success) 26%, var(--color-border));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg-card));
color: var(--color-success);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.picture-agent-shell {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
min-height: calc(100vh - var(--topbar-height) - 120px);
}
.picture-agent-panel,
.picture-agent-board {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
}
.picture-agent-panel {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.picture-agent-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding-bottom: 2px;
}
.picture-agent-panel-head div {
display: grid;
gap: 4px;
min-width: 0;
}
.picture-agent-panel-head strong {
font-size: 14px;
color: var(--color-text-primary);
}
.picture-agent-panel-head span {
font-size: 12px;
line-height: 1.45;
color: var(--color-text-muted);
}
.picture-agent-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 4px;
background: color-mix(in srgb, var(--color-bg) 74%, var(--color-bg-card));
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
}
.picture-agent-tabs button {
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.picture-agent-tabs button.active {
background: var(--color-bg-card);
color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.picture-agent-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.picture-agent-field span {
font-size: 12px;
color: var(--color-text-muted);
}
.picture-agent-field textarea,
.picture-agent-field select,
.picture-agent-field input,
.picture-agent-select {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
font: inherit;
font-size: 13px;
outline: none;
}
.picture-agent-field textarea {
resize: vertical;
min-height: 150px;
padding: 10px 12px;
line-height: 1.55;
}
.picture-agent-field textarea:focus,
.picture-agent-field select:focus,
.picture-agent-field input:focus,
.picture-agent-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 14%, transparent);
}
.picture-agent-field select,
.picture-agent-field input,
.picture-agent-select {
height: 38px;
padding: 0 10px;
}
.picture-agent-field input:disabled {
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-bg) 70%, var(--color-bg-card));
}
.picture-agent-select {
min-width: min(360px, 70vw);
background: var(--color-bg);
}
.picture-agent-grid-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.picture-agent-size-row {
display: grid;
grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);
gap: 10px;
align-items: end;
}
.picture-agent-size-preview {
min-height: 38px;
display: grid;
align-content: center;
gap: 2px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
min-width: 0;
}
.picture-agent-size-preview span {
color: var(--color-text-muted);
font-size: 11px;
}
.picture-agent-size-preview strong {
color: var(--color-info);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.picture-agent-presets button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.picture-agent-presets button:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
color: var(--color-primary);
}
.picture-agent-upload {
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
padding: 12px;
display: grid;
gap: 10px;
background: color-mix(in srgb, var(--color-bg) 52%, var(--color-bg-card));
}
.picture-agent-upload-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.picture-agent-upload-bar span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-muted);
font-size: 12px;
}
.picture-agent-upload img,
.picture-agent-upload-empty {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 6px;
border: 1px solid var(--color-border-light);
}
.picture-agent-upload img {
object-fit: contain;
background: var(--color-bg-card);
}
.picture-agent-upload-empty {
display: grid;
place-items: center;
color: var(--color-text-muted);
font-size: 12px;
background: var(--color-bg-card);
cursor: pointer;
font: inherit;
}
.picture-agent-submit {
min-height: 40px;
width: 100%;
margin-top: auto;
}
.picture-agent-canvas {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
min-width: 0;
}
.picture-agent-template-strip {
display: grid;
gap: 8px;
min-width: 0;
}
.picture-agent-template-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.picture-agent-template-title strong {
font-size: 13px;
}
.picture-agent-template-title span {
color: var(--color-text-muted);
font-size: 12px;
}
.picture-agent-template-list {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
.picture-agent-template-empty {
width: 100%;
min-height: 116px;
display: grid;
place-items: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 50%, var(--color-bg-card));
color: var(--color-text-muted);
font-size: 12px;
text-align: center;
padding: 12px;
}
.picture-agent-template-card {
width: 156px;
min-height: 168px;
flex: 0 0 auto;
display: grid;
grid-template-rows: 112px auto;
gap: 8px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: var(--shadow-card);
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
.picture-agent-template-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
}
.picture-agent-template-card img {
width: 100%;
height: 112px;
display: block;
object-fit: cover;
border-radius: 6px;
background: color-mix(in srgb, var(--color-bg) 70%, var(--color-bg-card));
}
.picture-agent-template-card span {
display: grid;
gap: 3px;
min-width: 0;
}
.picture-agent-template-card strong,
.picture-agent-template-card em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-template-card strong {
font-size: 13px;
}
.picture-agent-template-card em {
color: var(--color-text-muted);
font-size: 12px;
font-style: normal;
}
.picture-agent-board {
min-height: 520px;
padding: 16px;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
background-image:
linear-gradient(color-mix(in srgb, var(--color-border) 32%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--color-border) 32%, transparent) 1px, transparent 1px);
background-size: 28px 28px;
}
.picture-agent-image-frame {
min-height: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.picture-agent-image-frame img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
background: var(--color-bg-card);
}
.picture-agent-board-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 8px;
border-top: 1px solid var(--color-border-light);
}
.picture-agent-board-meta div {
display: grid;
gap: 2px;
min-width: 0;
}
.picture-agent-board-meta strong {
font-size: 13px;
}
.picture-agent-board-meta span {
color: var(--color-text-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-empty {
min-height: 420px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
text-align: center;
}
.picture-agent-history {
min-height: 88px;
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
.picture-agent-history-wrap {
display: grid;
gap: 8px;
}
.picture-agent-history-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.picture-agent-history-title strong {
font-size: 13px;
}
.picture-agent-history-title span,
.picture-agent-history-empty {
color: var(--color-text-muted);
font-size: 12px;
}
.picture-agent-history-empty {
width: 100%;
min-height: 88px;
display: grid;
place-items: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 50%, var(--color-bg-card));
text-align: center;
padding: 12px;
}
.picture-agent-history button {
width: 88px;
height: 88px;
flex: 0 0 auto;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
cursor: pointer;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.picture-agent-history button.active {
border-color: var(--color-primary);
}
.picture-agent-history img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
@media (max-width: 900px) {
.picture-agent-shell {
grid-template-columns: 1fr;
}
.picture-agent-board {
min-height: 420px;
}
.picture-agent-panel {
order: 2;
}
.picture-agent-canvas {
order: 1;
}
}
@media (max-width: 640px) {
.picture-agent-select {
min-width: 100%;
}
.picture-agent-template-title {
align-items: flex-start;
flex-direction: column;
}
.picture-agent-template-card {
width: 132px;
min-height: 150px;
grid-template-rows: 92px auto;
}
.picture-agent-template-card img {
height: 92px;
}
.picture-agent-grid-fields {
grid-template-columns: 1fr;
}
.picture-agent-size-row {
grid-template-columns: 1fr 1fr;
}
.picture-agent-size-preview {
grid-column: 1 / -1;
}
.picture-agent-board {
min-height: 340px;
padding: 10px;
}
.picture-agent-empty {
min-height: 300px;
}
}
.oauth-toolbar-card { .oauth-toolbar-card {
padding: 16px; padding: 16px;
margin-bottom: 16px; margin-bottom: 16px;
+92 -23
View File
@@ -9,29 +9,40 @@ import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
const VERSION = '1.3.0'; const VERSION = '1.3.0';
const FEATURES = [ const FEATURES = [
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' }, { icon: 'GW', title: '统一模型网关', desc: '一个入口聚合 OpenAI / Claude / Gemini / 方舟等兼容上游,统一鉴权、计费与日志。' },
{ icon: '🧠', title: '智能路由引擎', desc: '按成本、延迟、成功率自动选择最优通道,故障自动转移' }, { icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
{ icon: '📡', title: '多站点聚合', desc: '集中管理 New API / One API / OneHub / DoneHub / Veloera 等' }, { icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
{ icon: '🔍', title: '自动模型发现', desc: '上游新增模型自动出现在模型列表,零配置路由生成' }, { icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
{ icon: '🏪', title: '模型广场', desc: '跨站模型覆盖、定价对比、延迟与成功率实测数据' }, { icon: 'CU', title: 'ComfyUI 工作台', desc: '内置多租户 ComfyUI 容器池,支持用 BoosAPI 模型驱动文本、图像和视频节点。' },
{ icon: '✅', title: '自动签到', desc: '定时签到 + 余额刷新,不再手动操心' }, { icon: 'AG', title: 'ComfyUI Agent', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: '🔔', title: '多渠道告警', desc: 'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒' }, { icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
{ icon: '📦', title: '轻量部署', desc: '单 Docker 容器,内置 SQLite,无外部依赖' }, { icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
]; ];
const TECH_STACK = [ const TECH_STACK = [
{ name: 'Fastify', desc: '高性能 Node.js 后端框架' }, { name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
{ name: 'React', desc: '用户界面库' }, { name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和容器生命周期管理。' },
{ name: 'TypeScript', desc: '端到端类型安全' }, { name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
{ name: 'Tailwind CSS v4', desc: '原子化样式框架' }, { name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
{ name: 'Drizzle ORM', desc: '轻量 TypeScript ORM' }, { name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
{ name: 'SQLite', desc: '零配置嵌入式数据库' }, { name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
]; ];
const LINKS = [ const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: '📂' }, { label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: 'GH' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: '🐳' }, { label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: 'DH' },
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' }, { label: '站点文档', href: SITE_DOCS_URL, icon: 'DOC' },
];
const ENDPOINTS = [
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
]; ];
export default function About() { export default function About() {
@@ -99,7 +110,24 @@ export default function About() {
</div> </div>
</div> </div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}> <div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。')} {tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、ComfyUI、图像生成、视频任务和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和 ComfyUI 节点都能使用后台已经配置好的模型。')}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI', 'Picture Agent', 'Volcengine Video'].map((tag) => (
<span
key={tag}
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '5px 9px',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'var(--color-bg-secondary)',
}}
>
{tag}
</span>
))}
</div> </div>
</div> </div>
@@ -137,7 +165,22 @@ export default function About() {
display: 'flex', gap: 10, padding: '8px 0', display: 'flex', gap: 10, padding: '8px 0',
borderBottom: '1px solid var(--color-border-light)', borderBottom: '1px solid var(--color-border-light)',
}}> }}>
<span style={{ fontSize: 18, lineHeight: '24px', flexShrink: 0 }}>{f.icon}</span> <span style={{
width: 34,
height: 26,
borderRadius: 8,
border: '1px solid var(--color-border-light)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
color: 'var(--color-primary)',
background: 'var(--color-bg-secondary)',
flexShrink: 0,
}}>
{f.icon}
</span>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div> <div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}> <div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
@@ -149,8 +192,34 @@ export default function About() {
</div> </div>
</div> </div>
{/* Tech Stack */}
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}> <div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3>
<div style={{ display: 'grid', gap: 9 }}>
{ENDPOINTS.map((endpoint) => (
<div
key={endpoint.path}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(190px, 260px) 1fr',
gap: 12,
alignItems: 'start',
padding: '9px 0',
borderBottom: '1px solid var(--color-border-light)',
}}
>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-primary)' }}>
{endpoint.path}
</code>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.6 }}>
{tr(endpoint.desc)}
</div>
</div>
))}
</div>
</div>
{/* Tech Stack */}
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3> <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
<div style={{ <div style={{
display: 'grid', display: 'grid',
@@ -174,7 +243,7 @@ export default function About() {
</div> </div>
{/* Links */} {/* Links */}
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}> <div className="card animate-slide-up stagger-5" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3> <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{LINKS.map((l) => ( {LINKS.map((l) => (
@@ -195,7 +264,7 @@ export default function About() {
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')} onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')} onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
> >
<span>{l.icon}</span> <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{l.icon}</span>
<span>{tr(l.label)}</span> <span>{tr(l.label)}</span>
</a> </a>
))} ))}
@@ -206,7 +275,7 @@ export default function About() {
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}> <div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3> <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}> <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')} {tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、ComfyUI 容器与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
</div> </div>
</div> </div>
</div> </div>
+10 -2
View File
@@ -1371,7 +1371,15 @@ export default function ModelTester() {
rawMode: customRequestMode, rawMode: customRequestMode,
...(customRequestMode ...(customRequestMode
? { rawJsonText: customRequestBody } ? { rawJsonText: customRequestBody }
: { jsonBody: { model: inputs.model, prompt: assetPrompt.trim() } }), : {
jsonBody: {
model: inputs.model,
prompt: assetPrompt.trim(),
ratio: '16:9',
fps: 24,
duration: 5,
},
}),
}; };
} }
@@ -1379,7 +1387,7 @@ export default function ModelTester() {
if (!videoInspectId.trim()) return null; if (!videoInspectId.trim()) return null;
return { return {
method: videoInspectAction, method: videoInspectAction,
path: `/v1/videos/${encodeURIComponent(videoInspectId.trim())}`, path: `/v1/videos/${encodeURIComponent(videoInspectId.trim())}?model=${encodeURIComponent(inputs.model)}`,
requestKind: 'empty', requestKind: 'empty',
stream: false, stream: false,
jobMode: false, jobMode: false,
+632
View File
@@ -0,0 +1,632 @@
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { fetchSessionResponse } from '../api.js';
import { clearSession, getSessionToken, type SessionAudience } from '../authSession.js';
type AgentImage = {
id: string;
url: string;
prompt: string;
model: string;
mode: 'generate' | 'edit';
createdAt: number;
};
type PictureMode = 'generate' | 'edit';
type PictureAgentStatus = {
success?: boolean;
mode?: string;
role?: string;
keyName?: string;
upstreamLabel?: string;
};
type PromptTemplate = {
id: string;
title: string;
tag: string;
size: string;
image: string;
prompt: string;
source?: {
name?: string;
url?: string;
postNumber?: number;
username?: string;
};
};
type GalleryItem = {
post_number?: number;
username?: string;
prompt?: string;
images?: string[];
image_host_url?: string;
};
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
const ASPECT_OPTIONS = [
{ value: '1:1', label: '1:1 方图', width: 1024, height: 1024 },
{ value: '2:3', label: '2:3 竖图', width: 1024, height: 1536 },
{ value: '3:2', label: '3:2 横图', width: 1536, height: 1024 },
{ value: '9:16', label: '9:16 竖屏', width: 1024, height: 1792 },
{ value: '16:9', label: '16:9 宽屏', width: 1792, height: 1024 },
{ value: 'auto', label: '自动', width: 0, height: 0 },
{ value: 'custom', label: '自定义', width: 1024, height: 1024 },
];
const STYLE_PRESETS = [
{
label: '商业摄影',
prompt: 'premium commercial product photography, clean studio lighting, sharp details, refined composition',
},
{
label: '电影感',
prompt: 'cinematic lighting, 35mm lens, dramatic atmosphere, rich color grading, high detail',
},
{
label: '角色设定',
prompt: 'character concept art, full body, clear silhouette, detailed outfit, neutral background',
},
{
label: '国潮插画',
prompt: 'modern Chinese illustration style, elegant linework, vivid but balanced colors, poster composition',
},
];
function makeId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `picture-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function extractImageUrls(payload: any): string[] {
const data = Array.isArray(payload?.data) ? payload.data : [];
return data
.map((item: any) => {
if (typeof item?.url === 'string' && item.url.trim()) return item.url.trim();
if (typeof item?.b64_json === 'string' && item.b64_json.trim()) {
return `data:image/png;base64,${item.b64_json.trim()}`;
}
return '';
})
.filter(Boolean);
}
async function parseError(response: Response) {
const text = await response.text().catch(() => '');
if (!text) return `HTTP ${response.status}`;
try {
const value = JSON.parse(text);
return value?.error?.message || value?.error || value?.message || text.slice(0, 180);
} catch {
return text.slice(0, 180);
}
}
function getFilename(image: AgentImage) {
const safeModel = image.model.replace(/[^a-z0-9_-]+/gi, '-').slice(0, 32) || 'image';
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
}
function clampDimension(value: number) {
if (!Number.isFinite(value)) return 1024;
return Math.min(4096, Math.max(256, Math.round(value)));
}
function parseSizeValue(value: string): { width: number; height: number } | null {
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value.trim());
if (!match) return null;
return {
width: clampDimension(Number(match[1])),
height: clampDimension(Number(match[2])),
};
}
function aspectFromSize(size: string) {
if (size === 'auto') return 'auto';
const parsed = parseSizeValue(size);
if (!parsed) return 'custom';
const match = ASPECT_OPTIONS.find((option) => option.width === parsed.width && option.height === parsed.height);
return match?.value || 'custom';
}
function buildSize(aspect: string, width: number, height: number) {
if (aspect === 'auto') return 'auto';
if (aspect === 'custom') return `${clampDimension(width)}x${clampDimension(height)}`;
const option = ASPECT_OPTIONS.find((item) => item.value === aspect);
if (!option || !option.width || !option.height) return 'auto';
return `${option.width}x${option.height}`;
}
function templateTitle(prompt: string, postNumber?: number) {
const clean = prompt.split(/\s+/).join(' ').trim();
let title = Array.from(clean).slice(0, 18).join('');
if (!title) title = postNumber ? `#${postNumber}` : '参考模板';
if (Array.from(clean).length > 18) title += '...';
return title;
}
function templateTag(prompt: string) {
const lower = prompt.toLowerCase();
if (prompt.includes('海报') || lower.includes('poster')) return '海报';
if (prompt.includes('漫画') || lower.includes('comic')) return '漫画';
if (prompt.includes('图鉴') || prompt.includes('信息图') || lower.includes('infographic')) return '图鉴';
if (prompt.includes('贴纸') || lower.includes('sticker') || lower.includes('mascot')) return '贴纸';
if (prompt.includes('分镜') || lower.includes('storyboard')) return '分镜';
return '参考';
}
function templateSize(prompt: string) {
const lower = prompt.toLowerCase();
if (lower.includes('16:9') || prompt.includes('横版')) return '1536x1024';
if (lower.includes('9:16') || lower.includes('--ar 9:16') || prompt.includes('竖版')) return '1024x1536';
if (lower.includes('1:1') || prompt.includes('方图')) return '1024x1024';
return 'auto';
}
function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate[] {
return items
.map<PromptTemplate | null>((item) => {
const prompt = (item.prompt || '').trim();
const image = (item.images || []).find((value) => value.startsWith('http'))
|| (item.image_host_url || '').trim();
if (Array.from(prompt).length < 20 || !image.startsWith('http')) return null;
return {
id: item.post_number ? `linux-do-${item.post_number}` : `linux-do-${templateTitle(prompt)}`,
title: templateTitle(prompt, item.post_number),
tag: templateTag(prompt),
size: templateSize(prompt),
image,
prompt,
source: {
name: 'Linux Do GPT Image Gallery',
url: 'https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html',
postNumber: item.post_number,
username: item.username,
},
} satisfies PromptTemplate;
})
.filter((value): value is PromptTemplate => value !== null)
.slice(0, limit);
}
async function loadGalleryTemplatesDirectly(limit = 48): Promise<PromptTemplate[]> {
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
const items = await response.json();
return normalizeGalleryItems(Array.isArray(items) ? items : [], limit);
}
export default function PictureAgent() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [status, setStatus] = useState<PictureAgentStatus | null>(null);
const [models, setModels] = useState<string[]>([]);
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
const [mode, setMode] = useState<PictureMode>('generate');
const [aspect, setAspect] = useState('1:1');
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(1024);
const [quality, setQuality] = useState('auto');
const [templates, setTemplates] = useState<PromptTemplate[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [templatesError, setTemplatesError] = useState('');
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState('');
const [images, setImages] = useState<AgentImage[]>([]);
const [selectedId, setSelectedId] = useState('');
const [loading, setLoading] = useState(false);
const [bootError, setBootError] = useState('');
const [error, setError] = useState('');
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
const resolvedSize = buildSize(aspect, customWidth, customHeight);
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
return fetchSessionResponse(audience, url, options);
}, [audience]);
useEffect(() => {
let ignore = false;
async function load() {
if (!token) {
clearSession(audience, localStorage);
setBootError(audience === 'user' ? '用户会话已过期,请重新登录。' : '管理员会话已过期,请重新登录。');
return;
}
setBootError('');
setTemplatesLoading(true);
setTemplatesError('');
try {
const [statusRes, modelsRes, templatesRes] = await Promise.all([
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
authedFetch('/api/picture-agent/templates?limit=48', { timeoutMs: 30_000 }),
]);
if (!statusRes.ok) throw new Error(await parseError(statusRes));
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
const nextStatus = await statusRes.json();
const nextModels = await modelsRes.json();
let nextTemplates: PromptTemplate[] = [];
if (templatesRes.ok) {
const templatePayload = await templatesRes.json();
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
} else {
try {
nextTemplates = await loadGalleryTemplatesDirectly(48);
} catch {
setTemplatesError(await parseError(templatesRes));
}
}
if (ignore) return;
const availableModels = Array.isArray(nextModels?.models) ? nextModels.models : [];
setStatus(nextStatus);
setModels(availableModels);
setTemplates(nextTemplates);
setModel((current) => current || nextModels?.defaultModel || availableModels[0] || 'gpt-image-2');
} catch (err: any) {
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
} finally {
if (!ignore) setTemplatesLoading(false);
}
}
void load();
return () => {
ignore = true;
};
}, [audience, authedFetch, token]);
useEffect(() => {
if (!sourceFile) {
setSourcePreview('');
return;
}
const url = URL.createObjectURL(sourceFile);
setSourcePreview(url);
return () => URL.revokeObjectURL(url);
}, [sourceFile]);
const canSubmit = useMemo(() => {
if (loading) return false;
if (!prompt.trim()) return false;
if (!model.trim()) return false;
if (mode === 'edit' && !sourceFile) return false;
return true;
}, [loading, mode, model, prompt, sourceFile]);
const handleSubmit = async () => {
if (!canSubmit) return;
setLoading(true);
setError('');
try {
let response: Response;
if (mode === 'edit') {
const form = new FormData();
form.set('model', model.trim());
form.set('prompt', prompt.trim());
form.set('size', resolvedSize);
form.set('quality', quality);
if (sourceFile) form.set('image', sourceFile, sourceFile.name || 'source.png');
response = await authedFetch('/api/picture-agent/images/edits', {
method: 'POST',
body: form,
timeoutMs: 180_000,
});
} else {
response = await authedFetch('/api/picture-agent/images/generations', {
method: 'POST',
body: JSON.stringify({
model: model.trim(),
prompt: prompt.trim(),
size: resolvedSize,
quality,
}),
timeoutMs: 180_000,
});
}
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
const urls = extractImageUrls(payload);
if (urls.length === 0) throw new Error('上游没有返回图片 URL 或 base64 图片。');
const createdAt = Date.now();
const nextImages = urls.map((url) => ({
id: makeId(),
url,
prompt: prompt.trim(),
model: model.trim(),
mode,
createdAt,
}));
setImages((current) => [...nextImages, ...current].slice(0, 48));
setSelectedId(nextImages[0]?.id || '');
} catch (err: any) {
setError(err?.message || '图片生成失败');
} finally {
setLoading(false);
}
};
const handleDownload = (image: AgentImage | null) => {
if (!image) return;
const link = document.createElement('a');
link.href = image.url;
link.download = getFilename(image);
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
};
const applyPreset = (preset: string) => {
setPrompt((current) => {
const clean = current.trim();
if (!clean) return preset;
if (clean.includes(preset)) return clean;
return `${clean}, ${preset}`;
});
};
const applyTemplate = (template: PromptTemplate) => {
setPrompt(template.prompt);
const templateAspect = aspectFromSize(template.size);
setAspect(templateAspect);
const parsed = parseSizeValue(template.size);
if (parsed) {
setCustomWidth(parsed.width);
setCustomHeight(parsed.height);
}
setMode('generate');
setError('');
};
if (bootError) {
return (
<div className="empty-state" style={{ minHeight: 320 }}>
<div className="empty-state-title">生图助手暂不可用</div>
<div className="empty-state-desc">{bootError}</div>
</div>
);
}
return (
<div className="picture-agent-page">
<div className="page-header">
<div>
<h1 className="page-title">生图助手</h1>
<p className="page-subtitle">
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
</p>
</div>
<div className="page-actions">
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
<select className="picture-agent-select" value={model} onChange={(event) => setModel(event.target.value)}>
{models.length === 0 && <option value={model || 'gpt-image-2'}>{model || 'gpt-image-2'}</option>}
{models.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
</div>
<div className="picture-agent-shell">
<aside className="picture-agent-panel">
<div className="picture-agent-panel-head">
<div>
<strong>创作参数</strong>
<span>{mode === 'edit' ? '上传参考图后进行局部或整体改写' : '输入提示词生成第一张画布图片'}</span>
</div>
</div>
<div className="picture-agent-tabs" aria-label="picture mode">
<button className={mode === 'generate' ? 'active' : ''} onClick={() => setMode('generate')} type="button">文生图</button>
<button className={mode === 'edit' ? 'active' : ''} onClick={() => setMode('edit')} type="button">图像编辑</button>
</div>
<label className="picture-agent-field">
<span>提示词</span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={8}
placeholder="描述主体、镜头、风格、材质、光线和构图"
/>
</label>
<div className="picture-agent-presets">
{STYLE_PRESETS.map((preset) => (
<button key={preset.label} type="button" onClick={() => applyPreset(preset.prompt)}>
{preset.label}
</button>
))}
</div>
<div className="picture-agent-grid-fields">
<label className="picture-agent-field">
<span>比例</span>
<select
value={aspect}
onChange={(event) => {
const nextAspect = event.target.value;
setAspect(nextAspect);
const option = ASPECT_OPTIONS.find((item) => item.value === nextAspect);
if (option?.width && option?.height) {
setCustomWidth(option.width);
setCustomHeight(option.height);
}
}}
>
{ASPECT_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</label>
<label className="picture-agent-field">
<span>质量</span>
<select value={quality} onChange={(event) => setQuality(event.target.value)}>
{QUALITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
</div>
<div className="picture-agent-size-row">
<label className="picture-agent-field">
<span>宽度</span>
<input
type="number"
min={256}
max={4096}
step={64}
value={customWidth}
disabled={aspect === 'auto'}
onChange={(event) => {
setAspect('custom');
setCustomWidth(clampDimension(Number(event.target.value)));
}}
/>
</label>
<label className="picture-agent-field">
<span>高度</span>
<input
type="number"
min={256}
max={4096}
step={64}
value={customHeight}
disabled={aspect === 'auto'}
onChange={(event) => {
setAspect('custom');
setCustomHeight(clampDimension(Number(event.target.value)));
}}
/>
</label>
<div className="picture-agent-size-preview">
<span>提交尺寸</span>
<strong>{resolvedSize}</strong>
</div>
</div>
{mode === 'edit' && (
<div className="picture-agent-upload">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
style={{ display: 'none' }}
/>
<div className="picture-agent-upload-bar">
<button type="button" className="btn btn-ghost" onClick={() => fileInputRef.current?.click()}>
选择图片
</button>
{sourceFile && <span>{sourceFile.name}</span>}
</div>
{sourcePreview ? (
<img src={sourcePreview} alt="source" />
) : (
<button type="button" className="picture-agent-upload-empty" onClick={() => fileInputRef.current?.click()}>
上传一张参考图
</button>
)}
</div>
)}
{error && <div className="alert alert-error">{error}</div>}
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
{loading ? '处理中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
</button>
</aside>
<section className="picture-agent-canvas">
<div className="picture-agent-template-strip">
<div className="picture-agent-template-title">
<strong>参考模板</strong>
<span>{templatesLoading ? '正在刷新模板...' : '每次打开自动获取 Linux Do GPT Image Gallery'}</span>
</div>
<div className="picture-agent-template-list">
{templatesError && templates.length === 0 ? (
<div className="picture-agent-template-empty">模板加载失败:{templatesError}</div>
) : templates.length === 0 ? (
<div className="picture-agent-template-empty">{templatesLoading ? '正在加载参考模板...' : '暂无可用模板'}</div>
) : templates.map((template) => (
<button
key={template.id}
type="button"
className="picture-agent-template-card"
onClick={() => applyTemplate(template)}
title={template.prompt}
>
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
<span>
<strong>{template.title}</strong>
<em>{template.tag}{template.source?.postNumber ? ` · #${template.source.postNumber}` : ''}</em>
</span>
</button>
))}
</div>
</div>
<div className="picture-agent-board">
{selectedImage ? (
<>
<div className="picture-agent-image-frame">
<img src={selectedImage.url} alt={selectedImage.prompt} />
</div>
<div className="picture-agent-board-meta">
<div>
<strong>{resultLabel}</strong>
<span>{selectedImage.model} · {new Date(selectedImage.createdAt).toLocaleTimeString()}</span>
</div>
<button type="button" className="btn btn-ghost" onClick={() => handleDownload(selectedImage)}>下载</button>
</div>
</>
) : (
<div className="picture-agent-empty">
<div style={emptyIconStyle} />
<div className="empty-state-title">画布等待第一张图片</div>
<div className="empty-state-desc">选择模型、调整参数,然后开始生成或上传参考图进行编辑。</div>
</div>
)}
</div>
<div className="picture-agent-history-wrap">
<div className="picture-agent-history-title">
<strong>历史结果</strong>
<span>{images.length} 张</span>
</div>
<div className="picture-agent-history">
{images.length === 0 ? (
<div className="picture-agent-history-empty">生成结果会保留在这里,便于快速对比。</div>
) : images.map((image) => (
<button
key={image.id}
type="button"
className={image.id === selectedImage?.id ? 'active' : ''}
onClick={() => setSelectedId(image.id)}
title={image.prompt}
>
<img src={image.url} alt="" />
</button>
))}
</div>
</div>
</section>
</div>
</div>
);
}
const emptyIconStyle: CSSProperties = {
width: 54,
height: 54,
borderRadius: 8,
border: '1px solid var(--color-border)',
background:
'linear-gradient(135deg, color-mix(in srgb, var(--color-info) 16%, var(--color-bg-card)), color-mix(in srgb, var(--color-success) 12%, var(--color-bg-card)))',
};
+18
View File
@@ -10,6 +10,7 @@ import FactoryResetModal from './settings/FactoryResetModal.js';
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js'; import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
import { import {
createCodexDefaultHighReasoningVisualPreset, createCodexDefaultHighReasoningVisualPreset,
createVolcengineVideoTaskVisualPreset,
createVisualPayloadRule, createVisualPayloadRule,
isVisualPayloadRuleBlank, isVisualPayloadRuleBlank,
payloadRulesToVisualRules, payloadRulesToVisualRules,
@@ -1004,6 +1005,15 @@ export default function Settings() {
toast.success('已填入 Codex 默认高推理预设'); toast.success('已填入 Codex 默认高推理预设');
}; };
const applyVolcengineVideoTaskPreset = () => {
applyVisualPayloadRules((currentRules) => [
...currentRules.filter((rule) => !isVisualPayloadRuleBlank(rule)),
...createVolcengineVideoTaskVisualPreset(),
]);
setShowPayloadRulesEditor(true);
toast.success('已填入火山视频任务 Step1/Step2 模板');
};
const addPayloadVisualRule = () => { const addPayloadVisualRule = () => {
applyVisualPayloadRules((currentRules) => [ applyVisualPayloadRules((currentRules) => [
...currentRules, ...currentRules,
@@ -1564,6 +1574,14 @@ export default function Settings() {
> >
Codex 默认高推理 Codex 默认高推理
</button> </button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={applyVolcengineVideoTaskPreset}
>
火山视频任务 Step1/Step2
</button>
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
+8 -2
View File
@@ -1329,7 +1329,13 @@ export const buildVideoCreateRequestEnvelope = (
stream: false, stream: false,
jobMode: false, jobMode: false,
rawMode: false, rawMode: false,
jsonBody: files.length > 0 ? undefined : { model: inputs.model, prompt: modeState.videosPrompt }, jsonBody: files.length > 0 ? undefined : {
model: inputs.model,
prompt: modeState.videosPrompt,
ratio: '16:9',
fps: 24,
duration: 5,
},
multipartFields: files.length > 0 ? { model: inputs.model, prompt: modeState.videosPrompt } : undefined, multipartFields: files.length > 0 ? { model: inputs.model, prompt: modeState.videosPrompt } : undefined,
multipartFiles: files.length > 0 ? files : undefined, multipartFiles: files.length > 0 ? files : undefined,
}); });
@@ -1339,7 +1345,7 @@ export const buildVideoInspectRequestEnvelope = (
modeState: ModelTesterModeState, modeState: ModelTesterModeState,
): TesterProxyEnvelope => ({ ): TesterProxyEnvelope => ({
method: inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET', method: inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET',
path: `/v1/videos/${encodeURIComponent(modeState.videosInspectId.trim())}`, path: `/v1/videos/${encodeURIComponent(modeState.videosInspectId.trim())}?model=${encodeURIComponent(inputs.model)}`,
requestKind: 'empty', requestKind: 'empty',
stream: false, stream: false,
jobMode: false, jobMode: false,
@@ -66,6 +66,24 @@ export function createCodexDefaultHighReasoningVisualPreset(): VisualPayloadRule
]; ];
} }
export function createVolcengineVideoTaskVisualPreset(): VisualPayloadRule[] {
return [
createVisualPayloadRule({
action: 'default',
modelPattern: 'doubao-seedance-*',
protocol: 'openai',
path: 'content',
value: JSON.stringify([
{
type: 'text',
text: '一只橘猫在赛博朋克城市屋顶奔跑 --ratio 16:9 --fps 24 --dur 5',
},
], null, 2),
valueMode: 'json',
}),
];
}
export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] { export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] {
if (!isRecord(value)) return []; if (!isRecord(value)) return [];