feat: cache remote picture templates in database

This commit is contained in:
boos
2026-07-14 03:42:34 +08:00
parent bdd26a5e43
commit 379dfd7b11
5 changed files with 142 additions and 52 deletions
+23 -13
View File
@@ -27,8 +27,8 @@
## 2. 开发环境 (Development) ## 2. 开发环境 (Development)
### 前提条件 ### 前提条件
- **Rust**: 1.85+ (推荐使用 rustup) - **Rust**: 1.88+ (推荐使用 rustup)
- **Node.js**: 20+ (仅用于前端构建) - **Bun**: 1.2+ (仅用于前端构建,生产运行不需要 Bun)
- **SQLite**: 系统自带或手动安装 - **SQLite**: 系统自带或手动安装
### 步骤 ### 步骤
@@ -39,24 +39,26 @@
``` ```
2. **配置环境**: 2. **配置环境**:
复制 `config.toml.example` 并根据需要修改: 编辑仓库中的 `config.toml`,或直接设置环境变量:
```bash ```bash
cp config.toml.example config.toml export BOOSAPI_SERVER__HOST=127.0.0.1
export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=change-me-admin-token
``` ```
或者直接设置环境变量。 或者直接设置环境变量。
3. **运行前端 (热更新模式)**: 3. **运行前端 (热更新模式)**:
```bash ```bash
npm install bun install --frozen-lockfile
# 启动 vite 开发服务器 # 启动 Vite 开发服务器
npm run dev:web bun run dev
``` ```
默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。 默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。
4. **运行后端**: 4. **运行后端**:
```bash ```bash
# 使用默认配置运行 # 使用默认配置运行
cargo run cargo run --release
# 推荐:使用 cargo-watch 自动重载 # 推荐:使用 cargo-watch 自动重载
# cargo install cargo-watch # cargo install cargo-watch
# cargo watch -x run # cargo watch -x run
@@ -120,10 +122,7 @@ config.toml
1. **构建前端与后端**: 1. **构建前端与后端**:
```bash ```bash
# 构建前端 ./build-and-run.sh --production
npm run build
# 构建后端
cargo build --release
``` ```
2. **安装**: 2. **安装**:
@@ -232,10 +231,21 @@ backend = "boosapi"
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。 - `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。 - `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。 - 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;`build-and-run.sh` 会按需安装与构建。
--- ---
### 图片助手模板
`/api/picture-agent/templates` 使用数据库缓存的远程 Linux Do 模板。服务启动时
刷新一次,之后每 6 小时刷新;远程失败时继续使用旧缓存,不依赖 Redis。
### SQLx vendor
`vendor/sqlx-core`、`vendor/sqlx-mysql` 和 `vendor/sqlx-sqlite` 由根目录
`Cargo.toml` 的 `[patch.crates-io]` 显式引用,当前不能作为冗余文件删除。移除前需要
先删除这些 patch,并完成完整 Cargo 构建和数据库迁移验证。
## 5. 运维操作 (Ops) ## 5. 运维操作 (Ops)
### 检查配置 ### 检查配置
+27 -25
View File
@@ -15,7 +15,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat"> <img alt="Rust" src="https://img.shields.io/badge/Rust-1.88%2B-000000?logo=rust&style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat"> <img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat"> <img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat"> <img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
@@ -57,7 +57,7 @@
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 | | 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 | | 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
## 2026-06 更新重点 ## 2026-07 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。 - **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。 - **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
@@ -66,8 +66,10 @@
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。 - **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。 - **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。 - **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。 - **Workflow UI 更新**:`bun run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。 - **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
- **图片助手模板**:模板接口使用数据库缓存的远程图库数据,服务启动时刷新,之后每 6 小时刷新,远程失败时保留旧缓存。
- **部署方式统一**:前端统一使用 Bun,生产构建统一使用 `./build-and-run.sh --production`。
## ✨ 核心功能 ## ✨ 核心功能
@@ -261,7 +263,7 @@ curl http://127.0.0.1:4000/v1/videos \
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。 返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。 > Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `bun run workflow-ui:install:cloud`、`bun run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始 ## 🚀 快速开始
@@ -331,7 +333,8 @@ docker compose up -d
├── config.toml # 默认配置文件 ├── config.toml # 默认配置文件
├── Dockerfile # 多阶段构建 ├── Dockerfile # 多阶段构建
├── docker/ ├── docker/
│ └── docker-compose.yml │ ├── docker-compose.databases.yml
│ └── docker-compose.override.yml
├── src/ ├── src/
│ ├── main.rs # 入口 │ ├── main.rs # 入口
│ ├── lib.rs # 库入口 │ ├── lib.rs # 库入口
@@ -359,6 +362,8 @@ docker compose up -d
│ ├── billing/ # 计费逻辑 │ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送 │ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移 ├── migrations/ # SQLite 迁移
├── migrations_pg/ # PostgreSQL 迁移
├── migrations_mysql/ # MySQL/MariaDB 迁移
├── src/web/ # 管理台前端源码(React + Vite) ├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶) │ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage) │ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
@@ -489,7 +494,7 @@ socket 不需要的 PAM/LDAP,并剥离符号;可选体积模式额外使用
| 样式 | Tailwind CSS v4 | | 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart | | 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s | | 容器化 | Docker + Docker Compose + K3s |
| 构建工具 | Cargo(后端)/ npm(前端静态构建) | | 构建工具 | Cargo(后端)/ Bun(前端静态构建) |
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。 > **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
@@ -541,21 +546,21 @@ export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
```bash ```bash
# 安装依赖(首次) # 安装依赖(首次)
npm install bun install --frozen-lockfile
# 开发模式(热更新,需要 Node.js) # 开发模式(热更新,需要 Bun)
cd src/web && npx vite --host 0.0.0.0 bun run dev --host 0.0.0.0
# 类型检查与构建生产版本 # 类型检查与构建生产版本
npm run typecheck:web bun run typecheck:web
npm run build:web bun run build:web
# 输出到 dist/web/,Rust 后端自动 serve # 输出到 dist/web/,Rust 后端自动 serve
``` ```
生图助手的 tldraw 无限画布在 HTTPS 生产域名需要有效许可证。许可证是域名绑定的客户端构建变量,构建时注入即可: 生图助手的 tldraw 无限画布在 HTTPS 生产域名需要有效许可证。许可证是域名绑定的客户端构建变量,构建时注入即可:
```bash ```bash
VITE_TLDRAW_LICENSE_KEY='tldraw-...' npm run build:web VITE_TLDRAW_LICENSE_KEY='tldraw-...' bun run build:web
``` ```
`build-and-run.sh` 会把该变量的哈希纳入前端构建指纹;许可证变化时会自动重新构建静态资源。本地 HTTP / loopback 开发环境不要求许可证。当前仓库仅用于绑定 `0.0.0.0` 或 `::` 的本机/局域网测试,因此 `vite.config.ts` 固定将 `process.env.NODE_ENV` 定义为 `development`。公开部署时必须改回 `production` 并使用 `VITE_TLDRAW_LICENSE_KEY`。 `build-and-run.sh` 会把该变量的哈希纳入前端构建指纹;许可证变化时会自动重新构建静态资源。本地 HTTP / loopback 开发环境不要求许可证。当前仓库仅用于绑定 `0.0.0.0` 或 `::` 的本机/局域网测试,因此 `vite.config.ts` 固定将 `process.env.NODE_ENV` 定义为 `development`。公开部署时必须改回 `production` 并使用 `VITE_TLDRAW_LICENSE_KEY`。
@@ -564,26 +569,23 @@ Workflow UI 前端资产单独更新:
```bash ```bash
# Comfy Cloud 前端 # Comfy Cloud 前端
npm run workflow-ui:install:cloud bun run workflow-ui:install:cloud
# External / RunningHub 前端 # External / RunningHub 前端
npm run workflow-ui:install:external bun run workflow-ui:install:external
``` ```
可选使用 Bun 加速主前端构建: 构建脚本默认使用 Bun:
```bash ```bash
# 一次性使用 Bun 构建主前端
./build-and-run.sh --bun ./build-and-run.sh --bun
# 显式指定 Bun
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
``` ```
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。 > Workflow UI 子模块也统一使用 Bun 构建,避免本地开发环境出现多套包管理器。
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。 > 生产环境不需要 Bun 进程。`bun run build:web` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。 > 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js) ### 独立官网(Next.js)
@@ -592,14 +594,14 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
```bash ```bash
# 首次安装 # 首次安装
npm --prefix website install bun install --cwd website --frozen-lockfile
# 本地开发官网 # 本地开发官网
npm run website:dev bun run website:dev
# 类型检查与生产构建 # 类型检查与生产构建
npm run website:typecheck bun run website:typecheck
npm run website:build bun run website:build
``` ```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。 `./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
+3
View File
@@ -276,6 +276,7 @@ async fn main() -> anyhow::Result<()> {
let state = AppState::new_with_redis(config.clone(), pool, builtin, runtime_auth, redis); let state = AppState::new_with_redis(config.clone(), pool, builtin, runtime_auth, redis);
let payment_job_worker = boosapi::services::payment_jobs::spawn(state.clone()); let payment_job_worker = boosapi::services::payment_jobs::spawn(state.clone());
let account_health_worker = boosapi::services::account_health::spawn(state.clone()); let account_health_worker = boosapi::services::account_health::spawn(state.clone());
let picture_template_worker = boosapi::routes::spawn_template_sync(state.clone());
if let Err(err) = state.comfyui.start().await { if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start"); tracing::warn!(%err, "ComfyUI embedded process did not start");
} }
@@ -301,6 +302,8 @@ async fn main() -> anyhow::Result<()> {
} }
account_health_worker.abort(); account_health_worker.abort();
let _ = account_health_worker.await; let _ = account_health_worker.await;
picture_template_worker.abort();
let _ = picture_template_worker.await;
if let Err(err) = comfyui_process.stop().await { if let Err(err) = comfyui_process.stop().await {
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly"); tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
+1
View File
@@ -42,6 +42,7 @@ mod payment;
mod payment_providers; mod payment_providers;
mod payment_refunds; mod payment_refunds;
mod picture_agent; mod picture_agent;
pub use picture_agent::spawn_template_sync;
mod profile_avatar; mod profile_avatar;
mod proxy; mod proxy;
mod search; mod search;
+88 -14
View File
@@ -25,6 +25,10 @@ const TEMPLATE_GALLERY_PAGE_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html"; "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
const TEMPLATE_DATA_URL: &str = const TEMPLATE_DATA_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.with_tags.json"; "https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.with_tags.json";
const TEMPLATE_CACHE_KEY: &str = "picture_agent.linux_do_templates";
const TEMPLATE_REFRESH_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
const TEMPLATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const TEMPLATE_MAX_BYTES: usize = 16 * 1024 * 1024;
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation"; const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300; const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
const PICTURE_GENERATION_RETRY_SECS: u64 = 180; const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
@@ -58,6 +62,21 @@ pub fn routes() -> Router<AppState> {
.route("/api/picture-agent/images/edits", post(images_edits)) .route("/api/picture-agent/images/edits", post(images_edits))
} }
pub fn spawn_template_sync(state: AppState) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
refresh_template_cache(&state).await;
let mut ticker = tokio::time::interval_at(
tokio::time::Instant::now() + TEMPLATE_REFRESH_INTERVAL,
TEMPLATE_REFRESH_INTERVAL,
);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
refresh_template_cache(&state).await;
}
})
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TemplateQuery { struct TemplateQuery {
limit: Option<usize>, limit: Option<usize>,
@@ -435,38 +454,93 @@ async fn templates_handler(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?; let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(144).clamp(1, 240); let limit = query.limit.unwrap_or(144).clamp(1, 240);
let (templates, remote_status) = match fetch_gallery_templates(&state.http, limit).await { let (templates, cache_status) = match cached_template_json(&state).await {
Ok(templates) => (templates, json!({ "ok": true, "source": "remote-json" })), Ok(Some(raw)) => match parse_gallery_templates(&raw, limit) {
Err(err) => { Ok(templates) => (templates, json!({ "ok": true, "source": "database-cache" })),
tracing::warn!(%err, "Picture Agent remote Linux Do template gallery unavailable"); Err(err) => (Vec::new(), json!({ "ok": false, "message": err })),
(Vec::new(), json!({ "ok": false, "message": err })) },
Ok(None) => {
refresh_template_cache(&state).await;
match cached_template_json(&state).await {
Ok(Some(raw)) => match parse_gallery_templates(&raw, limit) {
Ok(templates) => (
templates,
json!({ "ok": true, "source": "database-cache" }),
),
Err(err) => (Vec::new(), json!({ "ok": false, "message": err })),
},
Ok(None) => (
Vec::new(),
json!({ "ok": false, "message": "template cache is empty" }),
),
Err(err) => (Vec::new(), json!({ "ok": false, "message": err })),
}
} }
Err(err) => (Vec::new(), json!({ "ok": false, "message": err })),
}; };
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL, "sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
"source": remote_status, "source": cache_status,
"templates": templates, "templates": templates,
}))) })))
} }
async fn fetch_gallery_templates( async fn refresh_template_cache(state: &AppState) {
client: &reqwest::Client, match fetch_template_json(state).await {
limit: usize, Ok(raw) => {
) -> Result<Vec<Value>, String> { let result = crate::db::sql::query(
let raw = client "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
)
.bind(TEMPLATE_CACHE_KEY)
.bind(raw)
.execute(&*state.db)
.await;
if let Err(err) = result {
tracing::warn!(%err, "failed to persist Picture Agent template cache");
} else {
tracing::info!("Picture Agent template cache refreshed");
}
}
Err(err) => {
tracing::warn!(%err, "Picture Agent template refresh failed; keeping old cache")
}
}
}
async fn fetch_template_json(state: &AppState) -> Result<String, String> {
let response = state
.http
.get(TEMPLATE_DATA_URL) .get(TEMPLATE_DATA_URL)
.timeout(TEMPLATE_REQUEST_TIMEOUT)
.send() .send()
.await .await
.map_err(|err| format!("failed to fetch remote Linux Do templates: {err}"))? .map_err(|err| format!("failed to fetch remote Linux Do templates: {err}"))?
.error_for_status() .error_for_status()
.map_err(|err| format!("remote Linux Do templates returned an error: {err}"))? .map_err(|err| format!("remote Linux Do templates returned an error: {err}"))?;
.text() let bytes = response
.bytes()
.await .await
.map_err(|err| format!("failed to read remote Linux Do templates: {err}"))?; .map_err(|err| format!("failed to read remote Linux Do templates: {err}"))?;
if bytes.len() > TEMPLATE_MAX_BYTES {
return Err(format!(
"remote Linux Do templates exceed {TEMPLATE_MAX_BYTES} bytes"
));
}
String::from_utf8(bytes.to_vec())
.map_err(|err| format!("remote Linux Do templates are not UTF-8: {err}"))
}
parse_gallery_templates(&raw, limit) async fn cached_template_json(state: &AppState) -> Result<Option<String>, String> {
crate::db::sql::query_scalar::<_, Option<String>>(
"SELECT value FROM settings WHERE key = ? LIMIT 1",
)
.bind(TEMPLATE_CACHE_KEY)
.fetch_optional(&*state.db)
.await
.map(|value| value.flatten())
.map_err(|err| format!("failed to read Picture Agent template cache: {err}"))
} }
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> { fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {