Files
codex-market-app/renderer/app.js
T
2026-07-15 18:45:40 +08:00

2798 lines
112 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.js —— 前端逻辑,只经 window.apic.* 与主进程通信。
// 绝不出现市场地址 / 密钥(那些只在主进程)。
"use strict";
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 注意:preload 已在 window 上定义了不可配置的全局属性 `api`,
// 顶层再用 const/let 声明同名会抛 "already been declared",故用 window.api 起个别名。
const apic = window.api;
// —— 工具 ——
function humanSize(n) {
const u = ["B", "K", "M", "G"];
let x = n;
for (const unit of u) {
if (x < 1024) return unit === "B" ? `${Math.round(x)}${unit}` : `${x.toFixed(1)}${unit}`;
x /= 1024;
}
return `${x.toFixed(1)}T`;
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
let toastTimer = null;
function toast(msg, kind = "") {
const t = $("#toast");
t.textContent = msg;
t.className = `toast ${kind}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add("hidden"), 4200);
}
function busy(btn, on, textWhenBusy) {
if (!btn) return;
if (on) {
btn.dataset.label = btn.textContent;
btn.textContent = textWhenBusy || "处理中…";
btn.disabled = true;
} else {
btn.textContent = btn.dataset.label || btn.textContent;
btn.disabled = false;
}
}
// —— 顶部广告:点击用系统浏览器打开官方推荐中转 ——
const promoEl = document.querySelector(".promo");
if (promoEl) {
promoEl.addEventListener("click", (e) => {
e.preventDefault();
apic.openExternal(promoEl.dataset.open);
});
}
// —— 标签切换 ——
// 激活某个面板(可能有顶部标签,也可能没有——如会话记录/记忆现从插件市场进入)
function activatePanel(name) {
$$(".tab").forEach((t) => t.classList.remove("active"));
$$(".panel").forEach((p) => p.classList.remove("active"));
const panel = $(`#tab-${name}`);
if (panel) panel.classList.add("active");
const tab = document.querySelector(`.tab[data-tab="${name}"]`);
if (tab) tab.classList.add("active");
}
$$(".tab").forEach((tab) => {
tab.addEventListener("click", () => activatePanel(tab.dataset.tab));
});
// 插件市场内「打开」内置工具(会话记录 / 会话记忆)
$$("[data-open-panel]").forEach((btn) => {
btn.addEventListener("click", () => {
const name = btn.dataset.openPanel;
activatePanel(name);
if (name === "sessions" && !sessLoaded) sessLoad();
if (name === "memory" && !memLoaded) memLoad();
});
});
// 内置工具面板「← 返回插件市场」
$$("[data-back-plugins]").forEach((btn) => {
btn.addEventListener("click", () => activatePanel("plugins"));
});
$$(".subtab").forEach((st) => {
st.addEventListener("click", () => {
$$(".subtab").forEach((s) => s.classList.remove("active"));
$$(".subpanel").forEach((s) => s.classList.remove("active"));
st.classList.add("active");
$(`#sub-${st.dataset.sub}`).classList.add("active");
});
});
// —— 日志抽屉 ——
$("#log-toggle").addEventListener("click", () => {
const p = $("#log-panel");
p.classList.toggle("hidden");
$("#log-caret").textContent = p.classList.contains("hidden") ? "▲" : "▼";
});
apic.onLog((msg) => {
const out = $("#log-out");
out.textContent += msg + "\n";
$("#log-panel").scrollTop = $("#log-panel").scrollHeight;
});
// ===========================================================================
// ① 插件市场
// ===========================================================================
let skillCat = null; // 完整 catalog {total, categories, skills}
let storeCat = "__all__"; // 当前分类(__all__ / __hot__ / 中文分类名)
let storeSort = "hot"; // hot / downloads / stars
let storeShown = 0; // 已渲染数量(分页)
const STORE_PAGE = 60;
const installed = new Set(JSON.parse(localStorage.getItem("installedSkills") || "[]"));
let installedSkills = []; // 磁盘实况(~/.codex/skills 扫描结果)
let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装”
// 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)
function safeSkillDir(name) {
return (String(name || "skill")
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
}
async function loadInstalledSkills() {
try { installedSkills = await apic.skillInstalled(); }
catch { installedSkills = []; }
installedDirs = new Set(installedSkills.map((x) => x.dir));
}
// 分类图标(与网页语义对应)
const CAT_ICON = {
"全部": "🗂️", "热门": "🔥", "API": "🔌", "办公": "📊", "设计": "🎨",
"效率": "⚡", "内容媒体": "📝", "文档处理": "📄", "开发": "🛠️",
"教育科研": "🎓", "图表": "📈", "客服": "🎧", "后端": "🗄️",
"数据分析": "📊", "数据库": "🗃️", "产品管理": "📋", "运维": "⚙️",
"财务": "💰", "健康": "🩺", "前端": "🖥️", "Git": "🔀", "人力资源": "👥",
"图片生成": "🖼️", "法律": "⚖️", "市场": "📣", "移动端": "📱", "运营": "🚀",
"支付": "💳", "项目管理": "🗓️", "React": "⚛️", "房产": "🏠", "销售": "🤝",
"测试": "🧪", "AI增强": "🤖", "其它": "🧩", "我已安装": "🧰",
};
const AGENT_CLASS = { Claude: "claude", Codex: "codex", OpenClaw: "openclaw" };
function numify(s) {
if (!s) return 0;
const n = parseFloat(String(s).replace(/,/g, ""));
if (isNaN(n)) return 0;
if (/k/i.test(s)) return n * 1e3;
if (/m/i.test(s)) return n * 1e6;
return n;
}
// 某个技能在「我的 gitea 仓库」里的文件页地址
function skillGitUrl(s) {
// 浏览走 update 域名(管理登录会话在此),clone 用 up 域名(git 直连)
const base = (skillCat.repo || "")
.replace(/\.git$/, "")
.replace("up.tokendun.cc", "update.tokendun.cc"); // https://update.tokendun.cc/mhgmh/skills
if (!base) return "https://hub.cocoloop.cn/popular";
if (!s.path) return base; // 无本地路径时退回仓库首页
// 中文分类目录需 URL 编码,路径分隔符保留
const enc = s.path.split("/").map(encodeURIComponent).join("/");
return `${base}/src/branch/main/${enc}`;
}
// 当前筛选后的技能列表
function storeFiltered() {
const q = $("#market-search").value.trim().toLowerCase();
let list = skillCat.skills;
if (storeCat === "__hot__") {
list = list.filter((s) => numify(s.dl) >= 50000 || numify(s.fav) >= 200000);
} else if (storeCat !== "__all__") {
list = list.filter((s) => s.category === storeCat || (s.cnTags || []).includes(storeCat));
}
if (q) {
list = list.filter((s) =>
(s.name || "").toLowerCase().includes(q) ||
(s.author || "").toLowerCase().includes(q) ||
(s.subtitle || "").toLowerCase().includes(q) ||
(s.brief || "").toLowerCase().includes(q)
);
}
if (storeSort === "downloads") list = [...list].sort((a, b) => numify(b.dl) - numify(a.dl));
else if (storeSort === "stars") list = [...list].sort((a, b) => numify(b.stars) - numify(a.stars));
// hot:保持 catalog 原序(已按下载量排)
return list;
}
function agentBadges(s) {
const a = (s.agents || []).map(
(x) => `<span class="agent-badge ${AGENT_CLASS[x] || ""}">${esc(x)}</span>`
).join("");
const mm = s.mm ? '<span class="agent-badge mm">🎨 多模态</span>' : "";
return `<div class="sk-agents"><span class="lab">适配</span>${a}${mm}</div>`;
}
function skillCardHTML(s) {
const sec = (s.sec || "").toUpperCase();
const secBadge = /^[SAB]$/.test(sec) ? `<div class="sk-sec ${sec}" title="安全评级 ${sec}">${sec}</div>` : "";
const isIn = installedDirs.has(safeSkillDir(s.name)) || installed.has(String(s.id));
const meta = [
`<span>⬇ ${esc(s.dl || "0")}</span>`,
s.stars ? `<span>★ ${esc(s.stars)}</span>` : "",
s.fav ? `<span>♥ ${esc(s.fav)}</span>` : "",
s.cred ? `<span class="cred">${esc(s.cred)}</span>` : "",
].join("");
return `
<div class="skill-card" data-id="${esc(String(s.id))}">
<div class="sk-top">
<div class="sk-ico">${esc(s.icon || "🧩")}</div>
<div class="sk-headings">
<div class="sk-name">${esc(s.name)}</div>
<div class="sk-author">by ${esc(s.author || "未知")}</div>
</div>
${secBadge}
</div>
<div class="sk-subtitle">${esc(s.subtitle || s.name)}</div>
<div class="sk-brief">${esc(s.brief || "")}</div>
${agentBadges(s)}
<div class="sk-meta">${meta}</div>
<div class="sk-foot">
<button class="btn primary small" data-install="${esc(String(s.id))}">${isIn ? "重新安装" : "安装"}</button>
<button class="btn ghost small" data-detail="${esc(String(s.id))}">详情</button>
</div>
</div>`;
}
function renderStore(reset = true) {
const grid = $("#market-grid");
const empty = $("#market-empty");
const more = $("#store-more");
if (storeCat === "__installed__") { renderInstalledSkills(); return; }
const list = storeFiltered();
if (reset) { storeShown = 0; grid.innerHTML = ""; }
if (!list.length) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "没有匹配的技能,换个关键词或分类试试。";
more.classList.add("hidden");
return;
}
empty.classList.add("hidden");
const next = list.slice(storeShown, storeShown + STORE_PAGE);
grid.insertAdjacentHTML("beforeend", next.map(skillCardHTML).join(""));
storeShown += next.length;
// 副标题
$("#store-cat-title").textContent =
storeCat === "__all__" ? "全部技能" : storeCat === "__hot__" ? "热门技能" : storeCat;
$("#store-cat-sub").textContent =
`${list.length} 个技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`;
// 加载更多
if (storeShown < list.length) {
more.classList.remove("hidden");
more.innerHTML = `已显示 ${storeShown} / ${list.length} · <button class="btn ghost small" id="store-more-btn">加载更多</button>`;
$("#store-more-btn").addEventListener("click", () => renderStore(false));
} else {
more.classList.remove("hidden");
more.innerHTML = `<span class="muted small">已全部显示 · 共 ${list.length} 个</span>`;
}
bindStoreCards();
}
function bindStoreCards() {
const grid = $("#market-grid");
grid.querySelectorAll("[data-install]").forEach((btn) => {
if (btn._bound) return; btn._bound = true;
btn.addEventListener("click", () => installSkill(btn.dataset.install, btn));
});
grid.querySelectorAll("[data-detail]").forEach((btn) => {
if (btn._bound) return; btn._bound = true;
btn.addEventListener("click", () => openSkillDetail(btn.dataset.detail));
});
}
// 「我已安装」视图:以磁盘 ~/.codex/skills 为准,可逐个卸载
function renderInstalledSkills() {
const grid = $("#market-grid");
const empty = $("#market-empty");
const more = $("#store-more");
more.classList.add("hidden");
more.innerHTML = "";
$("#store-cat-title").textContent = "我已安装";
$("#store-cat-sub").textContent =
`${installedSkills.length} 个已安装技能 · Codex 侧位于 ~/.codex/skills · 卸载会同时删除工具箱仓库实体`;
if (!installedSkills.length) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "还没有安装任何技能。去左侧分类里挑一个安装吧。";
return;
}
empty.classList.add("hidden");
grid.innerHTML = installedSkills.map((s) => `
<div class="skill-card installed" data-dir="${esc(s.dir)}">
<div class="sk-top">
<div class="sk-ico">🧩</div>
<div class="sk-headings">
<div class="sk-name">${esc(s.name || s.dir)}</div>
<div class="sk-author">已安装${s.symlink ? " · 软链" : ""} · ${humanSize(s.size || 0)}</div>
</div>
</div>
<div class="sk-brief">${esc(s.description || "(该技能未提供描述)")}</div>
<div class="sk-foot">
<button class="btn danger small" data-uninstall="${esc(s.dir)}">卸载</button>
<span class="muted small mono">${esc(s.dir)}</span>
</div>
</div>`).join("");
grid.querySelectorAll("[data-uninstall]").forEach((btn) =>
btn.addEventListener("click", () => uninstallSkill(btn.dataset.uninstall, btn))
);
}
async function uninstallSkill(dir, btn) {
const item = installedSkills.find((x) => x.dir === dir);
const label = (item && item.name) || dir;
if (!confirm(`确认卸载技能「${label}」?\n\n将删除 ~/.codex/skills/${dir} 以及对应工具箱仓库实体,此操作不可恢复。`)) return;
busy(btn, true, "卸载中…");
try {
await apic.skillUninstall(dir);
// 清掉旧的 localStorage 标记(按 id 存的,尽力匹配)
await loadInstalledSkills();
toast(`已卸载 ${label} ✅`, "ok");
renderInstalledSkills();
renderStoreNav();
} catch (e) {
toast(`卸载失败:${e.message || e}`, "err");
busy(btn, false);
}
}
function renderStoreNav() {
const nav = $("#store-nav");
$("#store-total").textContent = skillCat.total.toLocaleString();
const item = (key, name, count) => {
const active = storeCat === key ? " active" : "";
const c = count != null ? `<span class="nav-count">${count.toLocaleString()}</span>` : "";
return `<div class="nav-item${active}" data-cat="${esc(key)}">
<span class="nav-ico">${CAT_ICON[name] || "•"}</span>
<span class="nav-name">${esc(name)}</span>${c}</div>`;
};
let html = "";
html += `<div class="store-nav-group">精选</div>`;
html += item("__all__", "全部", skillCat.total);
html += item("__hot__", "热门", null);
html += item("__installed__", "我已安装", installedSkills.length);
html += `<div class="store-nav-group">全部分类</div>`;
for (const c of skillCat.categories) html += item(c.name, c.name, c.count);
nav.innerHTML = html;
nav.querySelectorAll(".nav-item").forEach((el) =>
el.addEventListener("click", () => {
storeCat = el.dataset.cat;
nav.querySelectorAll(".nav-item").forEach((x) => x.classList.remove("active"));
el.classList.add("active");
$("#market-grid").scrollTop = 0;
renderStore(true);
})
);
}
async function installSkill(id, btn) {
const s = skillCat.skills.find((x) => String(x.id) === String(id));
if (!s) return;
busy(btn, true, "安装中…");
btn.classList.add("installing");
try {
const r = await apic.skillInstall(s);
installed.add(String(s.id));
localStorage.setItem("installedSkills", JSON.stringify([...installed]));
await loadInstalledSkills();
renderStoreNav();
toast(`已安装 ${s.name} ✅(${r.files} 个文件)重启 Codex 生效`, "ok");
btn.textContent = "已安装 ✓";
btn.dataset.label = "已安装 ✓";
} catch (e) {
toast(`安装失败:${e.message || e}`, "err");
busy(btn, false);
} finally {
btn.classList.remove("installing");
}
}
function openSkillDetail(id) {
const s = skillCat.skills.find((x) => String(x.id) === String(id));
if (!s) return;
$("#modal-title").textContent = "技能详情";
const tags = (s.cnTags || []).map((t) => `<span class="sd-tag">${esc(t)}</span>`).join("");
$("#modal-body").innerHTML = `
<div class="sd-hero">
<div class="sk-ico">${esc(s.icon || "🧩")}</div>
<div>
<h3>${esc(s.name)}</h3>
<p class="muted small" style="margin:3px 0 0">by ${esc(s.author || "未知")} · 分类 ${esc(s.category)}</p>
</div>
</div>
<p style="font-weight:600;font-size:14px">${esc(s.subtitle || "")}</p>
<p>${esc(s.brief || "(暂无简介)")}</p>
${tags ? `<div class="sd-tags">${tags}</div>` : ""}
<div class="sd-stats">
<span>⬇ 下载 ${esc(s.dl || "0")}</span>
${s.stars ? `<span>★ 星标 ${esc(s.stars)}</span>` : ""}
${s.fav ? `<span>♥ 收藏 ${esc(s.fav)}</span>` : ""}
${s.sec ? `<span>🛡 安全级 ${esc(s.sec)}</span>` : ""}
${s.cred ? `<span>可信度 ${esc(s.cred)}</span>` : ""}
</div>
<h4>适配 AI 模型 / Agent</h4>
${agentBadges(s)}
<p class="muted small" style="margin-top:8px">${esc(skillCat.model_note || "SKILL.md 标准技能")}${s.mm ? ";本技能涉及图片/视觉,建议搭配多模态强模型。" : "。"}</p>
<div class="sd-install-row">
<button class="btn primary" id="sd-install">安装到 ~/.codex/skills</button>
<button class="btn ghost" id="sd-source">在我的 Git 查看 ↗</button>
</div>`;
modalOverlay.classList.remove("hidden");
$("#sd-install").addEventListener("click", (e) => installSkill(s.id, e.currentTarget));
$("#sd-source").addEventListener("click", () => apic.openExternal(skillGitUrl(s)));
}
async function loadMarket() {
const grid = $("#market-grid");
const empty = $("#market-empty");
empty.classList.add("hidden");
grid.innerHTML = '<div class="empty"><div class="spinner"></div>正在加载技能商店…</div>';
try {
skillCat = await apic.skillCatalog();
await loadInstalledSkills();
renderStoreNav();
renderStore(true);
} catch (e) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = `加载失败:${e.message || e}`;
}
}
let storeSearchTimer = null;
$("#market-search").addEventListener("input", () => {
clearTimeout(storeSearchTimer);
storeSearchTimer = setTimeout(() => renderStore(true), 180);
});
$("#store-sort").querySelectorAll(".seg").forEach((s) => {
s.addEventListener("click", () => {
$("#store-sort").querySelectorAll(".seg").forEach((x) => x.classList.remove("active"));
s.classList.add("active");
storeSort = s.dataset.sort;
renderStore(true);
});
});
// ===========================================================================
// ② 插件市场(tokendun 官方自研插件,数据源自 marketList)
// ===========================================================================
let pluginData = [];
let pluginLoaded = false;
function pluginCardHTML(e) {
const platLabel = { macos: "仅 macOS", windows: "仅 Windows", linux: "仅 Linux" }[(e.platform || "").toLowerCase()];
const platBadge = platLabel ? `<span class="pc-badge plat">${platLabel}</span>` : "";
return `
<div class="plugin-card">
<div class="pc-top"><span class="pc-badge plugin">插件</span>${platBadge}<div class="pc-name">${esc(e.displayName || e.name)}</div></div>
<div class="pc-meta">
<span>${humanSize(e.size || 0)}</span>
<span>tokendun 官方</span>
</div>
<div class="pc-desc">${esc(e.description) || "(暂无描述)"}</div>
<div class="pc-foot">
<span></span>
<button class="btn primary small" data-install='${esc(JSON.stringify(e))}'>安装</button>
</div>
</div>`;
}
function renderPlugins() {
const q = $("#plugin-search").value.trim().toLowerCase();
const grid = $("#plugin-grid");
const empty = $("#plugin-empty");
const list = pluginData.filter((e) =>
!q ||
(e.displayName || "").toLowerCase().includes(q) ||
(e.name || "").toLowerCase().includes(q) ||
(e.source || "").toLowerCase().includes(q) ||
(e.description || "").toLowerCase().includes(q)
);
if (!list.length) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.innerHTML = pluginData.length
? "没有匹配的插件,换个关键词试试。"
: "插件市场即将上线,敬请期待 —— tokendun 官方自研插件将陆续上架。";
return;
}
empty.classList.add("hidden");
grid.innerHTML = list.map(pluginCardHTML).join("");
grid.querySelectorAll("[data-install]").forEach((btn) => btn.addEventListener("click", () => installPlugin(btn)));
grid.querySelectorAll("[data-open]").forEach((a) =>
a.addEventListener("click", (e) => { e.preventDefault(); apic.openExternal(a.dataset.open); })
);
}
async function installPlugin(btn) {
let entry;
try { entry = JSON.parse(btn.dataset.install); } catch { return; }
busy(btn, true, "安装中…");
btn.classList.add("installing");
try {
await apic.pluginInstall(entry);
toast(`已安装 ${entry.displayName || entry.name} ✅ 重启 codex 生效`, "ok");
btn.textContent = "已安装 ✓";
btn.dataset.label = "已安装 ✓";
loadInstalledPlugins();
} catch (e) {
toast(`安装失败:${e.message || e}`, "err");
busy(btn, false);
} finally {
btn.classList.remove("installing");
}
}
// 已安装插件(以磁盘 ~/.codex/plugins/cache 为准,可卸载)
let installedPlugins = [];
function installedPluginCardHTML(p) {
const uninstall = p.protected
? `<button class="btn small" disabled>系统插件</button>`
: `<button class="btn danger small" data-uninstall='${esc(JSON.stringify({ rel: p.rel, name: p.name, label: p.displayName || p.name }))}'>卸载</button>`;
return `
<div class="plugin-card installed">
<div class="pc-top"><span class="pc-badge plugin">已安装</span><div class="pc-name">${esc(p.displayName || p.name)}</div></div>
<div class="pc-meta">
<span>${humanSize(p.size || 0)}</span>
<span>来源:${esc(p.source || "—")}</span>
${p.symlink ? "<span>软链</span>" : ""}
</div>
<div class="pc-desc">${esc(p.description) || "(暂无描述)"}</div>
<div class="pc-foot">
<span class="muted small mono">${esc(p.rel)}</span>
${uninstall}
</div>
</div>`;
}
function renderInstalledPlugins() {
const grid = $("#plugin-installed-grid");
const empty = $("#plugin-installed-empty");
$("#plugin-installed-count").textContent = installedPlugins.length ? `(${installedPlugins.length})` : "";
if (!installedPlugins.length) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "还没有安装任何插件。";
return;
}
empty.classList.add("hidden");
grid.innerHTML = installedPlugins.map(installedPluginCardHTML).join("");
grid.querySelectorAll("[data-uninstall]").forEach((btn) =>
btn.addEventListener("click", () => uninstallPlugin(btn))
);
}
async function loadInstalledPlugins() {
try { installedPlugins = await apic.pluginInstalled(); }
catch { installedPlugins = []; }
renderInstalledPlugins();
}
async function uninstallPlugin(btn) {
let info;
try { info = JSON.parse(btn.dataset.uninstall); } catch { return; }
if (!confirm(`确认卸载插件「${info.label || info.name}」?\n\n将删除 ~/.codex/${info.rel} 以及对应工具箱仓库实体,此操作不可恢复。`)) return;
busy(btn, true, "卸载中…");
try {
await apic.pluginUninstall(info.rel);
toast(`已卸载 ${info.name} ✅`, "ok");
loadInstalledPlugins();
} catch (e) {
toast(`卸载失败:${e.message || e}`, "err");
busy(btn, false);
}
}
async function loadPlugins() {
const grid = $("#plugin-grid");
const empty = $("#plugin-empty");
empty.classList.add("hidden");
grid.innerHTML = '<div class="empty"><div class="spinner"></div>正在加载插件…</div>';
try {
const cat = await apic.pluginCatalog();
pluginData = (cat && cat.plugins) || [];
pluginLoaded = true;
renderPlugins();
loadInstalledPlugins();
} catch (e) {
pluginData = [];
pluginLoaded = true;
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.innerHTML = "插件市场即将上线,敬请期待 —— tokendun 官方自研插件将陆续上架。";
loadInstalledPlugins();
}
}
$("#plugin-search").addEventListener("input", renderPlugins);
$("#plugin-refresh").addEventListener("click", loadPlugins);
document.querySelector('.tab[data-tab="plugins"]').addEventListener("click", () => {
if (!pluginLoaded) loadPlugins();
});
// ===========================================================================
// 设置 —— 密钥
// ===========================================================================
async function loadSecrets() {
try {
const gh = await apic.getSecret("github");
if (gh) $("#set-github").value = gh;
const ak = await apic.getSecret("adminKey");
if (ak) $("#set-admin").value = ak;
} catch { /* 忽略 */ }
}
$("#btn-save-admin").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "保存中…");
try {
await apic.setSecret("adminKey", $("#set-admin").value.trim());
toast("管理员密钥已保存 ✅", "ok");
} catch (e) {
toast(`保存失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#btn-save-secret").addEventListener("click", async (ev) => {
const val = $("#set-github").value.trim();
const btn = ev.currentTarget;
busy(btn, true, "保存中…");
try {
await apic.setSecret("github", val);
toast("已保存 ✅", "ok");
} catch (e) {
toast(`保存失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 强制清除本机 Codex —— 检测安装位置 + 一键清干净
// ===========================================================================
const KIND_LABEL = {
data: "数据目录",
bin: "可执行文件",
"npm-package": "npm 全局包",
};
function renderPurgeList(items) {
const box = $("#purge-list");
const actions = $("#purge-actions");
if (!items || !items.length) {
box.innerHTML =
'<p class="muted small" style="margin:6px 2px">未检测到本机安装的 Codex(可能未安装,或不在常见位置)。</p>';
actions.style.display = "none";
return;
}
box.innerHTML = items
.map((it, i) => {
const tag = KIND_LABEL[it.kind] || it.kind;
return `<label class="cl-item">
<input type="checkbox" data-purge="${i}" checked />
<div style="min-width:0">
<div class="cl-name">${esc(it.label)}</div>
<div class="cl-src" style="word-break:break-all">${esc(it.path)}</div>
</div>
<span class="cl-size">${esc(tag)}${it.isDir ? " · " + esc(it.sizeText) : ""}</span>
</label>`;
})
.join("");
box._items = items;
actions.style.display = "flex";
}
async function purgeScan(btn) {
busy(btn, true, "检测中…");
try {
const items = await apic.codexDetect();
renderPurgeList(items);
toast(items.length ? `检测到 ${items.length} 个位置` : "未检测到 Codex", items.length ? "ok" : "");
} catch (e) {
toast("检测失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
}
$("#btn-purge-scan").addEventListener("click", (ev) => purgeScan(ev.currentTarget));
$("#btn-purge-do").addEventListener("click", async (ev) => {
const box = $("#purge-list");
const items = box._items || [];
const paths = $$('#purge-list input[data-purge]')
.filter((el) => el.checked)
.map((el) => items[+el.dataset.purge]?.path)
.filter(Boolean);
if (!paths.length) {
toast("请先勾选要清除的位置", "err");
return;
}
const btn = ev.currentTarget;
busy(btn, true, "清除中…");
try {
const r = await apic.codexPurge(paths);
if (r.canceled) {
toast("已取消", "");
return;
}
const ok = r.results.filter((x) => x.ok).length;
const fail = r.results.length - ok;
toast(fail ? `清除完成:成功 ${ok},失败 ${fail}(详见运行日志)` : `已彻底清除 ${ok} 个位置 ✅ 可重新安装 Codex 了`, fail ? "err" : "ok");
await purgeScan(btn); // 刷新列表,确认已清空
} catch (e) {
toast("清除失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 会话记录 —— 浏览 / 搜索 / 阅读 / 导出 / 备份 / 导入
// ===========================================================================
let sessData = [];
let sessCurrent = null;
let sessLoaded = false;
let sessAllMode = false;
function sessUpdateScope() {
const el = $("#sess-scope");
if (!el) return;
const archived = sessData.filter((s) => s.archived).length;
const subagents = sessData.filter((s) => s.threadSource === "subagent").length;
if (sessAllMode) {
el.textContent = `全部记录 ${sessData.length} 条(归档 ${archived},子任务 ${subagents})`;
} else {
el.textContent = `当前会话 ${sessData.length} 条`;
}
}
function sessRender() {
const q = $("#sess-search").value.trim().toLowerCase();
const box = $("#sess-list");
const list = sessData.filter(
(s) =>
!q ||
(s.title || "").toLowerCase().includes(q) ||
(s.project || "").toLowerCase().includes(q) ||
(s.cwd || "").toLowerCase().includes(q)
);
if (!list.length) {
box.innerHTML = `<div class="empty">${sessData.length ? "没有匹配的对话" : "没有找到会话记录"}</div>`;
sessUpdateScope();
return;
}
box.innerHTML = list
.map(
(s) => {
const tags = [
s.archived ? '<span class="si-badge">归档</span>' : "",
s.threadSource === "subagent" ? '<span class="si-badge">子任务</span>' : "",
].filter(Boolean).join("");
return `
<div class="sess-item" data-idx="${sessData.indexOf(s)}">
<div class="si-title">${esc(s.title)}</div>
<div class="si-meta"><span>${esc(s.project || "—")}</span><span>${(s.ts || "").slice(0, 10)}</span>${tags}</div>
</div>`;
}
)
.join("");
box.querySelectorAll(".sess-item").forEach((el) =>
el.addEventListener("click", () => sessOpen(+el.dataset.idx, el))
);
sessUpdateScope();
}
async function sessOpen(idx, el) {
const item = sessData[idx];
sessCurrent = item;
$$(".sess-item").forEach((x) => x.classList.remove("active"));
if (el) el.classList.add("active");
$("#sess-empty").classList.add("hidden");
$("#sess-conv").classList.remove("hidden");
$("#sess-conv-title").textContent = item.title;
$("#sess-messages").innerHTML = `<div class="empty"><div class="spinner"></div>读取中…</div>`;
try {
const conv = await apic.sessionRead(item.path);
const html = conv.messages
.map((m) => {
const who = m.role === "user" ? "👤 用户" : m.role === "assistant" ? "🤖 Codex" : "🔧 工具";
return `<div class="msg ${m.role}"><span class="who">${who}</span><div class="bubble">${esc(m.text)}</div></div>`;
})
.join("");
$("#sess-messages").innerHTML = html || `<div class="empty">这条对话没有可显示的消息</div>`;
} catch (e) {
$("#sess-messages").innerHTML = `<div class="empty">读取失败:${esc(e.message || e)}</div>`;
}
}
async function sessLoad(opts = {}) {
if (Object.prototype.hasOwnProperty.call(opts, "all")) sessAllMode = !!opts.all;
const box = $("#sess-list");
box.innerHTML = `<div class="empty"><div class="spinner"></div>加载中…</div>`;
try {
sessData = await apic.sessionsList(
sessAllMode
? { includeSubagent: true, includeArchived: true }
: { includeSubagent: false, includeArchived: false }
);
sessLoaded = true;
sessCurrent = null;
$("#sess-empty").classList.remove("hidden");
$("#sess-conv").classList.add("hidden");
sessRender();
if (opts.toast) toast(`${sessAllMode ? "已获取全部记录" : "已刷新"}:${sessData.length} 条`, "ok");
} catch (e) {
box.innerHTML = `<div class="empty">加载失败:${esc(e.message || e)}</div>`;
sessUpdateScope();
}
}
$("#sess-search").addEventListener("input", sessRender);
$("#sess-refresh").addEventListener("click", sessLoad);
$("#sess-fetch-all").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "读取中…");
try {
await sessLoad({ all: true, toast: true });
} finally {
busy(btn, false);
}
});
// 会话记录已改为从「插件市场」进入,懒加载由 [data-open-panel] 处理
$("#sess-export").addEventListener("click", async (ev) => {
if (!sessCurrent) return;
const btn = ev.currentTarget;
busy(btn, true, "导出中…");
try {
const r = await apic.sessionExport(sessCurrent);
if (!r.canceled) toast("已导出 ✅", "ok");
} catch (e) {
toast(`导出失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#sess-backup").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "备份中…");
try {
const r = await apic.sessionsBackup();
if (!r.canceled) toast(`已备份 ${r.count} 条会话 ✅`, "ok");
} catch (e) {
toast(`备份失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#sess-import").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "导入中…");
try {
const r = await apic.sessionsImport();
if (!r.canceled) {
toast(`导入完成:新增 ${r.added},跳过 ${r.skipped}`, "ok");
sessLoad();
}
} catch (e) {
toast(`导入失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
apic.onProviderSyncProgress((p) => {
const box = $("#sync-progress");
const fill = $("#sync-progress-fill");
const label = $("#sync-progress-label");
box.classList.remove("hidden");
if (p.phase === "完成") {
fill.style.width = "100%";
label.textContent = "完成 ✅";
} else if (p.total > 0) {
const pct = Math.round((p.done / p.total) * 100);
fill.style.width = pct + "%";
label.textContent = `${p.phase} ${p.done}/${p.total}(${pct}%)`;
} else {
fill.style.width = "8%";
label.textContent = p.phase + "…";
}
});
$("#sync-btn").addEventListener("click", async (ev) => {
const st = await apic.providerSyncStatus();
if (st.running) {
toast("请先完全退出 Codex(Cmd+Q)再同步", "err");
return;
}
if (!confirm(`将把 sessions 与 archived_sessions 的全部历史对话同步到当前账号(provider:${st.target}),使其在 Codex 侧栏重新显示。\n\n开始前会自动备份,可还原。确认继续?`)) return;
const btn = ev.currentTarget;
busy(btn, true, "同步中…");
$("#sync-progress").classList.remove("hidden");
$("#sync-progress-fill").style.width = "0%";
try {
const r = await apic.providerSync();
$("#sync-progress-fill").style.width = "100%";
const dbChanged = (r.ueRows || 0) + (r.provRows || 0) + (r.insertedRows || 0) + (r.projectRows || 0) + (r.projectlessRemoved || 0);
const changedMsg = r.changedFiles > 0 || dbChanged > 0
? `文件 ${r.changedFiles || 0} 条,数据库/项目索引 ${dbChanged} 项`
: `全部已是最新,无需改动`;
$("#sync-progress-label").textContent = `完成 ✅ 共 ${r.totalConversations} 条会话,${changedMsg}`;
const tip = r.changedFiles > 0 || dbChanged > 0
? `同步完成 ✅ ${changedMsg}。重新打开 Codex 即可看到`
: `全部 ${r.totalConversations} 条会话之前已同步过,本次无需改动 ✅`;
toast(tip, "ok");
setTimeout(() => $("#sync-progress").classList.add("hidden"), 4000);
} catch (e) {
$("#sync-progress").classList.add("hidden");
toast(`同步失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 会话记忆 —— 状态 / 项目开关 / 历史摘要检索
// ===========================================================================
let memStatus = null;
let memLoaded = false;
function memRenderStatus(st) {
memStatus = st;
const badge = $("#mem-state");
badge.textContent = st.enabled ? "已开启" : "已关闭";
badge.className = "pc-badge " + (st.enabled ? "skill" : "");
$("#mem-engine").textContent = st.engine + (st.pending ? ` · 待处理 ${st.pending}` : "");
$("#mem-count").textContent = `记忆库共 ${st.total} 条摘要`;
const tg = $("#mem-toggle");
tg.textContent = st.enabled ? "关闭记忆" : "开启记忆";
tg.className = "btn " + (st.enabled ? "ghost" : "primary");
// 项目列表(开关 + 打开 HANDOFF)
const box = $("#mem-projects");
if (!st.projects.length) {
box.innerHTML = `<div class="empty">还没有任何项目的会话被摘要。去 Codex 里聊一会儿,静默 ${st.idleMinutes} 分钟后这里就有了。</div>`;
} else {
box.innerHTML = st.projects
.map(
(p) => `
<div class="cl-item">
<input type="checkbox" ${p.enabled ? "checked" : ""} data-mem-proj="${esc(p.cwd)}" title="开/关该项目的 HANDOFF 写入" />
<div>
<div class="cl-name">${esc(p.project || "(未知)")}</div>
<div class="cl-src">${esc(p.cwd)}</div>
</div>
<div class="btn-group" style="margin-left:auto">
<span class="muted small">${p.count} 条 · ${(p.last || "").slice(0, 10)}</span>
${p.hasHandoff ? `<button class="btn small ghost" data-mem-open="${esc(p.cwd)}">HANDOFF ↗</button>` : ""}
</div>
</div>`
)
.join("");
box.querySelectorAll("[data-mem-proj]").forEach((el) =>
el.addEventListener("change", async () => {
try {
memRenderStatus(await apic.memoryToggleProject(el.dataset.memProj, el.checked));
toast(el.checked ? "已开启该项目的记忆写入 ✅" : "已关闭该项目的记忆写入", "ok");
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
}
})
);
box.querySelectorAll("[data-mem-open]").forEach((el) =>
el.addEventListener("click", async () => {
try {
await apic.memoryOpenHandoff(el.dataset.memOpen);
} catch (e) {
toast(e.message || String(e), "err");
}
})
);
}
// 项目筛选下拉(保留当前选中)
const sel = $("#mem-project-filter");
const cur = sel.value;
sel.innerHTML =
'<option value="">全部项目</option>' +
st.projects.map((p) => `<option value="${esc(p.cwd)}">${esc(p.project || p.cwd)}</option>`).join("");
sel.value = cur;
}
async function memRenderList() {
const box = $("#mem-list");
const empty = $("#mem-empty");
empty.classList.add("hidden");
try {
const rows = await apic.memoryList({
project: $("#mem-project-filter").value,
q: $("#mem-search").value.trim(),
limit: 100,
});
if (!rows.length) {
box.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "没有匹配的摘要。";
return;
}
box.innerHTML = rows
.map((r) => {
const when = (r.ts || "").slice(0, 16).replace("T", " ");
const dec = r.decisions.length
? `<div class="mem-line"><b>决策</b>${r.decisions.map((d) => esc(d)).join(" · ")}</div>`
: "";
const next = r.next_steps.length
? `<div class="mem-line"><b>下一步</b>${r.next_steps.map((d) => esc(d)).join(" · ")}</div>`
: "";
const files = r.files.length ? `<div class="mem-line"><b>文件</b>${esc(r.files.join("、"))}</div>` : "";
return `
<div class="mem-item">
<div class="mem-top">
<span class="mem-topic">${esc(r.topic)}</span>
<span class="pc-badge">${esc(r.project || "—")}</span>
<span class="muted small">${when}</span>
<span class="muted small">${r.engine === "local" ? "本地提取" : "LLM"}</span>
<div class="mem-actions">
<button class="btn small ghost" data-mem-redo="${esc(r.rollout_path)}">重新摘要</button>
<button class="btn small ghost" data-mem-del="${esc(r.id)}">删除</button>
</div>
</div>
<div class="mem-sum">${esc(r.summary)}</div>
${dec}${next}${files}
</div>`;
})
.join("");
box.querySelectorAll("[data-mem-redo]").forEach((el) =>
el.addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "摘要中…");
try {
await apic.memoryResummarize(el.dataset.memRedo);
toast("已重新摘要 ✅", "ok");
} catch (e) {
toast("摘要失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
})
);
box.querySelectorAll("[data-mem-del]").forEach((el) =>
el.addEventListener("click", async () => {
if (!confirm("删除这条摘要?(不影响原始会话记录)")) return;
try {
await apic.memoryDelete(el.dataset.memDel);
} catch (e) {
toast("删除失败:" + (e.message || e), "err");
}
})
);
} catch (e) {
box.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "加载失败:" + (e.message || e);
}
}
async function memLoad() {
memLoaded = true;
try {
memRenderStatus(await apic.memoryStatus());
await memRenderList();
} catch (e) {
toast("会话记忆读取失败:" + (e.message || e), "err");
}
}
// 会话记忆已改为从「插件市场」进入,懒加载由 [data-open-panel] 处理
$("#mem-refresh").addEventListener("click", memLoad);
$("#mem-search").addEventListener("input", memRenderList);
$("#mem-project-filter").addEventListener("change", memRenderList);
$("#mem-toggle").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "处理中…");
try {
memRenderStatus(await apic.memorySetEnabled(!(memStatus && memStatus.enabled)));
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
apic.onMemoryChanged(() => {
if (memLoaded) memLoad();
});
// ===========================================================================
// 说明弹窗 —— 使用说明 / 版权声明
// ===========================================================================
const MODAL_CONTENT = {
usage: {
title: "📖 使用说明",
html: `
<h4>插件是「命令包」,不是单个命令</h4>
<p>一个插件(如 <strong>creative-production</strong>)里通常包含多个技能命令。安装后不是输入插件名,而是输入里面的具体命令,比如 <code>/explore</code>、<code>/ads-explorer</code>。</p>
<h4>三步开始使用</h4>
<div class="step"><span class="n">1</span><span>在「插件市场」搜索并点 <strong>安装</strong>,或在「备份与还原 → 从 GitHub 拉取还原」里拉取仓库后还原到本机。</span></div>
<div class="step"><span class="n">2</span><span><strong>重启 Codex</strong>,让新装的插件生效。</span></div>
<div class="step"><span class="n">3</span><span>在 Codex 输入框打 <code>/</code>,再输入命令关键词(如 <code>ads</code>、<code>scan</code>)即可定位并运行对应功能。</span></div>
<h4>备份 / 分享给他人</h4>
<ul>
<li>「从本机导出 / 上传」可把你本机的插件打包推送到 GitHub 仓库(<strong>凭证文件已自动排除</strong>)。</li>
<li>把生成的仓库链接发给对方,对方在本 app 用「从 GitHub 拉取还原」即可一键装好。</li>
</ul>
<h4>每个插件具体能干嘛?</h4>
<p>点插件卡片可查看它的功能说明;各插件仓库首页的 README 里也有完整的命令清单与示例。</p>
`,
},
notice: {
title: "📄 版权声明 / NOTICE",
html: `
<h4>内容归属</h4>
<p>本市场收录并可分发的插件为 <strong>各自作者(含 OpenAI Codex 官方插件)</strong> 的作品,其源代码及相关知识产权归 <strong>原始著作权人</strong> 所有。本工具仅提供备份、分享与在已获授权环境中的还原,<strong>不修改插件源代码</strong>。</p>
<h4>使用范围</h4>
<ul>
<li>请在<strong>已获得相应使用授权</strong>的前提下备份与还原插件。</li>
<li>使用时请遵守 OpenAI 服务条款及各插件的原始许可条款。</li>
<li>如不确定是否有权使用某插件,请以其官方渠道说明为准。</li>
</ul>
<h4>凭证安全</h4>
<p>导出 / 上传时,<strong>凭证文件(auth.json、config.toml、history 等)已自动排除</strong>;GitHub Token 仅经系统安全存储(safeStorage)加密后保存在本机。</p>
<h4>免责声明</h4>
<p>本工具及其分发的备份按「<strong>现状(AS IS)</strong>」提供,不附带任何明示或默示担保。作者不对因使用而产生的任何后果负责。本声明不构成对第三方代码的任何再授权。</p>
`,
},
};
const modalOverlay = $("#modal-overlay");
function openModal(key) {
const c = MODAL_CONTENT[key];
if (!c) return;
$("#modal-title").textContent = c.title;
$("#modal-body").innerHTML = c.html;
const m = modalOverlay.querySelector(".modal");
if (m) m.classList.remove("cv-media-modal");
modalOverlay.classList.remove("hidden");
}
function closeModal() {
modalOverlay.classList.add("hidden");
const m = modalOverlay.querySelector(".modal");
if (m && m.classList.contains("cv-media-modal")) $("#modal-body").innerHTML = "";
if (m) m.classList.remove("cv-media-modal");
}
$("#open-usage").addEventListener("click", () => openModal("usage"));
$("#open-notice").addEventListener("click", () => openModal("notice"));
$("#modal-close").addEventListener("click", closeModal);
modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModal(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modalOverlay.classList.contains("hidden")) closeModal();
});
// ===========================================================================
// 路由中转
// ===========================================================================
let routerState = null;
let grpEditing = null;
function renderRouter(st) {
routerState = st;
$("#router-addr").textContent = st.address;
const badge = $("#router-state");
badge.textContent = st.running ? "运行中" : "未启动";
badge.className = "pc-badge " + (st.running ? "skill" : "");
$("#router-toggle").textContent = st.running ? "停止" : "启动";
$("#router-key").value = st.routerKey;
if (document.activeElement !== $("#router-port")) $("#router-port").value = st.port;
const claudeBtn = $("#router-apply-claude");
if (claudeBtn) {
claudeBtn.textContent = st.claudeApplied ? "取消接入 Claude Code" : "接入 Claude Code";
claudeBtn.classList.toggle("ghost", !!st.claudeApplied);
}
const box = $("#router-groups");
if (!st.groups.length) {
box.innerHTML = `<div class="empty">还没有分组,点右上「+ 新增分组」添加一个中转 API</div>`;
return;
}
box.innerHTML = st.groups
.map((g) => {
const badges =
(g.id === st.activeId ? '<span class="pc-badge skill">当前</span>' : "") +
(g.isDefault ? ' <span class="pc-badge">默认·不可删</span>' : "");
const actions = g.isDefault
? '<span class="muted small">切回这里 = 直连你的账号</span>'
: `<button class="btn small ghost" data-edit="${g.id}">编辑</button><button class="btn small ghost" data-del="${g.id}">删除</button>`;
return `
<div class="cl-item">
<input type="radio" name="active-grp" ${g.id === st.activeId ? "checked" : ""} data-active="${g.id}" />
<div>
<div class="cl-name">${esc(g.name)} ${badges}${g.codexImage ? ' <span class="agent-badge mm">🎨 生图</span>' : ""}</div>
<div class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
</div>
<div class="btn-group" style="margin-left:auto">${actions}</div>
</div>`;
})
.join("");
box.querySelectorAll("[data-active]").forEach((el) =>
el.addEventListener("change", async () => {
const r = await apic.routerSetActive(el.dataset.active);
renderRouter(r);
if (r._needsRestart) toast("已切换,重启 Codex 后生效(配置有变)", "ok");
else if (r._mode === "direct") toast("已切回默认(直连你的账号)✅", "ok");
else toast("已秒切分组 ✅ 无需重启 Codex", "ok");
})
);
box.querySelectorAll("[data-edit]").forEach((el) =>
el.addEventListener("click", () => openGrpModal(st.groups.find((g) => g.id === el.dataset.edit)))
);
box.querySelectorAll("[data-del]").forEach((el) =>
el.addEventListener("click", async () => {
if (confirm("删除这个分组?")) renderRouter(await apic.routerDeleteGroup(el.dataset.del));
})
);
}
async function loadRouter() {
try {
renderRouter(await apic.routerStatus());
} catch (e) {
toast("路由状态读取失败:" + (e.message || e), "err");
}
}
function openGrpModal(g) {
grpEditing = g || null;
$("#grp-modal-title").textContent = g ? "编辑分组" : "新增分组";
$("#grp-name").value = g ? g.name : "";
$("#grp-mode").value = g && g.mode ? g.mode : "responses";
$("#grp-url").value = g ? g.baseUrl : "";
$("#grp-key").value = ""; // 密钥不回显,留空则不改
$("#grp-key").placeholder = g && g.hasKey ? "(已保存,留空则不修改)" : "sk-…";
$("#grp-model").value = g ? g.model || "" : "";
$("#grp-codex-image").checked = !!(g && g.codexImage);
$("#grp-ctx").value = g && g.contextWindow ? g.contextWindow : "";
const sel = $("#grp-model-list");
sel.classList.add("hidden");
sel.innerHTML = '<option value="">— 从下拉选择模型 —</option>';
$("#grp-model-hint").textContent = "";
$("#grp-modal").classList.remove("hidden");
}
function closeGrpModal() {
$("#grp-modal").classList.add("hidden");
}
document.querySelectorAll("[data-ctx]").forEach((b) =>
b.addEventListener("click", () => {
$("#grp-ctx").value = b.dataset.ctx;
})
);
$("#grp-add").addEventListener("click", () => openGrpModal(null));
$("#grp-modal-close").addEventListener("click", closeGrpModal);
$("#grp-modal").addEventListener("click", (e) => {
if (e.target === $("#grp-modal")) closeGrpModal();
});
$("#grp-save").addEventListener("click", async () => {
const name = $("#grp-name").value.trim();
const baseUrl = $("#grp-url").value.trim();
if (!name || !baseUrl) {
toast("名称和 API 地址必填", "err");
return;
}
const group = {
name,
baseUrl,
model: $("#grp-model").value.trim(),
mode: $("#grp-mode").value,
contextWindow: $("#grp-ctx").value.trim(),
codexImage: $("#grp-codex-image").checked,
};
if (grpEditing) group.id = grpEditing.id;
const key = $("#grp-key").value;
if (key) group.key = key;
else if (grpEditing) group.key = undefined; // 留空且是编辑 → 保留原密钥(主进程 merge 时不覆盖)
// 编辑留空时不传 key 字段,避免覆盖
if (grpEditing && !key) delete group.key;
try {
renderRouter(await apic.routerUpsertGroup(group));
closeGrpModal();
toast("已保存分组 ✅", "ok");
} catch (e) {
toast("保存失败:" + (e.message || e), "err");
}
});
$("#grp-fetch-models").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
const baseUrl = $("#grp-url").value.trim();
const key = $("#grp-key").value; // 留空且是编辑 → 用已保存的密钥(传 groupId)
const hint = $("#grp-model-hint");
if (!baseUrl) {
hint.textContent = "请先填 API 地址";
return;
}
busy(btn, true, "获取中…");
hint.textContent = "";
try {
const models = await apic.routerFetchModels(baseUrl, key, grpEditing ? grpEditing.id : null);
const sel = $("#grp-model-list");
sel.innerHTML =
'<option value="">— 从下拉选择模型 —</option>' +
models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("");
// 若当前已有模型值,选中它
if ($("#grp-model").value) sel.value = $("#grp-model").value;
sel.classList.remove("hidden");
hint.textContent = `共 ${models.length} 个模型,选一个或保持手填`;
} catch (e) {
hint.textContent = "获取失败:" + (e.message || e) + "(可手动填模型)";
} finally {
busy(btn, false);
}
});
$("#grp-model-list").addEventListener("change", (e) => {
if (e.target.value) $("#grp-model").value = e.target.value;
});
$("#router-toggle").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "处理中…");
try {
renderRouter(routerState.running ? await apic.routerStop() : await apic.routerStart());
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#router-save-port").addEventListener("click", async () => {
try {
renderRouter(await apic.routerSetPort($("#router-port").value));
toast("端口已更新", "ok");
} catch (e) {
toast("改端口失败:" + (e.message || e), "err");
}
});
$("#router-apply").addEventListener("click", async (ev) => {
if (!confirm("将修改 Codex 的 config.toml,指向本地路由(会自动备份)。\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认?")) return;
const btn = ev.currentTarget;
busy(btn, true, "配置中…");
try {
const r = await apic.routerApplyCodex();
renderRouter(r.status);
toast("已配置到 Codex ✅ 重启 Codex 后即可通过本地路由,之后切分组无需重启", "ok");
} catch (e) {
toast("配置失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#router-apply-claude").addEventListener("click", async (ev) => {
const applied = routerState && routerState.claudeApplied;
if (applied) {
if (!confirm("取消接入:将从 ~/.claude/settings.json 移除 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN(还原成你原来的值)。\n\n确认?")) return;
const btn = ev.currentTarget;
busy(btn, true, "还原中…");
try {
const r = await apic.routerRestoreClaude();
renderRouter(r.status);
toast("已取消接入 Claude Code ✅", "ok");
} catch (e) {
toast("还原失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
return;
}
const hasAnthropicGroup = routerState && routerState.groups.some((g) => !g.isDefault && g.hasKey);
if (!hasAnthropicGroup && !confirm("还没有可用的中转分组。先去下面新增一个 Anthropic 格式的中转(base_url 填站点根、模式选 Responses 直连、填密钥),再回来接入。\n\n仍要继续?")) return;
if (!confirm("将修改 ~/.claude/settings.json,把 ANTHROPIC_BASE_URL 指向本地路由(会自动备份)。\n须搭配 Anthropic 格式的中转分组使用。\n\n确认?")) return;
const btn = ev.currentTarget;
busy(btn, true, "接入中…");
try {
const r = await apic.routerApplyClaude();
renderRouter(r.status);
toast("已接入 Claude Code ✅ 新开 claude 会话即走本地路由,之后切分组无需重启", "ok");
} catch (e) {
toast("接入失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
document.querySelector('.tab[data-tab="router"]').addEventListener("click", loadRouter);
apic.onRouterChanged((st) => renderRouter(st));
// ===========================================================================
// 在线更新
// ===========================================================================
async function loadVersion() {
try {
$("#app-version").textContent = "v" + (await apic.appVersion());
} catch {}
}
// 显示"发现新版本 + 前往下载"链接(Mac 半自动更新)
const RELEASE_FALLBACK = "https://update.tokendun.cc/mhgmh/codex-market-app/releases";
const updateModal = $("#update-modal");
let updateMandatory = false;
// 更新弹窗:显示版本 + 更新内容。kind="hot" 走应用内热更(下载小包→重启);kind="full" 打开下载页。
function showUpdateModal({ version, notes, mandatory, url, kind }) {
const link = url || RELEASE_FALLBACK;
const isHot = kind === "hot";
updateMandatory = !!mandatory;
$("#update-title").textContent = mandatory
? "重要更新(必须升级)"
: (isHot ? "有可在线更新的新版本" : "发现新版本");
$("#update-ver").textContent =
`新版本 v${version} · 当前 v${($("#app-version").textContent || "").replace(/^v/, "") || "?"}` +
(isHot ? " · 在线更新(几 MB,无需重装)" : "");
const badge = $("#update-badge");
badge.textContent = mandatory ? "强制更新" : (isHot ? "在线更新" : "推荐更新");
badge.className = "update-badge " + (mandatory ? "force" : "normal");
$("#update-notes").textContent = notes || "";
const foot = $("#update-foot");
function renderButtons(goLabel) {
foot.innerHTML =
(mandatory
? `<button class="btn danger" id="upd-quit">退出软件</button>`
: `<button class="btn ghost" id="upd-later">稍后再说</button>`) +
`<button class="btn primary" id="upd-go">${goLabel}</button>`;
if (mandatory) $("#upd-quit").addEventListener("click", () => apic.quitApp());
else $("#upd-later").addEventListener("click", closeUpdateModal);
$("#upd-go").addEventListener("click", onGo);
}
async function onGo(ev) {
if (!isHot) { apic.openExternal(link); return; }
// 热更:下载 → 校验 → 应用 → 重启
const go = ev ? ev.currentTarget : $("#upd-go");
go.disabled = true;
const later = $("#upd-later"); if (later) later.disabled = true;
const stop = apic.onHotProgress((p) => { go.textContent = `下载中 ${p}%`; });
try {
await apic.hotApply();
stop && stop();
updateMandatory = true; // 应用后锁定,引导重启
$("#update-notes").textContent = "更新已下载并校验完成 ✅ 重启后生效。";
foot.innerHTML = `<button class="btn primary" id="upd-restart">立即重启</button>`;
$("#upd-restart").addEventListener("click", () => apic.hotRestart());
} catch (e) {
stop && stop();
go.disabled = false;
const later2 = $("#upd-later"); if (later2) later2.disabled = false;
toast(`更新失败:${e.message || e}`, "err");
go.textContent = "重试";
}
}
renderButtons("立即更新");
updateModal.classList.remove("hidden");
}
function closeUpdateModal() {
if (updateMandatory) return; // 强制更新不允许关闭
updateModal.classList.add("hidden");
}
// 一般更新:点背景关闭;强制更新:点背景无效
updateModal.addEventListener("click", (e) => {
if (e.target === updateModal) closeUpdateModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !updateModal.classList.contains("hidden")) closeUpdateModal();
});
$("#btn-check-update").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
const info = $("#update-info");
busy(btn, true, "检查中…");
info.textContent = "";
try {
// 先查热更(gitea,国内可达)
const h = await apic.hotCheck();
if (h && h.canHot) {
info.textContent = `发现可在线更新 v${h.contentVersion}`;
showUpdateModal({ version: h.contentVersion, notes: h.notes, mandatory: h.mandatory, kind: "hot" });
return;
}
// 否则查完整安装包
const r = await apic.checkUpdate();
if (r.dev) info.textContent = "(开发版不检查更新)";
else if (r.network) {
info.innerHTML = `⚠️ ${esc(r.error)} <a class="link" id="dl-manual">前往下载页 ↗</a>`;
const a = $("#dl-manual");
if (a) a.addEventListener("click", () => apic.openExternal(r.url || RELEASE_FALLBACK));
}
else if (r.error) info.textContent = "检查失败:" + r.error;
else if (r.hasNew) {
info.textContent = `发现新版本 v${r.version}`;
showUpdateModal({ version: r.version, notes: r.notes, mandatory: r.mandatory, url: r.url, kind: "full" });
}
else info.textContent = "已是最新版本 ✅";
} catch (e) {
info.textContent = "检查失败:" + (e.message || e);
} finally {
busy(btn, false);
}
});
apic.onUpdateStatus((s) => {
const info = $("#update-info");
if (s.state === "hot-available") {
info.textContent = `发现可在线更新 v${s.version}`;
showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, kind: "hot" });
}
else if (s.state === "available-manual") {
info.textContent = `发现新版本 v${s.version}`;
showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, url: s.url, kind: "full" });
}
else if (s.state === "available") info.textContent = `发现新版本 ${s.version},下载中…`;
else if (s.state === "downloading") info.textContent = `下载新版本 ${s.percent}%…`;
else if (s.state === "downloaded") info.textContent = `新版本 ${s.version} 已就绪,退出软件即自动更新`;
else if (s.state === "none") info.textContent = "已是最新版本 ✅";
else if (s.state === "error") info.textContent = "更新检查失败:" + (s.message || "");
});
// ===========================================================================
// 全局 / 系统代理
// ===========================================================================
function renderProxy(st) {
const badge = $("#proxy-state");
if (!badge) return;
const detail = $("#proxy-state-detail");
const stopBtn = $("#proxy-stop");
const gBtn = $("#proxy-start-global");
const sBtn = $("#proxy-start-system");
if (st.mode === "global") {
badge.textContent = "全局代理运行中";
badge.className = "pc-badge on";
detail.textContent = "整机 TUN · 所有程序流量走代理";
} else if (st.mode === "system") {
badge.textContent = "系统代理运行中";
badge.className = "pc-badge on";
detail.textContent = `本地中转 127.0.0.1:${st.localPort}`;
} else {
badge.textContent = "未开启";
badge.className = "pc-badge";
detail.textContent = st.proxy
? `已配置 ${st.proxy.host}:${st.proxy.port}${st.proxy.hasAuth ? " · 需密码" : ""}`
: "未配置代理";
}
const running = st.mode !== "off";
stopBtn.classList.toggle("hidden", !running);
const disabled = !st.hasConfig || !st.binOk;
gBtn.disabled = disabled;
sBtn.disabled = disabled;
gBtn.classList.toggle("active", st.mode === "global");
sBtn.classList.toggle("active", st.mode === "system");
if (!st.binOk) detail.textContent = "⚠️ 缺少 sing-box 引擎文件,请重装软件";
}
async function loadProxy() {
try {
const cfg = await apic.proxyGetConfig();
if (cfg && cfg.proxyStr && !$("#proxy-str").value) $("#proxy-str").value = cfg.proxyStr;
renderProxy(await apic.proxyStatus());
} catch (e) {
toast("代理状态读取失败:" + (e.message || e), "err");
}
}
$("#proxy-save").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "保存中…");
try {
await apic.proxySetConfig({ proxyStr: $("#proxy-str").value.trim() });
renderProxy(await apic.proxyStatus());
toast("已保存 ✅", "ok");
} catch (e) {
toast("保存失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#proxy-test").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
const out = $("#proxy-test-out");
busy(btn, true, "测试中…");
out.textContent = "正在通过代理请求出口 IP…";
try {
await apic.proxySetConfig({ proxyStr: $("#proxy-str").value.trim() });
const r = await apic.proxyTest();
out.innerHTML = `✅ 代理可用 · 出口 IP:<b>${esc(r.ip)}</b>`;
} catch (e) {
out.textContent = "❌ 测试失败:" + (e.message || e);
} finally {
busy(btn, false);
}
});
async function startProxy(mode, btn) {
busy(btn, true, mode === "global" ? "启动全局中…" : "启动系统代理中…");
try {
await apic.proxySetConfig({ proxyStr: $("#proxy-str").value.trim() });
renderProxy(await apic.proxyStart(mode));
toast(mode === "global" ? "全局代理已开启(整机)✅" : "系统代理已开启 ✅", "ok");
} catch (e) {
toast("启动失败:" + (e.message || e), "err");
try {
renderProxy(await apic.proxyStatus());
} catch {}
} finally {
busy(btn, false);
}
}
$("#proxy-start-global").addEventListener("click", (ev) => startProxy("global", ev.currentTarget));
$("#proxy-start-system").addEventListener("click", (ev) => startProxy("system", ev.currentTarget));
$("#proxy-stop").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "关闭中…");
try {
renderProxy(await apic.proxyStop());
toast("代理已关闭,网络已还原", "ok");
} catch (e) {
toast("关闭失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
document.querySelector('.tab[data-tab="proxy"]').addEventListener("click", loadProxy);
apic.onProxyChanged((st) => renderProxy(st));
// ============================================================
// 创意画布(Drawflow 节点画布)
// 懒初始化:首次进入 canvas 标签才 new Drawflow + start(),避免容器无尺寸时初始化。
// 只经 apic.canvasStatus() / apic.canvasRun(kind, params) 与主进程通信(见 CANVAS_SPEC.md)。
// ============================================================
const CANVAS = {
editor: null, // Drawflow 实例(首次进入才创建)
inited: false, // 是否已初始化
status: null, // 最近一次 canvasStatus 结果
outputs: {}, // 各节点产物缓存:{ [nodeId]: { text?, image? } }
addCount: 0, // 添加计数,用于错位摆放避免节点重叠
groups: [], // 中转分组列表 [{id,name,model,host}]
activeGroupId: "", // 当前激活分组 id(新节点默认选中)
runSeq: 0, // 运行序号,配合 nodeId 组成唯一 runId
runMap: {}, // runId → nodeId 映射(流式渐进预览用)
refImages: {}, // 图片节点参考图缓存:{ [nodeId]: dataURL }(图生图,运行时传图床换外链)
videoMaterials: {}, // 视频节点素材缓存:{ [nodeId]: { first?, last?, refs:[] } }(dataURL,运行时传图床)
progressBound: false, // onCanvasProgress 是否已订阅(幂等)
};
// 4 类节点的元信息:标题 / 图标 / 输入端口数 / 输出端口数 / 角标
const CV_NODES = {
text: { title: "文本 / 提示词", icon: "📝", inputs: 0, outputs: 1, tag: "" },
image: { title: "文生图", icon: "🖼️", inputs: 1, outputs: 1, tag: "" },
video: { title: "Seedance 2.0", icon: "🎬", inputs: 1, outputs: 0, tag: "2.0" },
grokVideo: { title: "Grok 视频", icon: "🎞️", inputs: 1, outputs: 0, tag: "Grok" },
tts: { title: "TTS 配音", icon: "🔊", inputs: 1, outputs: 0, tag: "" },
};
// 各类节点的表单主体(Drawflow 以 HTML 字符串注入到 .drawflow_content_node)
function cvNodeBody(kind) {
if (kind === "text") {
return `
<textarea class="cv-field cv-field-prompt" rows="4" placeholder="输入文本 / 提示词,连线到下游节点作为输入…"></textarea>
<div class="cv-node-hint">作为输入源,实时输出上面的文本</div>`;
}
if (kind === "image") {
return `
<textarea class="cv-field cv-field-prompt" rows="3" placeholder="提示词(可留空,将取上游文本节点)"></textarea>
<div class="cv-refimg-row">
<label class="cv-refimg-pick" title="上传参考图(图生图)">📎 参考图
<input type="file" class="cv-field-refimg" accept="image/*" hidden />
</label>
<img class="cv-refimg-thumb hidden" alt="参考图" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
<button type="button" class="cv-refimg-clear hidden" title="移除参考图">✕</button>
<span class="cv-node-hint">选填:运行时自动传图床换外链,作图生图参考</span>
</div>
<select class="cv-field cv-field-group" title="中转分组"></select>
<div class="cv-model-row">
<input class="cv-field cv-field-model" placeholder="填图像模型,如 flux-pro / dall-e-3" />
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
</div>
<datalist class="cv-node-datalist"></datalist>
<div class="cv-row2">
<select class="cv-field cv-field-ratio" title="比例">
<option value="1:1">1:1 方</option>
<option value="16:9">16:9 横</option>
<option value="9:16">9:16 竖</option>
<option value="4:3">4:3</option>
<option value="3:4">3:4</option>
<option value="3:2">3:2</option>
</select>
<select class="cv-field cv-field-quality" title="清晰度">
<option value="1K">1K</option>
<option value="2K">2K</option>
<option value="4K">4K</option>
</select>
</div>
<details class="cv-adv"><summary>⚙️ 高级</summary>
<input class="cv-field cv-field-endpoint" placeholder="/v1/images/generations" />
<input class="cv-field cv-field-reffield" placeholder="参考图字段名(默认 image)" />
</details>
<button class="cv-run">运行</button>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>`;
}
if (kind === "video") {
return `
<select class="cv-field cv-field-vmode" title="模式">
<option value="first">图生视频 · 首帧图</option>
<option value="firstlast">首尾帧</option>
<option value="reference">多参考图</option>
<option value="video">参考视频</option>
<option value="text">文生视频(仅提示词)</option>
</select>
<textarea class="cv-field cv-field-prompt" rows="2" placeholder="运动 / 镜头提示词(可选)"></textarea>
<div class="cv-vmat cv-vmat-first">
<label class="cv-refimg-pick" title="上传首帧图">📎 首帧
<input type="file" class="cv-vmat-file" data-slot="first" accept="image/*" hidden />
</label>
<img class="cv-vmat-thumb hidden" data-slot="first" alt="首帧" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
<button type="button" class="cv-vmat-clear hidden" data-slot="first" title="移除">✕</button>
<input class="cv-field cv-vmat-url" data-slot="first" placeholder="或手填公网图片URL;留空用上游图" />
</div>
<div class="cv-vmat cv-vmat-last hidden">
<label class="cv-refimg-pick" title="上传尾帧图">📎 尾帧
<input type="file" class="cv-vmat-file" data-slot="last" accept="image/*" hidden />
</label>
<img class="cv-vmat-thumb hidden" data-slot="last" alt="尾帧" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
<button type="button" class="cv-vmat-clear hidden" data-slot="last" title="移除">✕</button>
<input class="cv-field cv-vmat-url" data-slot="last" placeholder="或手填尾帧公网URL" />
</div>
<div class="cv-vmat cv-vmat-refs hidden">
<label class="cv-refimg-pick" title="上传多张参考图">📎 参考图(可多选)
<input type="file" class="cv-vmat-file" data-slot="refs" accept="image/*" multiple hidden />
</label>
<button type="button" class="cv-vmat-clear hidden" data-slot="refs" title="清空">✕</button>
<div class="cv-vmat-refs-thumbs"></div>
<div class="cv-node-hint">可多选,运行时各自传图床换外链</div>
</div>
<div class="cv-vmat cv-vmat-refvideo hidden">
<input class="cv-field cv-vmat-url" data-slot="video" placeholder="参考视频公网URL(http(s) mp4)" />
<div class="cv-node-hint">图床不托管视频,请手填公网视频URL</div>
</div>
<select class="cv-field cv-field-group" title="中转分组"></select>
<div class="cv-model-row">
<input class="cv-field cv-field-model" placeholder="填视频模型,如 seedance / kling" />
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
</div>
<datalist class="cv-node-datalist"></datalist>
<div class="cv-row2">
<select class="cv-field cv-field-ratio" title="比例">
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="1:1">1:1</option>
<option value="4:3">4:3</option>
</select>
<select class="cv-field cv-field-vsize" title="分辨率">
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="480p">480p</option>
<option value="auto">自动</option>
</select>
</div>
<div class="cv-row2">
<select class="cv-field cv-field-duration" title="时长">
<option value="-1">时长 自动</option>
<option value="5">5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
</select>
<label class="cv-check"><input type="checkbox" class="cv-field-audio" /> 生成音频</label>
</div>
<details class="cv-adv"><summary>⚙️ 高级</summary>
<input class="cv-field cv-field-endpoint" placeholder="/v1/video/generations" />
<div class="cv-row2" style="margin-top:6px">
<label class="cv-check"><input type="checkbox" class="cv-field-watermark" /> 水印</label>
<label class="cv-check"><input type="checkbox" class="cv-field-camerafixed" /> 固定镜头</label>
</div>
<input class="cv-field cv-field-seed" type="number" placeholder="seed(可选,-1/空=随机)" />
</details>
<button class="cv-run">运行</button>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>`;
}
if (kind === "grokVideo") {
return `
<select class="cv-field cv-field-vmode" title="模式">
<option value="text">文生视频</option>
<option value="first">首帧图生视频</option>
</select>
<textarea class="cv-field cv-field-prompt" rows="3" placeholder="Grok 视频提示词(可连接上游文本节点)"></textarea>
<div class="cv-vmat cv-vmat-first hidden">
<label class="cv-refimg-pick" title="上传首帧图">📎 首帧
<input type="file" class="cv-vmat-file" data-slot="first" accept="image/*" hidden />
</label>
<img class="cv-vmat-thumb hidden" data-slot="first" alt="首帧" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
<button type="button" class="cv-vmat-clear hidden" data-slot="first" title="移除">✕</button>
<input class="cv-field cv-vmat-url" data-slot="first" placeholder="或手填公网图片URL;留空用上游图" />
</div>
<select class="cv-field cv-field-group" title="中转分组"></select>
<div class="cv-model-row">
<input class="cv-field cv-field-model" value="grok-imagine-video" placeholder="grok-imagine-video / grok-imagine-video-1.5" />
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
</div>
<datalist class="cv-node-datalist"></datalist>
<div class="cv-row2">
<select class="cv-field cv-field-vsize" title="分辨率">
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="480p">480p</option>
</select>
<select class="cv-field cv-field-duration" title="时长">
<option value="8">8s</option>
<option value="5">5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
</select>
</div>
<div class="cv-node-hint cv-grok-hint">1.5 的 1080p/15s 需要首帧图;完成前可能在 94% 停留较久</div>
<details class="cv-adv"><summary>⚙️ 高级</summary>
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos/generations" />
</details>
<button class="cv-run">运行</button>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>`;
}
if (kind === "tts") {
return `
<textarea class="cv-field cv-field-prompt" rows="3" placeholder="要配音的文本(可留空,取上游文本节点)"></textarea>
<select class="cv-field cv-field-group" title="中转分组"></select>
<div class="cv-model-row">
<input class="cv-field cv-field-model" placeholder="填 TTS 模型,如 tts-1(可留空)" />
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
</div>
<datalist class="cv-node-datalist"></datalist>
<div class="cv-row2">
<select class="cv-field cv-field-voice" title="音色">
<option value="alloy">alloy</option>
<option value="nova">nova</option>
<option value="shimmer">shimmer</option>
<option value="echo">echo</option>
<option value="fable">fable</option>
<option value="onyx">onyx</option>
</select>
<select class="cv-field cv-field-format" title="格式">
<option value="mp3">mp3</option>
<option value="wav">wav</option>
<option value="opus">opus</option>
</select>
</div>
<button class="cv-run">运行</button>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>`;
}
return "";
}
// 整个节点的 HTML(外层卡片 + 头部标题 + 主体)
function cvNodeHTML(kind) {
const m = CV_NODES[kind];
const tag = m.tag ? `<span class="cv-node-tag">${m.tag}</span>` : "";
return `
<div class="cv-node cv-node-${kind}">
<div class="cv-node-head">
<span class="cv-node-ico">${m.icon}</span>
<span class="cv-node-name">${m.title}</span>${tag}
</div>
<div class="cv-node-body">${cvNodeBody(kind)}</div>
</div>`;
}
// 懒初始化 Drawflow(幂等)
function cvInit() {
if (CANVAS.inited) return;
const el = document.getElementById("drawflow");
if (!el || typeof Drawflow === "undefined") return;
const editor = new Drawflow(el);
editor.reroute = false; // 关闭连线中转点:否则点连线是加点而非选中,导致无法删除连线
editor.curvature = 0.5;
editor.draggable_inputs = false; // 输入/文本域/下拉不触发节点拖拽,保证可正常输入
editor.start();
CANVAS.editor = editor;
CANVAS.inited = true;
// 连线删除:单击选中 → 双击该连线即删除;或选中后按 Delete/Backspace
let selConn = null;
editor.on("connectionSelected", (c) => { selConn = c; });
editor.on("connectionUnselected", () => { selConn = null; });
el.addEventListener("dblclick", (e) => {
if (!e.target.closest(".connection")) return;
if (selConn && CANVAS.editor) {
CANVAS.editor.removeSingleConnection(selConn.output_id, selConn.input_id, selConn.output_class, selConn.input_class);
selConn = null;
}
});
document.addEventListener("keydown", (e) => {
if ((e.key === "Delete" || e.key === "Backspace") && selConn && CANVAS.editor) {
const t = e.target;
// 避免在输入框里按退格误删连线
if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
CANVAS.editor.removeSingleConnection(selConn.output_id, selConn.input_id, selConn.output_class, selConn.input_class);
selConn = null;
}
});
// 节点删除时清理产物缓存 + 流式映射
editor.on("nodeRemoved", (id) => {
delete CANVAS.outputs[id];
delete CANVAS.refImages[id];
delete CANVAS.videoMaterials[id];
cvClearRunMapForNode(id);
});
// 事件委托:点击节点内「运行」/「拉取模型」按钮
el.addEventListener("click", (e) => {
const runBtn = e.target.closest(".cv-run");
if (runBtn) {
const nodeEl = runBtn.closest(".drawflow-node");
if (nodeEl) cvRunNode(nodeEl.id.replace("node-", ""), runBtn).catch(() => {});
return;
}
const openPreviewBtn = e.target.closest(".cv-preview-open");
if (openPreviewBtn) {
cvOpenPreview(openPreviewBtn.closest(".cv-node-preview"));
return;
}
const downloadPreviewBtn = e.target.closest(".cv-preview-download");
if (downloadPreviewBtn) {
cvDownloadPreview(downloadPreviewBtn.closest(".cv-node-preview"));
return;
}
const fetchBtn = e.target.closest(".cv-fetch-models");
if (fetchBtn) {
const nodeEl = fetchBtn.closest(".drawflow-node");
if (nodeEl) cvFetchNodeModels(nodeEl.id.replace("node-", ""), fetchBtn);
return;
}
const clrBtn = e.target.closest(".cv-refimg-clear");
if (clrBtn) {
const nodeEl = clrBtn.closest(".drawflow-node");
if (nodeEl) cvClearRefImage(nodeEl);
return;
}
const vmatClr = e.target.closest(".cv-vmat-clear");
if (vmatClr) {
const nodeEl = vmatClr.closest(".drawflow-node");
if (nodeEl) cvClearVideoMat(nodeEl, vmatClr.dataset.slot);
}
});
// 事件委托:切换节点分组下拉 → 联动模型框/清空旧候选;切换视频模式 → 显隐图片URL
el.addEventListener("change", (e) => {
const grpSel = e.target.closest(".cv-field-group");
if (grpSel) {
const nodeEl = grpSel.closest(".drawflow-node");
if (nodeEl) cvOnGroupChange(nodeEl, grpSel.value);
return;
}
const vmodeSel = e.target.closest(".cv-field-vmode");
if (vmodeSel) {
const nodeEl = vmodeSel.closest(".drawflow-node");
if (nodeEl) cvSyncVideoMode(nodeEl);
return;
}
const refInput = e.target.closest(".cv-field-refimg");
if (refInput) {
const nodeEl = refInput.closest(".drawflow-node");
if (nodeEl) cvOnRefImagePicked(nodeEl, refInput.files && refInput.files[0]);
return;
}
const vmatFile = e.target.closest(".cv-vmat-file");
if (vmatFile) {
const nodeEl = vmatFile.closest(".drawflow-node");
if (nodeEl) cvOnVideoMatPicked(nodeEl, vmatFile.dataset.slot, vmatFile.files);
}
});
// 缩放变化(按钮 / Ctrl+滚轮)→ 同步右下角百分比
editor.on("zoom", cvUpdateZoomLabel);
// 普通鼠标滚轮直接缩放(无需 Ctrl);Ctrl+滚轮仍走 Drawflow 原生。
// 用 rAF 把「一次滚动的多次事件」合并成每帧一次,避免一帧内多写 transform / 多次 dispatch。
let wheelAccum = 0;
let wheelRAF = 0;
el.addEventListener(
"wheel",
(e) => {
if (e.ctrlKey) return; // 交给 Drawflow 原生(含 Mac 触控板捏合)
e.preventDefault();
if (!CANVAS.editor) return;
wheelAccum += e.deltaY;
if (wheelRAF) return; // 一帧内只处理一次
wheelRAF = requestAnimationFrame(() => {
wheelRAF = 0;
const STEP = 100;
let steps = Math.trunc(wheelAccum / STEP);
wheelAccum -= steps * STEP;
while (steps < 0) { CANVAS.editor.zoom_in(); steps++; }
while (steps > 0) { CANVAS.editor.zoom_out(); steps--; }
// label 由 editor.on("zoom") 自动同步,无需手动调用
});
},
{ passive: false }
);
// 首次进入即预置一条「连好线、可直接运行」的示例工作流
cvSeedSample();
}
// 刷新右下角缩放百分比显示
function cvUpdateZoomLabel() {
const el = document.getElementById("cv-zoom-reset");
if (el && CANVAS.editor) el.textContent = Math.round((CANVAS.editor.zoom || 1) * 100) + "%";
}
// 图片节点选了参考图(图生图):读成 dataURL 存缓存 + 显示缩略图
function cvOnRefImagePicked(nodeEl, file) {
if (!file) return;
const id = nodeEl.id.replace("node-", "");
const reader = new FileReader();
reader.onload = () => {
CANVAS.refImages[id] = reader.result; // dataURL
const thumb = nodeEl.querySelector(".cv-refimg-thumb");
if (thumb) {
thumb.src = reader.result;
thumb.classList.remove("hidden");
}
const clr = nodeEl.querySelector(".cv-refimg-clear");
if (clr) clr.classList.remove("hidden");
};
reader.readAsDataURL(file);
}
// 清除图片节点参考图
function cvClearRefImage(nodeEl) {
const id = nodeEl.id.replace("node-", "");
delete CANVAS.refImages[id];
const thumb = nodeEl.querySelector(".cv-refimg-thumb");
if (thumb) {
thumb.src = "";
thumb.classList.add("hidden");
}
const clr = nodeEl.querySelector(".cv-refimg-clear");
if (clr) clr.classList.add("hidden");
const inp = nodeEl.querySelector(".cv-field-refimg");
if (inp) inp.value = "";
}
// 视频节点选了素材(首帧/尾帧/多参考图):读成 dataURL 存缓存 + 显示缩略图
function cvOnVideoMatPicked(nodeEl, slot, fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
const id = nodeEl.id.replace("node-", "");
const store = (CANVAS.videoMaterials[id] = CANVAS.videoMaterials[id] || { refs: [] });
if (slot === "refs") {
store.refs = store.refs || [];
const box = nodeEl.querySelector(".cv-vmat-refs-thumbs");
files.forEach((f) => {
const reader = new FileReader();
reader.onload = () => {
store.refs.push(reader.result);
if (box) {
const im = document.createElement("img");
im.src = reader.result;
im.alt = "参考图";
im.style.cssText = "max-width:40px;max-height:40px;border-radius:4px;object-fit:cover;margin:2px";
box.appendChild(im);
}
const clr = nodeEl.querySelector('.cv-vmat-clear[data-slot="refs"]');
if (clr) clr.classList.remove("hidden");
};
reader.readAsDataURL(f);
});
return;
}
// first / last:单张
const reader = new FileReader();
reader.onload = () => {
store[slot] = reader.result;
const thumb = nodeEl.querySelector(`.cv-vmat-thumb[data-slot="${slot}"]`);
if (thumb) {
thumb.src = reader.result;
thumb.classList.remove("hidden");
}
const clr = nodeEl.querySelector(`.cv-vmat-clear[data-slot="${slot}"]`);
if (clr) clr.classList.remove("hidden");
};
reader.readAsDataURL(files[0]);
}
// 清除视频节点某槽位素材
function cvClearVideoMat(nodeEl, slot) {
const id = nodeEl.id.replace("node-", "");
const store = CANVAS.videoMaterials[id];
if (store) {
if (slot === "refs") store.refs = [];
else delete store[slot];
}
if (slot === "refs") {
const box = nodeEl.querySelector(".cv-vmat-refs-thumbs");
if (box) box.innerHTML = "";
} else {
const thumb = nodeEl.querySelector(`.cv-vmat-thumb[data-slot="${slot}"]`);
if (thumb) {
thumb.src = "";
thumb.classList.add("hidden");
}
}
const clr = nodeEl.querySelector(`.cv-vmat-clear[data-slot="${slot}"]`);
if (clr) clr.classList.add("hidden");
const inp = nodeEl.querySelector(`.cv-vmat-file[data-slot="${slot}"]`);
if (inp) inp.value = "";
}
// 用 CANVAS.groups 填充一个分组下拉,默认选中 activeGroupId(保留已有选择)
function cvFillGroupSelect(sel) {
if (!sel) return;
const prev = sel.value;
const groups = CANVAS.groups || [];
if (!groups.length) {
sel.innerHTML = `<option value="">请先在『路由中转』添加分组</option>`;
return;
}
sel.innerHTML = groups
.map((g) => {
const label = esc(g.name) + (g.host ? " · " + esc(g.host) : "");
return `<option value="${esc(g.id)}">${label}</option>`;
})
.join("");
const keep = prev && groups.some((g) => String(g.id) === prev);
const nodeEl = sel.closest(".drawflow-node");
const isGrokVideo = !!(nodeEl && nodeEl.querySelector(".cv-node-grokVideo"));
const grokGroup = isGrokVideo
? groups.find((g) => /grok/i.test(g.name || "") || /grok-imagine-video/i.test(g.model || ""))
: null;
const want = keep ? prev : String((grokGroup && grokGroup.id) || CANVAS.activeGroupId || "");
if (want) sel.value = want;
}
// 节点创建后统一初始化:唯一化 datalist + 让模型框指向它 + 填充分组下拉
function cvSetupNode(id) {
const nodeEl = document.getElementById(`node-${id}`);
if (!nodeEl) return;
const dl = nodeEl.querySelector(".cv-node-datalist");
const model = nodeEl.querySelector(".cv-field-model");
if (dl && model) {
const listId = `cv-models-node-${id}`;
dl.id = listId;
model.setAttribute("list", listId);
}
cvFillGroupSelect(nodeEl.querySelector(".cv-field-group"));
// 视频节点:按当前模式初始化素材控件显隐
if (nodeEl.querySelector(".cv-field-vmode")) cvSyncVideoMode(nodeEl);
}
// 分组下拉变化:清空旧模型候选;若模型框为空,用该分组默认 model 预填
function cvOnGroupChange(nodeEl, groupId) {
const dl = nodeEl.querySelector(".cv-node-datalist");
if (dl) dl.innerHTML = "";
const model = nodeEl.querySelector(".cv-field-model");
if (model && !model.value.trim()) {
const g = (CANVAS.groups || []).find((x) => String(x.id) === String(groupId));
if (g && g.model) model.value = g.model;
}
}
// 拉取某节点所选分组的模型 → 填充该节点自己的 datalist
async function cvFetchNodeModels(nodeId, btn) {
const nodeEl = document.getElementById(`node-${nodeId}`);
if (!nodeEl) return;
const sel = nodeEl.querySelector(".cv-field-group");
const groupId = sel ? sel.value : "";
if (!groupId) {
toast("请先选择中转分组(或在『路由中转』添加分组)", "");
return;
}
const dl = nodeEl.querySelector(".cv-node-datalist");
busy(btn, true, "…");
try {
if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)");
const r = await apic.canvasModels(groupId);
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
const models = Array.isArray(r.models) ? r.models : [];
if (dl) dl.innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join("");
toast(`已拉取 ${models.length} 个模型,点模型框可选`, "ok");
} catch (e) {
toast("拉取模型失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
}
// 加载中转分组并缓存;完成后回填所有已存在节点的分组下拉
async function cvLoadGroups() {
try {
if (!apic.canvasGroups) throw new Error("no api");
const r = await apic.canvasGroups();
if (r && r.ok) {
CANVAS.groups = Array.isArray(r.groups) ? r.groups : [];
CANVAS.activeGroupId = r.activeId || (CANVAS.groups[0] && CANVAS.groups[0].id) || "";
} else {
CANVAS.groups = [];
CANVAS.activeGroupId = "";
}
} catch (e) {
CANVAS.groups = [];
CANVAS.activeGroupId = "";
}
cvBackfillGroups();
}
// 回填画布内所有节点的分组下拉(节点可能先于分组数据创建)
function cvBackfillGroups() {
document.querySelectorAll("#drawflow .cv-field-group").forEach(cvFillGroupSelect);
}
// 当前画布全部节点(对象:{ [id]: nodeData })
function cvAllNodes() {
try {
const ed = CANVAS.editor;
return (ed.drawflow.drawflow[ed.module] || {}).data || {};
} catch (e) {
return {};
}
}
// 给某节点的提示词/文本域预填内容(addNode 后 DOM 已同步存在)
function cvSetField(id, value) {
const el = document.querySelector(`#node-${id} .cv-field-prompt`);
if (el) el.value = value;
}
// 注入示例工作流:文本→文生图→图生视频,另一文本→TTS(仅在画布为空时)
function cvSeedSample() {
const ed = CANVAS.editor;
if (!ed) return;
if (Object.keys(cvAllNodes()).length > 0) return; // 已有节点则不重复注入
try {
// addNode 返回新节点 id;错开摆放避免重叠(节点宽 250px)
const t1 = ed.addNode("text", 0, 1, 40, 60, "cv-df-text", {}, cvNodeHTML("text"), false);
const img = ed.addNode("image", 1, 1, 370, 40, "cv-df-image", {}, cvNodeHTML("image"), false);
const vid = ed.addNode("video", 1, 0, 710, 40, "cv-df-video", {}, cvNodeHTML("video"), false);
const t2 = ed.addNode("text", 0, 1, 40, 420, "cv-df-text", {}, cvNodeHTML("text"), false);
const tts = ed.addNode("tts", 1, 0, 370, 400, "cv-df-tts", {}, cvNodeHTML("tts"), false);
cvSetField(t1, "一只戴宇航头盔的柴犬,赛博朋克霓虹风");
cvSetField(t2, "大家好,这是TD工具箱创意画布");
// 能力节点:唯一化 datalist + 回填分组下拉(默认选中 activeId)
[img, vid, tts].forEach(cvSetupNode);
// 连线:output_1 → input_1
ed.addConnection(t1, img, "output_1", "input_1");
ed.addConnection(img, vid, "output_1", "input_1");
ed.addConnection(t2, tts, "output_1", "input_1");
} catch (e) {
// 预置失败不影响画布可用
}
}
// 全屏尺寸变化后触发一次重绘(节点绝对坐标不变,仅刷新缩放/连线)
function cvReflow() {
try {
if (CANVAS.editor && typeof CANVAS.editor.zoom_refresh === "function") {
CANVAS.editor.zoom_refresh();
}
} catch (e) {}
}
// 切换沉浸式全屏(fixed 覆盖整个应用窗口,不新开窗口)
function cvToggleFull(force) {
const panel = document.getElementById("tab-canvas");
if (!panel) return;
const on = typeof force === "boolean"
? force
: !panel.classList.contains("cv-fullscreen");
panel.classList.toggle("cv-fullscreen", on);
document.body.classList.toggle("cv-noscroll", on);
const btn = document.getElementById("cv-full-btn");
if (btn) btn.textContent = on ? "✕ 退出全屏" : "⛶ 全屏";
cvReflow();
}
// 计算画布中心坐标(考虑当前缩放/平移),并错位摆放避免重叠
function cvCenterPos() {
const c = document.getElementById("drawflow");
const ed = CANVAS.editor;
const zoom = ed.zoom || 1;
const rect = c.getBoundingClientRect();
const stagger = (CANVAS.addCount++ % 6) * 34;
const x = (rect.width / 2 - (ed.canvas_x || 0)) / zoom - 120 + stagger;
const y = (rect.height / 2 - (ed.canvas_y || 0)) / zoom - 90 + stagger;
return { x: Math.max(20, x), y: Math.max(20, y) };
}
// 在画布中心添加一个指定类型的节点
function cvAddNode(kind) {
const m = CV_NODES[kind];
if (!CANVAS.editor || !m) return;
const pos = cvCenterPos();
const id = CANVAS.editor.addNode(
kind, m.inputs, m.outputs, pos.x, pos.y,
`cv-df-${kind}`, {}, cvNodeHTML(kind), false
);
cvSetupNode(id); // 唯一化 datalist + 回填分组下拉
}
// 切换视频模式:按 mode 显隐 首帧/尾帧/多参考图/参考视频 四组素材控件
function cvSyncVideoMode(nodeEl) {
const sel = nodeEl.querySelector(".cv-field-vmode");
if (!sel) return;
const mode = sel.value || "first";
const show = {
first: mode === "first" || mode === "firstlast",
last: mode === "firstlast",
refs: mode === "reference",
refvideo: mode === "video",
};
["first", "last", "refs", "refvideo"].forEach((slot) => {
const box = nodeEl.querySelector(".cv-vmat-" + slot);
if (box) box.classList.toggle("hidden", !show[slot]);
});
}
// 读取单个节点的「输出产物」:文本节点实时读 textarea,其它读运行后缓存的 dataUrl
function cvNodeOutput(id) {
const stored = CANVAS.outputs[id] || {};
const res = { text: stored.text || "", image: stored.image || "" };
try {
const node = CANVAS.editor.getNodeFromId(id);
if (node && node.name === "text") {
const el = document.querySelector(`#node-${id} .cv-field-prompt`);
if (el) res.text = el.value.trim();
}
} catch (e) {}
return res;
}
// 汇总某节点所有上游连线的产物(取第一个非空文本 / 图片)
function cvCollectUpstream(nodeId) {
const out = { text: "", image: "" };
try {
const node = CANVAS.editor.getNodeFromId(nodeId);
if (!node || !node.inputs) return out;
for (const key of Object.keys(node.inputs)) {
const conns = (node.inputs[key] && node.inputs[key].connections) || [];
for (const c of conns) {
const src = cvNodeOutput(c.node);
if (src.text && !out.text) out.text = src.text;
if (src.image && !out.image) out.image = src.image;
}
}
} catch (e) {}
return out;
}
// 节点内错误红字
function cvSetErr(nodeEl, msg) {
const e = nodeEl.querySelector(".cv-node-err");
if (!e) return;
if (msg) {
e.textContent = msg;
e.classList.remove("hidden");
} else {
e.textContent = "";
e.classList.add("hidden");
}
}
// 运行按钮 loading 态
function cvBusy(btn, on) {
if (!btn) return;
if (on) {
btn.dataset.lbl = btn.textContent;
btn.textContent = "运行中…";
btn.disabled = true;
btn.classList.add("loading");
} else {
btn.textContent = btn.dataset.lbl || "运行";
btn.disabled = false;
btn.classList.remove("loading");
}
}
function cvMediaExt(mime, kind) {
if (/png/i.test(mime)) return "png";
if (/jpe?g/i.test(mime)) return "jpg";
if (/webp/i.test(mime)) return "webp";
if (/mp4/i.test(mime) || kind === "video" || kind === "grokVideo") return "mp4";
return kind === "image" ? "png" : "bin";
}
function cvPreviewChrome(kind, dataUrl, sourceUrl, foot) {
const isVideo = kind === "video" || kind === "grokVideo";
const sourceAttr = sourceUrl ? ` data-source-url="${esc(sourceUrl)}"` : "";
const media = isVideo
? `<video class="cv-media" controls src="${dataUrl}"${sourceAttr}></video>`
: `<img class="cv-media" src="${dataUrl}"${sourceAttr} alt="生成图片" />`;
return `
<div class="cv-preview-shell">
<div class="cv-preview-actions">
<button type="button" class="cv-preview-open" title="放大预览">⛶</button>
<button type="button" class="cv-preview-download" title="下载">⇩</button>
</div>
${media}
</div>${foot}`;
}
function cvOpenPreview(previewEl) {
if (!previewEl) return;
const media = previewEl.querySelector("img.cv-media, video.cv-media");
if (!media || !media.src) return;
const isVideo = media.tagName === "VIDEO";
const src = media.dataset.sourceUrl || media.src;
const modal = modalOverlay.querySelector(".modal");
const body = $("#modal-body");
$("#modal-title").textContent = isVideo ? "视频预览" : "图片预览";
body.innerHTML = "";
const m = document.createElement(isVideo ? "video" : "img");
m.className = "cv-modal-media";
m.src = src;
if (isVideo) m.controls = true;
body.appendChild(m);
if (modal) modal.classList.add("cv-media-modal");
modalOverlay.classList.remove("hidden");
}
async function cvDownloadPreview(previewEl) {
if (!previewEl) return;
const media = previewEl.querySelector("img.cv-media, video.cv-media");
if (!media || !media.src) return;
const isVideo = media.tagName === "VIDEO";
const src = media.dataset.sourceUrl || media.src;
try {
const res = await fetch(src);
const blob = await res.blob();
const ext = cvMediaExt(blob.type, isVideo ? "video" : "image");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `td-canvas-${Date.now()}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
} catch (e) {
if (/^https?:\/\//i.test(src) && apic.openExternal) {
apic.openExternal(src);
toast("已用浏览器打开原始文件链接", "ok");
return;
}
toast("下载失败:" + (e.message || e), "err");
}
}
// 渲染运行结果到预览区(结果均为主进程返回的 data URL)
function cvRenderResult(kind, el, r) {
const meta = r.meta || {};
const info = [meta.model, meta.ms ? meta.ms + "ms" : "", meta.host]
.filter(Boolean).join(" · ");
const foot = info ? `<div class="cv-meta">${esc(info)}</div>` : "";
if (kind === "image") {
el.innerHTML = cvPreviewChrome(kind, r.dataUrl, r.url || "", foot);
} else if (kind === "video" || kind === "grokVideo") {
el.innerHTML = cvPreviewChrome(kind, r.dataUrl, r.url || "", foot);
} else if (kind === "tts") {
el.innerHTML = `<audio class="cv-media-audio" controls src="${r.dataUrl}"></audio>${foot}`;
}
}
// 据 比例×清晰度 算出图像尺寸 "WxH"(长边 1K=1024/2K=2048/4K=4096,短边按比例取整)
function cvRatioToSize(ratio, quality) {
const longEdge = quality === "4K" ? 4096 : quality === "2K" ? 2048 : 1024;
const parts = String(ratio).split(":").map((n) => parseFloat(n));
let w = parts[0], h = parts[1];
if (!w || !h) { w = 1; h = 1; }
const maxPart = Math.max(w, h);
const scale = longEdge / maxPart;
return `${Math.round(w * scale)}x${Math.round(h * scale)}`;
}
// —— 流式图片渐进预览 ——
// 清理某节点的所有 runId 映射(重跑/删除时,避免旧帧串到新预览)
function cvClearRunMapForNode(nodeId) {
const nid = String(nodeId);
for (const rid of Object.keys(CANVAS.runMap)) {
if (String(CANVAS.runMap[rid]) === nid) delete CANVAS.runMap[rid];
}
}
// 把预览区里的 <img> 换成新 dataUrl;若还是 skeleton 则替换成 <img>
function cvSetPreviewImage(prevEl, dataUrl) {
if (!prevEl || !dataUrl) return;
prevEl.classList.remove("hidden");
const img = prevEl.querySelector("img.cv-media");
if (img) {
img.src = dataUrl;
} else {
prevEl.innerHTML = `<img class="cv-media" src="${dataUrl}" alt="生成中…" />`;
}
}
// 订阅后端流式进度(幂等,页面生命周期内保留一次)
function cvBindProgress() {
if (CANVAS.progressBound) return;
if (!apic.onCanvasProgress) return; // 后端未接入则不订阅
CANVAS.progressBound = true;
apic.onCanvasProgress((p) => {
try {
if (!p || !p.runId || !p.dataUrl) return;
const nodeId = CANVAS.runMap[p.runId];
if (!nodeId) return; // 无映射(已结束/已清理)→ 丢弃,避免串图
const nodeEl = document.getElementById(`node-${nodeId}`);
if (!nodeEl) return;
cvSetPreviewImage(nodeEl.querySelector(".cv-node-preview"), p.dataUrl);
} catch (e) {}
});
}
// 运行一个节点:组装 params → apic.canvasRun → 预览 / 报错(全程 try/catch)
async function cvRunNode(nodeId, btn) {
const nodeEl = document.getElementById(`node-${nodeId}`);
if (!nodeEl) return;
const node = CANVAS.editor.getNodeFromId(nodeId);
const kind = node && node.name;
const prevEl = nodeEl.querySelector(".cv-node-preview");
cvSetErr(nodeEl, "");
// 组装每类节点的入参(优先自身填写,其次上游连线)
const up = cvCollectUpstream(nodeId);
const promptEl = nodeEl.querySelector(".cv-field-prompt");
const ownText = promptEl ? promptEl.value.trim() : "";
const val = (sel) => {
const el = nodeEl.querySelector(sel);
return el ? el.value.trim() : "";
};
let runKind = "";
let params = null;
let imageRunId = ""; // 仅 image 流式用;finally 里清理映射
if (kind === "image") {
const prompt = ownText || up.text;
if (!prompt) return cvSetErr(nodeEl, "请填写提示词,或连接一个文本节点作为输入");
const model = val(".cv-field-model");
if (!model) return cvSetErr(nodeEl, "请先填写图像模型(如 flux-pro / dall-e-3)");
const size = cvRatioToSize(val(".cv-field-ratio") || "1:1", val(".cv-field-quality") || "1K");
const endpointPath = val(".cv-field-endpoint");
runKind = "image";
params = { prompt, model, size };
if (endpointPath) params.endpointPath = endpointPath;
// 图生图:本节点上传的参考图 → 主进程传图床换外链;字段名可在高级里改(默认 image)
const refImg = CANVAS.refImages[nodeId];
if (refImg) params.image = refImg;
const refField = val(".cv-field-reffield");
if (refField) params.refField = refField;
// 流式渐进预览:先清理该节点旧映射(避免重跑串图),再生成唯一 runId
cvClearRunMapForNode(nodeId);
imageRunId = nodeId + "-" + (++CANVAS.runSeq);
CANVAS.runMap[imageRunId] = nodeId;
params.runId = imageRunId;
} else if (kind === "video") {
let mode = val(".cv-field-vmode") || "first";
if (mode === "image") mode = "first"; // 兼容旧值
const model = val(".cv-field-model");
if (!model) return cvSetErr(nodeEl, "请先填写视频模型");
const endpointPath = val(".cv-field-endpoint");
const duration = val(".cv-field-duration");
const audioEl = nodeEl.querySelector(".cv-field-audio");
const wmEl = nodeEl.querySelector(".cv-field-watermark");
const cfEl = nodeEl.querySelector(".cv-field-camerafixed");
const seed = val(".cv-field-seed");
const mats = CANVAS.videoMaterials[nodeId] || {};
const matUrl = (slot) => {
const el = nodeEl.querySelector(`.cv-vmat-url[data-slot="${slot}"]`);
return el ? el.value.trim() : "";
};
runKind = "video";
params = {
mode,
prompt: ownText || up.text,
model,
ratio: val(".cv-field-ratio"),
resolution: val(".cv-field-vsize"),
generateAudio: !!(audioEl && audioEl.checked),
watermark: !!(wmEl && wmEl.checked),
cameraFixed: !!(cfEl && cfEl.checked),
};
if (seed) params.seed = seed;
// 按模式收集素材:优先手填URL,其次上传缓存(dataURL),首帧再兜底上游图
if (mode === "first" || mode === "firstlast") {
const first = matUrl("first") || mats.first || up.image || "";
if (!first) return cvSetErr(nodeEl, "该模式需首帧:连上游『文生图』/ 上传 / 手填公网URL");
params.image = first;
}
if (mode === "firstlast") {
const last = matUrl("last") || mats.last || "";
if (!last) return cvSetErr(nodeEl, "首尾帧模式还需尾帧图(上传或手填公网URL)");
params.lastFrameImage = last;
}
if (mode === "reference") {
const refs = (mats.refs || []).slice();
if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请至少上传 1 张参考图");
params.referenceImages = refs;
}
if (mode === "video") {
const v = matUrl("video");
if (!/^https?:\/\//i.test(v)) return cvSetErr(nodeEl, "参考视频请手填公网视频URL(http(s))");
params.referenceVideo = v;
}
if (mode === "text" && !(params.prompt && params.prompt.trim())) {
return cvSetErr(nodeEl, "文生视频请填写提示词");
}
if (duration) params.seconds = duration;
if (endpointPath) params.endpointPath = endpointPath;
} else if (kind === "grokVideo") {
const mode = val(".cv-field-vmode") || "text";
const model = val(".cv-field-model") || "grok-imagine-video";
const endpointPath = val(".cv-field-endpoint");
const duration = val(".cv-field-duration");
const mats = CANVAS.videoMaterials[nodeId] || {};
const matUrl = (slot) => {
const el = nodeEl.querySelector(`.cv-vmat-url[data-slot="${slot}"]`);
return el ? el.value.trim() : "";
};
runKind = "grokVideo";
params = {
mode,
prompt: ownText || up.text,
model,
resolution: val(".cv-field-vsize") || "720p",
};
if (!params.prompt) return cvSetErr(nodeEl, "请填写 Grok 视频提示词,或连接一个文本节点作为输入");
if (
mode === "text" &&
/grok-imagine-video-1\.5/i.test(model) &&
params.resolution === "1080p" &&
String(duration) === "15"
) {
return cvSetErr(nodeEl, "1.5 的 1080p/15s 需要首帧图;请切到「首帧图生视频」并提供图片,或降低文生视频规格");
}
if (mode === "first") {
const first = matUrl("first") || mats.first || up.image || "";
if (!first) return cvSetErr(nodeEl, "首帧图生视频需要首帧:连上游『文生图』/ 上传 / 手填公网URL");
params.image = first;
}
if (duration) params.seconds = duration;
if (endpointPath) params.endpointPath = endpointPath;
} else if (kind === "tts") {
const text = ownText || up.text;
if (!text) return cvSetErr(nodeEl, "请填写文本,或连接一个文本节点作为输入");
const model = val(".cv-field-model");
const voice = val(".cv-field-voice") || "alloy";
const format = val(".cv-field-format") || "mp3";
runKind = "tts";
params = { text, voice, format };
if (model) params.model = model;
} else {
return; // 文本节点无需运行
}
// 每个能力节点都需选定中转分组,params 带上 groupId
const groupId = val(".cv-field-group");
if (!groupId)
return cvSetErr(nodeEl, "请先选择中转分组(或在『路由中转』添加分组)");
params.groupId = groupId;
cvBusy(btn, true);
if (prevEl) {
prevEl.classList.remove("hidden");
prevEl.innerHTML = `<div class="cv-skeleton"><span class="cv-spin"></span>生成中…</div>`;
}
try {
if (!apic.canvasRun) throw new Error("画布运行能力尚未就绪(后端未接入)");
const r = await apic.canvasRun(runKind, params);
if (!r || !r.ok) throw new Error((r && r.error) || "生成失败");
cvRenderResult(runKind, prevEl, r);
// 缓存产物供下游取用(图片可连到图生视频)
if (runKind === "image") CANVAS.outputs[nodeId] = { image: r.dataUrl };
} catch (e) {
if (prevEl) {
prevEl.classList.add("hidden");
prevEl.innerHTML = "";
}
cvSetErr(nodeEl, e.message || String(e));
} finally {
cvBusy(btn, false);
if (imageRunId) delete CANVAS.runMap[imageRunId]; // 运行结束清理映射
}
}
// 刷新顶部状态徽标 + 未就绪遮罩
async function cvRefreshStatus() {
const badge = document.getElementById("cv-status");
const mask = document.getElementById("cv-mask");
const maskText = document.getElementById("cv-mask-text");
if (!badge || !mask) return;
badge.textContent = "检测中…";
badge.className = "pc-badge cv-status";
try {
if (!apic.canvasStatus) throw new Error("中转能力尚未就绪(后端未接入)");
const st = await apic.canvasStatus();
CANVAS.status = st;
if (st && st.ready) {
const p = st.provider || {};
const label = [p.name, p.model].filter(Boolean).join(" · ") || "已就绪";
badge.textContent = "● " + label;
badge.classList.add("on");
mask.classList.add("hidden");
} else {
badge.textContent = "○ 未就绪";
badge.classList.add("off");
if (maskText) maskText.textContent =
(st && st.reason) || "请先在『路由中转』配置并激活一个中转分组";
mask.classList.remove("hidden");
}
} catch (e) {
badge.textContent = "○ 状态获取失败";
badge.classList.add("off");
if (maskText) maskText.textContent = "无法获取中转状态:" + (e.message || e);
mask.classList.remove("hidden");
}
}
// 进入画布:懒初始化 + 加载分组 + 刷新状态
function cvEnter() {
cvInit();
cvBindProgress(); // 幂等;若启动时后端未就绪,进入时再尝试订阅
cvLoadGroups();
cvRefreshStatus();
}
const cvTabBtn = document.querySelector('.tab[data-tab="canvas"]');
if (cvTabBtn) cvTabBtn.addEventListener("click", cvEnter);
// 启动时订阅一次流式进度(幂等)
cvBindProgress();
// 顶部「添加节点」按钮
$$("[data-cv-add]").forEach((b) =>
b.addEventListener("click", () => {
cvInit();
cvAddNode(b.dataset.cvAdd);
})
);
// 遮罩内「前往路由中转」
const cvGoto = document.getElementById("cv-goto-router");
if (cvGoto) cvGoto.addEventListener("click", () => activatePanel("router"));
// 工具条「🔄 拉取模型」:用当前激活分组拉取,一次性灌进所有节点的 datalist
const cvModelsBtn = document.getElementById("cv-models-btn");
if (cvModelsBtn)
cvModelsBtn.addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "拉取中…");
try {
if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)");
const r = await apic.canvasModels(CANVAS.activeGroupId || undefined);
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
const models = Array.isArray(r.models) ? r.models : [];
const opts = models.map((m) => `<option value="${esc(m)}"></option>`).join("");
const dls = document.querySelectorAll("#drawflow .cv-node-datalist");
dls.forEach((dl) => { dl.innerHTML = opts; });
toast(`已按当前分组拉取 ${models.length} 个模型,填充 ${dls.length} 个节点`, "ok");
} catch (e) {
toast("拉取模型失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
// 工具条「载入示例」:画布为空时重新注入示例工作流
const cvSeedBtn = document.getElementById("cv-seed-btn");
if (cvSeedBtn)
cvSeedBtn.addEventListener("click", () => {
cvInit();
if (Object.keys(cvAllNodes()).length > 0) {
toast("画布已有节点,先清空后再载入示例", "");
return;
}
cvSeedSample();
});
// 工具条「⛶ 放大 / ✕ 退出放大」
const cvFullBtn = document.getElementById("cv-full-btn");
if (cvFullBtn)
cvFullBtn.addEventListener("click", () => {
cvInit();
cvToggleFull();
});
// 悬浮缩放控件(+ / 100% 重置 / -)→ Drawflow zoom_in/out/reset
const cvZoom = (fn) => () => {
cvInit();
if (CANVAS.editor && typeof CANVAS.editor[fn] === "function") {
CANVAS.editor[fn]();
cvUpdateZoomLabel();
}
};
document.getElementById("cv-zoom-in")?.addEventListener("click", cvZoom("zoom_in"));
document.getElementById("cv-zoom-out")?.addEventListener("click", cvZoom("zoom_out"));
document.getElementById("cv-zoom-reset")?.addEventListener("click", cvZoom("zoom_reset"));
// Esc 退出全屏
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
const panel = document.getElementById("tab-canvas");
if (panel && panel.classList.contains("cv-fullscreen")) cvToggleFull(false);
}
});
// —— 启动 ——
loadMarket();
loadSecrets();
loadVersion();
loadProxy();