Files
BoosAPI/docs/superpowers/plans/2026-06-18-pixelle-connection-management.md
T
boos 284cf84a47 docs: Pixelle 连接凭证管理实现计划(5 任务)
serde_yaml+config路径 → service(settings读写+yaml同步+restart) → admin端点(掩码)
→ 前端连接管理卡片 → 部署联调。写文件+重启容器同步机制。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 11:33:05 +08:00

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-*),长期可加清理,本期不做。