Integrate ComfyUI agents and video workflows
This commit is contained in:
BIN
Binary file not shown.
@@ -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
|
||||||
|
|||||||
+1
-1
Submodule comfy_source updated: 26515acd23...9afc945331
+2
-2
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
(
|
||||||
(status, http_status, error_message, latency_ms, created_at),
|
account_id,
|
||||||
)
|
(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?;
|
||||||
|
|||||||
@@ -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 = ?")
|
||||||
|
|||||||
@@ -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,12 +298,10 @@ 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)
|
||||||
)
|
.await
|
||||||
.execute(&pool)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('site.title', 'BoosAPI')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('site.title', 'BoosAPI')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -147,12 +147,15 @@ 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
|
||||||
"accountId": row.account_id,
|
.entry(model_name)
|
||||||
"username": row.username,
|
.or_default()
|
||||||
"siteId": row.site_id,
|
.push(json!({
|
||||||
"siteName": row.site_name,
|
"accountId": row.account_id,
|
||||||
}));
|
"username": row.username,
|
||||||
|
"siteId": row.site_id,
|
||||||
|
"siteName": row.site_name,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert HashMap -> serde_json::Map so the response keys serialize
|
// Convert HashMap -> serde_json::Map so the response keys serialize
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
+19
-16
@@ -369,20 +369,20 @@ 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?;
|
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
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)
|
)
|
||||||
.fetch_all(&*state.db)
|
.bind(id)
|
||||||
.await?;
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!({ "models": models })))
|
Ok(Json(json!({ "models": models })))
|
||||||
}
|
}
|
||||||
@@ -393,11 +393,10 @@ 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?;
|
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Err(AppError::NotFound("site not found".into()));
|
return Err(AppError::NotFound("site not found".into()));
|
||||||
}
|
}
|
||||||
@@ -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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+112
-23
@@ -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) => (
|
||||||
.into_iter()
|
selected,
|
||||||
.map(|chunk| {
|
attempts,
|
||||||
chunk.as_str().map(str::to_string).ok_or_else(|| {
|
chunks
|
||||||
AppError::UpstreamError("Invalid streaming response from conductor".into())
|
.into_iter()
|
||||||
})
|
.map(|chunk| {
|
||||||
})
|
chunk.as_str().map(str::to_string).ok_or_else(|| {
|
||||||
.collect::<AppResult<Vec<String>>>()?),
|
AppError::UpstreamError(
|
||||||
|
"Invalid streaming response from conductor".into(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -67,10 +67,9 @@ 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 =
|
||||||
sqlx::query_scalar("SELECT COALESCE(SUM(balance_used), 0.0) FROM accounts")
|
sqlx::query_scalar("SELECT COALESCE(SUM(balance_used), 0.0) FROM accounts")
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
@@ -93,10 +92,11 @@ 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)
|
)
|
||||||
.await?;
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
let today_checkin_success: i64 = sqlx::query_scalar(
|
let today_checkin_success: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM checkin_logs
|
"SELECT COUNT(*) FROM checkin_logs
|
||||||
WHERE status = 'success' AND date(created_at) = date('now')",
|
WHERE status = 'success' AND date(created_at) = date('now')",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ mod tests {
|
|||||||
.fetch_optional(db)
|
.fetch_optional(db)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_row(db: &SqlitePool) -> Option<(i64, String, i64)> {
|
async fn read_row(db: &SqlitePool) -> Option<(i64, String, i64)> {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +509,11 @@ 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();
|
|
||||||
assert_eq!(manual_count, 1);
|
assert_eq!(manual_count, 1);
|
||||||
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
|
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 = {},
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)))',
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user