// 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) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
if (name === "voice" && !voiceLoaded) voiceLoad();
});
});
// 内置工具面板「← 返回插件市场」
$$("[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;
});
// ===========================================================================
// 语音输入(CDP 注入 Codex 麦克风按钮)
// ===========================================================================
let voiceLoaded = false;
const PHASE_TEXT = {
unknown: "未连接",
checking: "检测中…",
not_running: "Codex 未运行",
running_no_port: "需重启 Codex",
restarting: "正在重启 Codex…",
connecting: "正在连接…",
injecting: "正在注入…",
connected: "已连接",
disconnected: "已断开",
transcribing: "转写中…",
recording: "录音中",
error: "出错",
};
function renderVoiceStatus(s) {
const dot = $("#voice-dot");
const phaseText = $("#voice-phase-text");
const detail = $("#voice-detail");
const connectBtn = $("#voice-connect-btn");
const discBtn = $("#voice-disconnect-btn");
const phase = s && s.phase ? s.phase : "unknown";
if (dot) dot.dataset.state = phase;
const txt = PHASE_TEXT[phase] || phase;
if (phaseText) phaseText.textContent = txt;
const connected = phase === "connected" || phase === "transcribing" || phase === "recording";
if (connectBtn) connectBtn.classList.toggle("hidden", connected);
if (discBtn) discBtn.classList.toggle("hidden", !connected);
let detailText = txt;
if (s && s.lastError) detailText += " " + s.lastError;
if (s && s.codexPath) detailText += " 路径:" + s.codexPath;
if (s && s.target && s.target.title) detailText += " 目标:" + s.target.title;
if (detail) detail.textContent = detailText;
}
async function voiceLoad() {
if (voiceLoaded) return;
voiceLoaded = true;
let unsub = null;
try {
const s0 = await apic.voiceStatus();
renderVoiceStatus(s0);
} catch {}
unsub = apic.onVoiceStatus((s) => renderVoiceStatus(s));
const connectBtn = $("#voice-connect-btn");
const discBtn = $("#voice-disconnect-btn");
const refreshSttBtn = $("#voice-refresh-stt");
async function loadSttInfo() {
try {
const info = await apic.voiceSttInfo();
const nameEl = $("#voice-stt-name");
const modelEl = $("#voice-stt-model");
if (info && info.ready) {
if (nameEl) nameEl.textContent = info.groupName || "(未命名)";
if (modelEl) modelEl.textContent = info.model || "whisper-1";
} else {
if (nameEl) nameEl.textContent = "未配置";
if (modelEl) modelEl.textContent = "—";
}
} catch {}
}
loadSttInfo();
connectBtn.addEventListener("click", async () => {
const cur = await apic.voiceStatus().catch(() => ({}));
// 若 Codex 在跑但没端口,需用户确认重启
if (cur.phase === "running_no_port") {
const ok = confirm(
"需要重启 Codex 以启用语音输入调试通道。\n\n当前 Codex 窗口会关闭一次后重新打开,登录态与历史会话保留。\n是否继续?"
);
if (!ok) return;
busy(connectBtn, true, "重启中…");
try {
const s = await apic.voiceConnect({ forceRestart: true });
renderVoiceStatus(s);
if (s.phase === "error") toast(s.lastError || "连接失败", "err");
} catch (e) {
toast("连接失败:" + (e && e.message ? e.message : e), "err");
} finally {
busy(connectBtn, false);
}
return;
}
busy(connectBtn, true, "连接中…");
try {
const s = await apic.voiceConnect({});
renderVoiceStatus(s);
if (s.phase === "running_no_port") {
toast("Codex 在运行,需重启以启用调试端口,请再次点击连接", "");
} else if (s.phase === "error") {
toast(s.lastError || "连接失败", "err");
} else if (s.phase === "connected") {
toast("已连接,Codex 输入框旁应出现 🎤 按钮");
}
} catch (e) {
toast("连接失败:" + (e && e.message ? e.message : e), "err");
} finally {
busy(connectBtn, false);
}
});
discBtn.addEventListener("click", async () => {
busy(discBtn, true, "断开中…");
try {
const s = await apic.voiceDisconnect();
renderVoiceStatus(s);
} finally {
busy(discBtn, false);
}
});
if (refreshSttBtn) {
refreshSttBtn.addEventListener("click", () => loadSttInfo());
}
const probeBtn = $("#voice-probe-stt");
if (probeBtn) {
probeBtn.addEventListener("click", async () => {
const out = $("#voice-probe-result");
if (out) out.textContent = "诊断中…";
try {
const arr = await apic.voiceProbeStt();
const lines = (arr || []).map((x) =>
(x.supported ? "✅" : "❌") + " " + (x.name || "") + " " + (x.baseUrl || "") + " HTTP " + x.status + (x.note ? " " + x.note : "")
);
if (out) out.innerHTML = "各分组 /v1/audio/transcriptions 支持情况(✅=路径存在,可转写):\n" + esc(lines.join("\n")).replace(/\n/g, " ");
} catch (e) {
if (out) out.textContent = "诊断失败:" + (e && e.message ? e.message : e);
}
});
}
}
// ===========================================================================
// ① 插件市场
// ===========================================================================
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")
.toLowerCase()
.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) => `${esc(x)} `
).join("");
const mm = s.mm ? '🎨 多模态 ' : "";
return `
适配 ${a}${mm}
`;
}
function skillCardHTML(s) {
const sec = (s.sec || "").toUpperCase();
const secBadge = /^[SAB]$/.test(sec) ? `${sec}
` : "";
const isIn = installedDirs.has(safeSkillDir(s.name)) || installed.has(String(s.id));
const meta = [
`⬇ ${esc(s.dl || "0")} `,
s.stars ? `★ ${esc(s.stars)} ` : "",
s.fav ? `♥ ${esc(s.fav)} ` : "",
s.cred ? `${esc(s.cred)} ` : "",
].join("");
return `
${esc(s.icon || "🧩")}
${esc(s.name)}
by ${esc(s.author || "未知")}
${secBadge}
${esc(s.subtitle || s.name)}
${esc(s.brief || "")}
${agentBadges(s)}
${meta}
`;
}
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} · 加载更多 `;
$("#store-more-btn").addEventListener("click", () => renderStore(false));
} else {
more.classList.remove("hidden");
more.innerHTML = `已全部显示 · 共 ${list.length} 个 `;
}
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) => `
🧩
${esc(s.name || s.dir)}
已安装${s.symlink ? " · 软链" : ""} · ${humanSize(s.size || 0)}
${esc(s.description || "(该技能未提供描述)")}
`).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 ? `${count.toLocaleString()} ` : "";
return `
${CAT_ICON[name] || "•"}
${esc(name)} ${c}
`;
};
let html = "";
html += `精选
`;
html += item("__all__", "全部", skillCat.total);
html += item("__hot__", "热门", null);
html += item("__installed__", "我已安装", installedSkills.length);
html += `全部分类
`;
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) => `${esc(t)} `).join("");
$("#modal-body").innerHTML = `
${esc(s.icon || "🧩")}
${esc(s.name)}
by ${esc(s.author || "未知")} · 分类 ${esc(s.category)}
${esc(s.subtitle || "")}
${esc(s.brief || "(暂无简介)")}
${tags ? `${tags}
` : ""}
⬇ 下载 ${esc(s.dl || "0")}
${s.stars ? `★ 星标 ${esc(s.stars)} ` : ""}
${s.fav ? `♥ 收藏 ${esc(s.fav)} ` : ""}
${s.sec ? `🛡 安全级 ${esc(s.sec)} ` : ""}
${s.cred ? `可信度 ${esc(s.cred)} ` : ""}
适配 AI 模型 / Agent
${agentBadges(s)}
${esc(skillCat.model_note || "SKILL.md 标准技能")}${s.mm ? ";本技能涉及图片/视觉,建议搭配多模态强模型。" : "。"}
安装到 ~/.codex/skills
在我的 Git 查看 ↗
`;
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 = '';
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 ? `${platLabel} ` : "";
return `
插件 ${platBadge}
${esc(e.displayName || e.name)}
${humanSize(e.size || 0)}
tokendun 官方
${esc(e.description) || "(暂无描述)"}
`;
}
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
? `系统插件 `
: `卸载 `;
return `
已安装 ${esc(p.displayName || p.name)}
${humanSize(p.size || 0)}
来源:${esc(p.source || "—")}
${p.symlink ? "软链 " : ""}
${esc(p.description) || "(暂无描述)"}
`;
}
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 = '';
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 =
'未检测到本机安装的 Codex(可能未安装,或不在常见位置)。
';
actions.style.display = "none";
return;
}
box.innerHTML = items
.map((it, i) => {
const tag = KIND_LABEL[it.kind] || it.kind;
return `
${esc(it.label)}
${esc(it.path)}
${esc(tag)}${it.isDir ? " · " + esc(it.sizeText) : ""}
`;
})
.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 = `${sessData.length ? "没有匹配的对话" : "没有找到会话记录"}
`;
sessUpdateScope();
return;
}
box.innerHTML = list
.map(
(s) => {
const tags = [
s.archived ? '归档 ' : "",
s.threadSource === "subagent" ? '子任务 ' : "",
].filter(Boolean).join("");
return `
${esc(s.title)}
${esc(s.project || "—")} ${(s.ts || "").slice(0, 10)} ${tags}
`;
}
)
.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 = ``;
try {
const conv = await apic.sessionRead(item.path);
const html = conv.messages
.map((m) => {
const who = m.role === "user" ? "👤 用户" : m.role === "assistant" ? "🤖 Codex" : "🔧 工具";
return ``;
})
.join("");
$("#sess-messages").innerHTML = html || `这条对话没有可显示的消息
`;
} catch (e) {
$("#sess-messages").innerHTML = `读取失败:${esc(e.message || e)}
`;
}
}
async function sessLoad(opts = {}) {
if (Object.prototype.hasOwnProperty.call(opts, "all")) sessAllMode = !!opts.all;
const box = $("#sess-list");
box.innerHTML = ``;
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 = `加载失败:${esc(e.message || e)}
`;
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 = `还没有任何项目的会话被摘要。去 Codex 里聊一会儿,静默 ${st.idleMinutes} 分钟后这里就有了。
`;
} else {
box.innerHTML = st.projects
.map(
(p) => `
${esc(p.project || "(未知)")}
${esc(p.cwd)}
${p.count} 条 · ${(p.last || "").slice(0, 10)}
${p.hasHandoff ? `HANDOFF ↗ ` : ""}
`
)
.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 =
'全部项目 ' +
st.projects.map((p) => `${esc(p.project || p.cwd)} `).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
? `决策 ${r.decisions.map((d) => esc(d)).join(" · ")}
`
: "";
const next = r.next_steps.length
? `下一步 ${r.next_steps.map((d) => esc(d)).join(" · ")}
`
: "";
const files = r.files.length ? `文件 ${esc(r.files.join("、"))}
` : "";
return `
${esc(r.topic)}
${esc(r.project || "—")}
${when}
${r.engine === "local" ? "本地提取" : "LLM"}
重新摘要
删除
${esc(r.summary)}
${dec}${next}${files}
`;
})
.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: `
插件是「命令包」,不是单个命令
一个插件(如 creative-production )里通常包含多个技能命令。安装后不是输入插件名,而是输入里面的具体命令,比如 /explore、/ads-explorer。
三步开始使用
1 在「插件市场」搜索并点 安装 ,或在「备份与还原 → 从 GitHub 拉取还原」里拉取仓库后还原到本机。
2 重启 Codex ,让新装的插件生效。
3 在 Codex 输入框打 /,再输入命令关键词(如 ads、scan)即可定位并运行对应功能。
备份 / 分享给他人
「从本机导出 / 上传」可把你本机的插件打包推送到 GitHub 仓库(凭证文件已自动排除 )。
把生成的仓库链接发给对方,对方在本 app 用「从 GitHub 拉取还原」即可一键装好。
每个插件具体能干嘛?
点插件卡片可查看它的功能说明;各插件仓库首页的 README 里也有完整的命令清单与示例。
`,
},
notice: {
title: "📄 版权声明 / NOTICE",
html: `
内容归属
本市场收录并可分发的插件为 各自作者(含 OpenAI Codex 官方插件) 的作品,其源代码及相关知识产权归 原始著作权人 所有。本工具仅提供备份、分享与在已获授权环境中的还原,不修改插件源代码 。
使用范围
请在已获得相应使用授权 的前提下备份与还原插件。
使用时请遵守 OpenAI 服务条款及各插件的原始许可条款。
如不确定是否有权使用某插件,请以其官方渠道说明为准。
凭证安全
导出 / 上传时,凭证文件(auth.json、config.toml、history 等)已自动排除 ;GitHub Token 仅经系统安全存储(safeStorage)加密后保存在本机。
免责声明
本工具及其分发的备份按「现状(AS IS) 」提供,不附带任何明示或默示担保。作者不对因使用而产生的任何后果负责。本声明不构成对第三方代码的任何再授权。
`,
},
};
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 = `还没有分组,点右上「+ 新增分组」添加一个中转 API
`;
return;
}
box.innerHTML = st.groups
.map((g) => {
const badges =
(g.id === st.activeId ? '当前 ' : "") +
(g.isDefault ? ' 默认·不可删 ' : "");
const actions = g.isDefault
? '切回这里 = 直连你的账号 '
: `编辑 删除 `;
return `
${esc(g.name)} ${badges}${g.codexImage ? ' 🎨 生图 ' : ""}${g.stt ? ' 🎤 转文字' + (g.sttModel ? " " + esc(g.sttModel) : "") + ' ' : ""}
${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}
${actions}
`;
})
.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-stt").checked = !!(g && g.stt);
$("#grp-stt-model").value = g && g.sttModel ? g.sttModel : "whisper-1";
$("#grp-ctx").value = g && g.contextWindow ? g.contextWindow : "";
const sel = $("#grp-model-list");
sel.classList.add("hidden");
sel.innerHTML = '— 从下拉选择模型 — ';
$("#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,
stt: $("#grp-stt").checked,
sttModel: $("#grp-stt").checked ? ($("#grp-stt-model").value.trim() || "whisper-1") : "",
};
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 =
'— 从下拉选择模型 — ' +
models.map((m) => `${esc(m)} `).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
? `退出软件 `
: `稍后再说 `) +
`${goLabel} `;
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 = `立即重启 `;
$("#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)} 前往下载页 ↗ `;
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:${esc(r.ip)} `;
} 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 `
作为输入源,实时输出上面的文本
`;
}
if (kind === "image") {
return `
拉取
1:1 方
16:9 横
9:16 竖
4:3
3:4
3:2
1K
2K
4K
⚙️ 高级
运行
`;
}
if (kind === "video") {
return `
图生视频 · 首帧图
首尾帧
多参考图
参考视频
文生视频(仅提示词)
拉取
16:9
9:16
1:1
4:3
720p
1080p
480p
自动
时长 自动
5s
10s
15s
生成音频
⚙️ 高级
水印
固定镜头
运行
`;
}
if (kind === "grokVideo") {
return `
文生视频
首帧图生视频
多参考图
拉取
720p
1080p
480p
8s
5s
10s
15s
多参考图使用 grok-imagine-video + 720p;1.5 的 1080p/15s 需要首帧图
⚙️ 高级
运行
`;
}
if (kind === "tts") {
return `
拉取
alloy
nova
shimmer
echo
fable
onyx
mp3
wav
opus
运行
`;
}
return "";
}
// 整个节点的 HTML(外层卡片 + 头部标题 + 主体)
function cvNodeHTML(kind) {
const m = CV_NODES[kind];
const tag = m.tag ? `${m.tag} ` : "";
return `
${m.icon}
${m.title} ${tag}
${cvNodeBody(kind)}
`;
}
// 懒初始化 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 = "";
const urls = nodeEl.querySelector('.cv-vmat-urls[data-slot="refs"]');
if (urls) urls.value = "";
} 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 = `请先在『路由中转』添加分组 `;
return;
}
sel.innerHTML = groups
.map((g) => {
const label = esc(g.name) + (g.host ? " · " + esc(g.host) : "");
return `${label} `;
})
.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) => ` `).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]);
});
if (mode === "reference" && nodeEl.querySelector(".cv-node-grokVideo")) {
const model = nodeEl.querySelector(".cv-field-model");
const resolution = nodeEl.querySelector(".cv-field-vsize");
if (model) model.value = "grok-imagine-video";
if (resolution) resolution.value = "720p";
}
}
// 读取单个节点的「输出产物」:文本节点实时读 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
? ` `
: ` `;
return `
${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 ? `${esc(info)}
` : "";
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 = ` ${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];
}
}
// 把预览区里的 换成新 dataUrl;若还是 skeleton 则替换成
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 = ` `;
}
}
// 订阅后端流式进度(幂等,页面生命周期内保留一次)
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() : "";
};
const matUrls = (slot) => {
const el = nodeEl.querySelector(`.cv-vmat-urls[data-slot="${slot}"]`);
return el ? el.value.split(/\s+/).map((s) => s.trim()).filter(Boolean) : [];
};
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 (mode === "reference") {
const refs = [up.image].concat(mats.refs || [], matUrls("refs")).filter(Boolean);
if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请连接上游图片、上传参考图,或填写公网图片URL");
params.referenceImages = refs;
params.model = "grok-imagine-video";
params.resolution = "720p";
params.aspectRatio = "16:9";
}
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 = ` 生成中…
`;
}
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) => ` `).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();