# 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): ```toml serde_yaml = "0.9" ``` - [ ] **Step 2: PixelleVideoConfig 加两字段(src/config.rs,struct 内 timeout_secs 后)** 在 `pub timeout_secs: u64,` 之后加: ```rust #[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 附近)** ```rust 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(...)` 那个),在其字段列表末尾加: ```rust 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** ```bash 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 " ``` ## 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 加模块声明** ```rust pub mod pixelle_connection; ``` - [ ] **Step 2: 写失败测试 `src/services/pixelle_connection.rs`(含结构 + sync 测试)** ```rust 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, pub comfyui_url: Option, pub comfyui_api_key: Option, } 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 { async fn get(db: &SqlitePool, k: &str) -> AppResult> { 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) -> 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} 三键,其余保留。 /// 改前备份到 .bak-。返回备份路径。 pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult { 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** ```bash 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 " ``` ## 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 之后)** ```rust 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`** ```rust 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) -> 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) -> AppResult> { 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, comfyui_url: Option, comfyui_api_key: Option, } fn keep_if_not_mask(incoming: Option) -> Option { // 掩码值(以 •••• 开头)表示"未修改",跳过 match incoming { Some(s) if s.starts_with(MASK_SENTINEL) => None, other => other, } } pub async fn put_connection( State(state): State, Json(p): Json, ) -> AppResult> { 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;` 附近)加: ```rust mod pixelle_connection; ``` 在 `routes()` 的路由链里(`/api/admin/user-balances` 那一带)加: ```rust .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** ```bash 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 " ``` ## Task 4: 前端连接管理卡片 **Files:** - Modify: `src/web/api.ts` - Modify: `src/web/pages/Accounts.tsx` - [ ] **Step 1: api.ts adminApi 加方法** 在 adminApi 对象里(找 `request(` 范式的 admin 方法群)加: ```typescript 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 + 加载 + 保存): ```tsx // —— 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): ```tsx

Pixelle 连接

``` > 注:`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** ```bash cd /root/metapi && git add src/web/api.ts src/web/pages/Accounts.tsx git commit -m "feat(pixelle): 连接管理页加 Pixelle 连接卡片 Co-Authored-By: Claude " ``` ## Task 5: 部署 + 端到端验证 **Files:** 无(部署 + 运行时) - [ ] **Step 1: 重建并重启 BoosAPI(含前后端改动)** ```bash 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 改成一个新值,保存。确认: ```bash 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-*),长期可加清理,本期不做。