serde_yaml+config路径 → service(settings读写+yaml同步+restart) → admin端点(掩码) → 前端连接管理卡片 → 部署联调。写文件+重启容器同步机制。 Co-Authored-By: Claude <noreply@anthropic.com>
19 KiB
Pixelle 连接凭证纳入 BoosAPI 管理 实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 BoosAPI 连接管理页加「Pixelle 连接」卡片,管 runninghub_api_key / comfyui_url / comfyui_api_key 三个凭证,保存即写入 settings 表 + 同步 Pixelle config.yaml + 重启 pixelle 容器生效。
Architecture: 新增 service pixelle_connection.rs(读写 settings 表 + 改 config.yaml YAML 三键 + docker restart)+ admin 路由(GET/PUT,密钥掩码)+ 前端 Accounts 页卡片。settings 表为真相源,config.yaml 是同步目标。
Tech Stack: Rust(axum, sqlx, serde_yaml<新增依赖>, tokio::process)、React/TS。
构建/测试:
cargo build -j 2(-j2 防 OOM)、cargo test -p boosapi --lib pixelle_connection、npm run typecheck:web。分支feat/comfy-cloud-adapter。Pixelle compose 目录/opt/Pixelle-Video,config/opt/Pixelle-Video/config.yaml。
文件结构
- Modify
Cargo.toml— 加serde_yaml依赖。 - Modify
src/config.rs—PixelleVideoConfig加config_path/compose_dir两字段(默认/opt/Pixelle-Video/config.yaml、/opt/Pixelle-Video)。 - Create
src/services/pixelle_connection.rs— read/save settings + sync_to_pixelle(改 yaml)+ restart。 - Modify
src/services/mod.rs—pub mod pixelle_connection;。 - Create
src/routes/admin/pixelle_connection.rs— GET/PUT handler(掩码)。 - Modify
src/routes/admin/mod.rs— 声明子模块 + 挂 2 条路由。 - Modify
src/web/api.ts— adminApi 加 getPixelleConnection / savePixelleConnection。 - Modify
src/web/pages/Accounts.tsx— 加「Pixelle 连接」卡片。
Task 1: 加 serde_yaml 依赖 + config 路径字段
Files:
-
Modify:
Cargo.toml -
Modify:
src/config.rs -
Step 1: Cargo.toml 加 serde_yaml
在 [dependencies] 段加(版本对齐项目 serde 生态,用 0.9):
serde_yaml = "0.9"
- Step 2: PixelleVideoConfig 加两字段(src/config.rs,struct 内 timeout_secs 后)
在 pub timeout_secs: u64, 之后加:
#[serde(default = "default_pixelle_config_path")]
pub config_path: String,
#[serde(default = "default_pixelle_compose_dir")]
pub compose_dir: String,
- Step 3: 加默认值函数(src/config.rs,default_pixelle_backend_provider 附近)
fn default_pixelle_config_path() -> String {
"/opt/Pixelle-Video/config.yaml".to_string()
}
fn default_pixelle_compose_dir() -> String {
"/opt/Pixelle-Video".to_string()
}
- Step 4: PixelleVideoConfig::default() 补两字段
找到 impl Default for ... 或 PixelleVideoConfig::default()(约 line 207 区域,有 api_base_url: Some(...) 那个),在其字段列表末尾加:
config_path: default_pixelle_config_path(),
compose_dir: default_pixelle_compose_dir(),
- Step 5: 构建确认
Run: cd /root/metapi && cargo build -j 2 2>&1 | tail -3
Expected: Finished。
- Step 6: Commit
cd /root/metapi && git add Cargo.toml Cargo.lock src/config.rs
git commit -m "feat(pixelle): 加 serde_yaml + Pixelle config 路径配置项
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 2: pixelle_connection service(settings + yaml 同步)
Files:
-
Create:
src/services/pixelle_connection.rs -
Modify:
src/services/mod.rs -
Step 1: src/services/mod.rs 加模块声明
pub mod pixelle_connection;
- Step 2: 写失败测试
src/services/pixelle_connection.rs(含结构 + sync 测试)
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PixelleConnection {
pub runninghub_api_key: Option<String>,
pub comfyui_url: Option<String>,
pub comfyui_api_key: Option<String>,
}
const KEY_RH: &str = "pixelle.runninghub_api_key";
const KEY_URL: &str = "pixelle.comfyui_url";
const KEY_CK: &str = "pixelle.comfyui_api_key";
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(k).fetch_optional(db).await?)
}
Ok(PixelleConnection {
runninghub_api_key: get(db, KEY_RH).await?,
comfyui_url: get(db, KEY_URL).await?,
comfyui_api_key: get(db, KEY_CK).await?,
})
}
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
if let Some(val) = v {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(k).bind(val).execute(db).await?;
}
Ok(())
}
put(db, KEY_RH, &conn.runninghub_api_key).await?;
put(db, KEY_URL, &conn.comfyui_url).await?;
put(db, KEY_CK, &conn.comfyui_api_key).await?;
Ok(())
}
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
let comfyui = doc.get_mut("comfyui")
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
let map = comfyui.as_mapping_mut()
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
if let Some(v) = &conn.runninghub_api_key {
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_url {
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_api_key {
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
}
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
let backup = format!("{config_path}.bak-{ts}");
std::fs::copy(config_path, &backup)
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
let out = serde_yaml::to_string(&doc)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
std::fs::write(config_path, out)
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
Ok(backup)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_updates_three_keys_preserves_rest() {
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.yaml");
std::fs::write(&path,
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
let conn = PixelleConnection {
runninghub_api_key: Some("newkey".into()),
comfyui_url: Some("http://new".into()),
comfyui_api_key: Some("newck".into()),
};
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
assert!(std::path::Path::new(&backup).exists());
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("newkey"));
assert!(after.contains("http://new"));
assert!(after.contains("newck"));
// 保留其余键
assert!(after.contains("runninghub_concurrent_limit"));
assert!(after.contains("project_name"));
std::fs::remove_dir_all(&dir).ok();
}
}
- Step 3: 运行测试,确认失败
Run: cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -15
Expected: FAIL —— 模块/函数未定义(首次编译错误)。
- Step 4: 实现
Step 2 的代码即实现(测试与实现同文件)。确保 mod.rs 已声明。
- Step 5: 运行测试,确认通过
Run: cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -10
Expected: PASS(1 个测试)。
- Step 6: Commit
cd /root/metapi && git add src/services/mod.rs src/services/pixelle_connection.rs
git commit -m "feat(pixelle): 连接凭证 service(settings 读写 + config.yaml 同步)
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 3: restart helper + admin 路由(掩码)
Files:
-
Modify:
src/services/pixelle_connection.rs(加 restart) -
Create:
src/routes/admin/pixelle_connection.rs -
Modify:
src/routes/admin/mod.rs -
Step 1: 加 restart_pixelle(pixelle_connection.rs,sync_to_pixelle 之后)
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
let output = tokio::process::Command::new("docker")
.args(["compose", "restart", "api"])
.current_dir(compose_dir)
.output()
.await
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::ServiceUnavailable(format!(
"Pixelle 重启失败: {}", stderr.trim()
)));
}
Ok(())
}
- Step 2: 写 admin 路由
src/routes/admin/pixelle_connection.rs
use crate::error::AppResult;
use crate::services::pixelle_connection::{self, PixelleConnection};
use crate::AppState;
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
const MASK_SENTINEL: &str = "••••";
fn mask(v: &Option<String>) -> Value {
match v {
Some(s) if s.len() > 4 => json!(format!("{}{}", MASK_SENTINEL, &s[s.len()-4..])),
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
_ => Value::Null,
}
}
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
let c = pixelle_connection::read_connection(&state.db).await?;
Ok(Json(json!({
"success": true,
"connection": {
// url 不算敏感,明文回;两个 key 掩码
"comfyuiUrl": c.comfyui_url,
"runninghubApiKey": mask(&c.runninghub_api_key),
"comfyuiApiKey": mask(&c.comfyui_api_key),
}
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePayload {
runninghub_api_key: Option<String>,
comfyui_url: Option<String>,
comfyui_api_key: Option<String>,
}
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
// 掩码值(以 •••• 开头)表示"未修改",跳过
match incoming {
Some(s) if s.starts_with(MASK_SENTINEL) => None,
other => other,
}
}
pub async fn put_connection(
State(state): State<AppState>,
Json(p): Json<UpdatePayload>,
) -> AppResult<Json<Value>> {
let conn = PixelleConnection {
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
comfyui_url: p.comfyui_url, // url 明文,不掩码
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
};
pixelle_connection::save_connection(&state.db, &conn).await?;
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
match restart {
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
Err(e) => Ok(Json(json!({
"success": true, "backup": backup, "restarted": false,
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
}))),
}
}
- Step 3: src/routes/admin/mod.rs 声明子模块 + 挂路由
模块声明区(mod users; 附近)加:
mod pixelle_connection;
在 routes() 的路由链里(/api/admin/user-balances 那一带)加:
.route(
"/api/admin/pixelle/connection",
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
)
确认 get/put 已从 axum::routing 引入(文件顶部应已 use axum::routing::{get, post, ...};若缺 put 则补)。
- Step 4: 构建确认
Run: cd /root/metapi && cargo build -j 2 2>&1 | tail -3
Expected: Finished。
- Step 5: Commit
cd /root/metapi && git add src/services/pixelle_connection.rs src/routes/admin/pixelle_connection.rs src/routes/admin/mod.rs
git commit -m "feat(pixelle): 连接凭证 admin 端点(GET/PUT 掩码 + 重启)
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 4: 前端连接管理卡片
Files:
-
Modify:
src/web/api.ts -
Modify:
src/web/pages/Accounts.tsx -
Step 1: api.ts adminApi 加方法
在 adminApi 对象里(找 request( 范式的 admin 方法群)加:
getPixelleConnection: () =>
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
method: "PUT",
body: JSON.stringify(data),
}),
- Step 2: Accounts.tsx 加「Pixelle 连接」卡片
先读 src/web/pages/Accounts.tsx 顶部,确认 api/adminApi 的导入名和 toast 用法。在页面主体某处(如页尾)加一个卡片组件块。最小实现(state + 加载 + 保存):
// —— Pixelle 连接(加在组件内 state 区)——
const [pxConn, setPxConn] = useState({ comfyuiUrl: '', runninghubApiKey: '', comfyuiApiKey: '' });
const [pxSaving, setPxSaving] = useState(false);
useEffect(() => {
adminApi.getPixelleConnection()
.then((r) => setPxConn({
comfyuiUrl: r.connection?.comfyuiUrl || '',
runninghubApiKey: r.connection?.runninghubApiKey || '',
comfyuiApiKey: r.connection?.comfyuiApiKey || '',
}))
.catch(() => {});
}, []);
const savePxConn = async () => {
setPxSaving(true);
try {
const res = await adminApi.savePixelleConnection({
runninghubApiKey: pxConn.runninghubApiKey || undefined,
comfyuiUrl: pxConn.comfyuiUrl || undefined,
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
});
if (res.warning) toast.error(res.warning);
else toast.success('已保存并重启 Pixelle,约数秒生效');
} catch (e: any) {
toast.error(e?.message || '保存失败');
} finally {
setPxSaving(false);
}
};
卡片 JSX(加到 return 的合适位置,样式对齐页面现有 card):
<div className="card" style={{ marginTop: 16, padding: 16 }}>
<h3>Pixelle 连接</h3>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>ComfyUI URL</span>
<input value={pxConn.comfyuiUrl} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))} placeholder="http://172.17.0.1:4000/comfy-cloud" style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>RunningHub API Key</span>
<input value={pxConn.runninghubApiKey} onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>Comfy Cloud API Key</span>
<input value={pxConn.comfyuiApiKey} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
</label>
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
{pxSaving ? '保存中...' : '保存并重启 Pixelle'}
</button>
</div>
注:
useState/useEffect若未导入需补;adminApi/api用 Accounts.tsx 实际的导入名(读文件确认是api还是adminApi)。若提交掩码值(••••...)后端会跳过不改,符合预期。
- Step 3: typecheck
Run: cd /root/metapi && npm run typecheck:web 2>&1 | tail -5
Expected: 无 TS 错误。
- Step 4: Commit
cd /root/metapi && git add src/web/api.ts src/web/pages/Accounts.tsx
git commit -m "feat(pixelle): 连接管理页加 Pixelle 连接卡片
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 5: 部署 + 端到端验证
Files: 无(部署 + 运行时)
- Step 1: 重建并重启 BoosAPI(含前后端改动)
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
Expected: 构建成功,BoosAPI 起在 :4000。动线上服务,确认时机。
- Step 2: 连接管理页确认「Pixelle 连接」卡片
硬刷新管理控制台连接管理页(/accounts),确认出现「Pixelle 连接」卡片,
ComfyUI URL 显示当前值,两个 key 显示掩码(••••xxxx)。
- Step 3: 改一个值测试同步
把 RunningHub API Key 改成一个新值,保存。确认:
grep runninghub_api_key /opt/Pixelle-Video/config.yaml | sed 's/[a-f0-9]\{20\}/.../'
ls -t /opt/Pixelle-Video/config.yaml.bak-* | head -1
rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
Expected: config.yaml 的 key 变成新值;有新备份文件;容器重启后 healthy。
- Step 4: 掩码不改测试
只改 ComfyUI URL、两个 key 保持掩码原值,保存。确认 config.yaml 里两个 key 没变 (掩码值被后端跳过),URL 变了。
- Step 5: 成功标准
UI 改 Pixelle 凭证 → settings 表存值 + config.yaml 同步 + 容器重启,不再需要手动编辑文件。
完成标准
- 后端:pixelle_connection service(读写 settings + sync_to_pixelle + restart)+ admin GET/PUT 端点,
cargo build -j 2通过,service 单测通过。 - 前端:连接管理页有「Pixelle 连接」卡片,typecheck 干净。
- 端到端:UI 改凭证 → config.yaml 同步 + 容器重启生效;掩码值不误改。
- 向后兼容:不改 Pixelle 源码;config.yaml 其余键保留;每次改前备份。
已知风险 / 备注
- serde_yaml 重序列化会丢 config.yaml 注释(已接受;每次有时间戳备份兜底)。
- BoosAPI 进程需有 docker compose 执行权限 + config.yaml 读写权限(同宿主 root 运行,满足)。
- Task 5 重启 BoosAPI 动线上服务,确认时机。
- 备份文件会累积(config.yaml.bak-*),长期可加清理,本期不做。