Improve offline template loading

This commit is contained in:
boos
2026-06-01 11:02:36 +08:00
parent bb31118792
commit 04792bd3f7
4 changed files with 292 additions and 65 deletions
+114 -14
View File
@@ -1,21 +1,21 @@
use crate::{
AppState,
auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
Json, Router,
body::Body,
extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Response, StatusCode},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::time::Duration;
const TEMPLATE_GALLERY_URL: &str =
@@ -344,18 +344,46 @@ async fn templates_handler(
) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(144).clamp(1, 240);
let mut templates = builtin_picture_templates(limit);
let remote_status = match fetch_remote_gallery_templates(limit.saturating_sub(templates.len()))
.await
{
Ok(mut remote_templates) => {
templates.append(&mut remote_templates);
json!({ "ok": true })
}
Err(err) => {
tracing::warn!(%err, "Picture Agent template gallery unavailable; using built-in templates");
json!({ "ok": false, "message": err })
}
};
templates.truncate(limit);
Ok(Json(json!({
"success": true,
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"fallback": remote_status.get("ok").and_then(Value::as_bool) != Some(true),
"remote": remote_status,
"templates": templates,
})))
}
async fn fetch_remote_gallery_templates(limit: usize) -> Result<Vec<Value>, String> {
if limit == 0 {
return Ok(Vec::new());
}
let items = template_client()
.get(TEMPLATE_GALLERY_URL)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {err}")))?
.map_err(|err| format!("failed to fetch templates: {err}"))?
.error_for_status()
.map_err(|err| AppError::UpstreamError(format!("template gallery returned {err}")))?
.map_err(|err| format!("template gallery returned {err}"))?
.json::<Vec<GalleryItem>>()
.await
.map_err(|err| AppError::UpstreamError(format!("failed to parse templates: {err}")))?;
.map_err(|err| format!("failed to parse templates: {err}"))?;
let templates: Vec<Value> = items
Ok(items
.into_iter()
.filter_map(|item| {
let prompt = item.prompt?.trim().to_string();
@@ -390,13 +418,85 @@ async fn templates_handler(
}))
})
.take(limit)
.collect();
.collect())
}
Ok(Json(json!({
"success": true,
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"templates": templates,
})))
fn builtin_picture_templates(limit: usize) -> Vec<Value> {
[
(
"boosapi-product-glass-robot",
"玻璃机器人产品照",
"商业",
"1024x1024",
"A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background, controlled studio lighting, premium advertising composition, ultra detailed",
),
(
"boosapi-hanfu-cat-golden-hour",
"汉服与白猫",
"国风",
"1024x1536",
"A breathtakingly beautiful ancient Chinese woman in flowing jade-green and blush pink hanfu, golden peony embroidery, sitting by a moon-shaped window in warm afternoon sunlight, gently teasing a fluffy white cat with a silk tassel, celadon tea set and ink calligraphy nearby, serene romantic dreamlike Song Dynasty aesthetics, cinematic depth of field, ultra detailed digital art",
),
(
"boosapi-cyberpunk-street",
"赛博街景海报",
"海报",
"1536x1024",
"A cinematic cyberpunk street market after rain, neon signs reflected on wet pavement, a courier in a translucent raincoat walking through steam, layered storefronts and holographic menus, dramatic teal and amber lighting, editorial poster composition, high detail",
),
(
"boosapi-character-turnaround",
"角色四视图",
"角色",
"1536x1024",
"Character design sheet, same original fantasy explorer shown in front view, side view, back view and three-quarter view, consistent outfit and proportions, clean light background, readable silhouette, detailed fabric and accessories, professional concept art",
),
(
"boosapi-food-photography",
"高级餐饮摄影",
"商业",
"1024x1024",
"Premium food photography of a porcelain bowl of hand-pulled noodles with chili oil, scallions and steam, dark stone tabletop, soft side lighting, shallow depth of field, appetizing texture, refined restaurant campaign style",
),
(
"boosapi-architecture-interior",
"现代茶室空间",
"空间",
"1536x1024",
"A quiet contemporary Chinese tea room interior with warm timber lattice, low stone tea table, linen cushions, bamboo courtyard visible through sliding doors, natural morning light, calm luxury hospitality design, realistic architectural visualization",
),
(
"boosapi-kids-book-dragon",
"绘本小龙",
"插画",
"1024x1024",
"A gentle children's book illustration of a tiny green dragon learning to fly above a village garden, soft watercolor texture, expressive friendly eyes, warm pastel palette, charming composition, lots of small story details",
),
(
"boosapi-film-still-office",
"悬疑电影帧",
"电影",
"1536x1024",
"A suspenseful cinematic film still in a dim modern office at night, a figure with a glowing screen-like face stands motionless near frosted glass, subtle volumetric light, muted colors, realistic lens depth, unsettling quiet atmosphere",
),
]
.into_iter()
.take(limit)
.map(|(id, title, tag, size, prompt)| {
json!({
"id": id,
"title": title,
"tag": tag,
"size": size,
"image": Value::Null,
"prompt": prompt,
"source": {
"name": "BoosAPI 内置模板",
"url": Value::Null,
},
})
})
.collect()
}
fn template_client() -> Client {
+141 -9
View File
@@ -539,6 +539,38 @@ fn workflow_template_payload() -> Value {
})
}
fn core_workflow_template_index() -> Value {
json!([
{
"moduleName": "default",
"title": "BoosAPI Examples",
"category": "basics",
"type": "core",
"isEssential": true,
"templates": [
{
"name": "boosapi-text-to-image",
"title": "BoosAPI 文生图",
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
"mediaType": "image",
"mediaSubtype": "svg",
"tags": ["boosapi", "image"],
"models": ["BoosAPI"]
},
{
"name": "boosapi-text-to-video",
"title": "BoosAPI 文生视频",
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
"mediaType": "image",
"mediaSubtype": "svg",
"tags": ["boosapi", "video"],
"models": ["BoosAPI"]
}
]
}
])
}
fn inject_boosapi_workflow_template_payload(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
*value = workflow_template_payload();
@@ -1531,13 +1563,22 @@ async fn official_workflow_template_asset(
if path.is_empty() || path.contains("..") {
return Err(AppError::BadRequest("invalid template path".into()));
}
if !state.config.comfyui.uses_cloud_backend() {
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
}
let encoded_path = path
.split('/')
.map(simple_urlencode)
.collect::<Vec<_>>()
.join("/");
let url = if state.config.comfyui.uses_cloud_backend() {
format!("{}/templates/{}", native_workflow_base(&state), encoded_path)
format!(
"{}/templates/{}",
native_workflow_base(&state),
encoded_path
)
} else {
format!("{}/{}", OFFICIAL_WORKFLOW_TEMPLATES_BASE, encoded_path)
};
@@ -1556,15 +1597,27 @@ async fn official_workflow_template_asset(
.header("Authorization", format!("Bearer {api_key}"));
}
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("workflow template unreachable: {err}")))?;
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote source unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
let status = response.status();
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote body unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[
@@ -1586,6 +1639,85 @@ async fn official_workflow_template_asset(
.into_response())
}
fn local_workflow_template_asset(path: &str) -> Option<Response> {
match path {
"index.json" | "index.zh.json" | "index.zh-CN.json" | "index_logo.json" => Some(
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=1800"),
],
if path == "index_logo.json" {
json!({}).to_string()
} else {
core_workflow_template_index().to_string()
},
)
.into_response(),
),
"boosapi-text-to-image.json" => {
Some(Json(default_boosapi_workflow_graph()).into_response())
}
"boosapi-text-to-video.json" => Some(Json(text_to_video_workflow_graph()).into_response()),
"boosapi-text-to-image.svg" | "boosapi-text-to-video.svg" => {
let label = if path.contains("video") {
"BoosAPI Video"
} else {
"BoosAPI Image"
};
Some(
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
workflow_template_thumbnail_svg(label),
)
.into_response(),
)
}
_ => None,
}
}
fn workflow_template_not_found_response(path: &str) -> Response {
(
StatusCode::NOT_FOUND,
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
json!({
"error": {
"message": format!("workflow template asset not available locally: {path}"),
"type": "Not Found",
}
})
.to_string(),
)
.into_response()
}
fn workflow_template_thumbnail_svg(label: &str) -> String {
let escaped_label = label
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" role="img" aria-label="{escaped_label}">
<rect width="320" height="180" rx="18" fill="#101820"/>
<path d="M0 132C58 102 94 104 146 126C202 150 248 154 320 118V180H0Z" fill="#1F7A8C"/>
<circle cx="264" cy="48" r="30" fill="#F6C85F"/>
<rect x="28" y="28" width="132" height="18" rx="9" fill="#E8F1F2" opacity=".9"/>
<rect x="28" y="58" width="210" height="10" rx="5" fill="#E8F1F2" opacity=".46"/>
<rect x="28" y="78" width="184" height="10" rx="5" fill="#E8F1F2" opacity=".32"/>
<text x="28" y="148" fill="#FFFFFF" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="700">{escaped_label}</text>
</svg>"##
)
}
fn embedded_workflow_ui_response(
config: &crate::config::ComfyUIConfig,
path: &str,
+26
View File
@@ -3650,6 +3650,32 @@ textarea {
background: color-mix(in srgb, var(--color-bg) 70%, var(--color-bg-card));
}
.picture-agent-template-placeholder {
width: 100%;
height: 102px;
display: grid;
place-items: center;
align-content: center;
gap: 4px;
border-radius: 6px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 14%, transparent), transparent 56%),
color-mix(in srgb, var(--color-bg) 74%, var(--color-bg-card));
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
color: var(--color-text-primary);
}
.picture-agent-template-placeholder strong {
max-width: 100%;
font-size: 13px;
text-align: center;
}
.picture-agent-template-placeholder small {
color: var(--color-text-muted);
font-size: 11px;
}
.picture-agent-template-card span {
display: grid;
gap: 3px;
+11 -42
View File
@@ -57,7 +57,7 @@ type PromptTemplate = {
title: string;
tag: string;
size: string;
image: string;
image?: string | null;
prompt: string;
source?: {
name?: string;
@@ -76,7 +76,6 @@ type GalleryItem = {
};
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 TEMPLATE_LIMIT = 144;
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
const ASPECT_OPTIONS = [
@@ -282,39 +281,6 @@ function isPictureModel(model: string) {
return !chatKeywords.some((keyword) => normalized.includes(keyword));
}
function normalizeGalleryItems(items: GalleryItem[], limit = TEMPLATE_LIMIT): 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 = TEMPLATE_LIMIT): 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';
@@ -424,11 +390,7 @@ export default function PictureAgent() {
const templatePayload = await templatesRes.json();
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
} else {
try {
nextTemplates = await loadGalleryTemplatesDirectly(TEMPLATE_LIMIT);
} catch {
setTemplatesError(await parseError(templatesRes));
}
setTemplatesError(await parseError(templatesRes));
}
let nextJobs: PictureJob[] = [];
if (jobsRes.ok) {
@@ -872,7 +834,7 @@ export default function PictureAgent() {
<div className="picture-agent-template-strip">
<div className="picture-agent-template-title">
<strong>参考模板</strong>
<span>{templatesLoading ? '正在刷新模板...' : '每次打开自动获取 Linux Do GPT Image Gallery'}</span>
<span>{templatesLoading ? '正在刷新模板...' : '内置模板优先,外部图库可用时自动补充'}</span>
</div>
<div className="picture-agent-template-list">
{templatesError && templates.length === 0 ? (
@@ -887,7 +849,14 @@ export default function PictureAgent() {
onClick={() => applyTemplate(template)}
title={template.prompt}
>
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
{template.image ? (
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
) : (
<div className="picture-agent-template-placeholder">
<strong>{template.tag}</strong>
<small>{template.size}</small>
</div>
)}
<span>
<strong>{template.title}</strong>
<em>{template.tag}{template.source?.postNumber ? ` · #${template.source.postNumber}` : ''}</em>