Files
codex-market-app/renderer/app.js
T

3863 lines
158 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.dataset.busyActive = "1";
btn.textContent = textWhenBusy || "处理中…";
btn.disabled = true;
} else {
btn.textContent = btn.dataset.label || btn.textContent;
delete btn.dataset.busyActive;
btn.disabled = false;
}
}
// —— 顶部广告:点击用系统浏览器打开官方推荐中转 ——
const promoEl = document.querySelector(".promo");
if (promoEl) {
promoEl.addEventListener("click", (e) => {
e.preventDefault();
apic.openExternal(promoEl.dataset.open);
});
}
// —— 标签切换 ——
// 激活某个面板(可能有顶部标签,也可能没有——如会话记录/记忆现从插件市场进入)
function activatePanel(name) {
if (name !== "token" && tkPollTimer) {
clearInterval(tkPollTimer);
tkPollTimer = null;
}
$$(".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();
if (name === "team") tmEnter();
if (name === "prompt" && !pmLoaded) pmEnter();
if (name === "token") tkEnter();
});
});
// 内置工具面板「← 返回插件市场」
$$("[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;
let voiceControlsWired = false;
let latestVoiceStatus = null;
const PHASE_TEXT = {
unknown: "未连接",
checking: "检测中…",
not_running: "Codex 未运行",
running_no_port: "需重启 Codex",
restarting: "正在重启 Codex…",
connecting: "正在连接…",
injecting: "正在注入…",
connected: "已连接",
disconnected: "已断开",
transcribing: "转写中…",
recording: "录音中",
error: "出错",
};
function isVoiceConnected(phase) {
return phase === "connected" || phase === "transcribing" || phase === "recording";
}
function renderVoiceStatus(s) {
latestVoiceStatus = s || null;
const dots = ["#voice-dot", "#codex-link-dot", "#tk-dot"].map((sel) => $(sel)).filter(Boolean);
const phaseTexts = ["#voice-phase-text", "#codex-link-phase-text"].map((sel) => $(sel)).filter(Boolean);
const detail = $("#voice-detail");
const phase = s && s.phase ? s.phase : "unknown";
dots.forEach((dot) => { dot.dataset.state = phase; });
const txt = PHASE_TEXT[phase] || phase;
phaseTexts.forEach((el) => { el.textContent = txt; });
const tokenPhaseText = $("#tk-phase-text");
if (tokenPhaseText) tokenPhaseText.textContent = isVoiceConnected(phase) ? "已连接" : txt;
const connected = isVoiceConnected(phase);
$$("[data-voice-link-connect]").forEach((btn) => btn.classList.toggle("hidden", connected));
$$("[data-voice-link-disconnect]").forEach((btn) => btn.classList.toggle("hidden", !connected));
$$("[data-voice-enable]").forEach((btn) => {
btn.disabled = connected;
if (!btn.dataset.busyActive) {
btn.textContent = connected
? (btn.dataset.enabledLabel || "已启用")
: (btn.dataset.enableLabel || "启用");
}
});
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 handleVoiceConnect(triggerBtn) {
const isEnable = triggerBtn && triggerBtn.hasAttribute("data-voice-enable");
const cur = await apic.voiceStatus().catch(() => latestVoiceStatus || {});
// 若 Codex 在跑但没端口,需用户确认重启
if (cur.phase === "running_no_port") {
const ok = confirm(
"需要重启 Codex 以启用语音输入调试通道。\n\n当前 Codex 窗口会关闭一次后重新打开,登录态与历史会话保留。\n是否继续?"
);
if (!ok) return;
busy(triggerBtn, 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(triggerBtn, false);
renderVoiceStatus(latestVoiceStatus || {});
}
return;
}
busy(triggerBtn, true, isEnable ? "启用中…" : "连接中…");
try {
const s = await apic.voiceConnect({});
renderVoiceStatus(s);
if (s.phase === "running_no_port") {
toast(isEnable ? "Codex 在运行,需重启以启用调试端口,请再次点击启用" : "Codex 在运行,需重启以启用调试端口,请再次点击连接", "");
} else if (s.phase === "error") {
toast(s.lastError || "连接失败", "err");
} else if (s.phase === "connected") {
toast(isEnable ? "语音输入已启用,Codex 输入框旁应出现 🎤 按钮" : "已连接 Codex");
}
} catch (e) {
toast("连接失败:" + (e && e.message ? e.message : e), "err");
} finally {
busy(triggerBtn, false);
renderVoiceStatus(latestVoiceStatus || {});
}
}
async function handleVoiceDisconnect(triggerBtn) {
busy(triggerBtn, true, "断开中…");
try {
const s = await apic.voiceDisconnect();
renderVoiceStatus(s);
} finally {
busy(triggerBtn, false);
renderVoiceStatus(latestVoiceStatus || {});
}
}
function wireVoiceControls() {
if (voiceControlsWired) return;
voiceControlsWired = true;
document.addEventListener("click", async (e) => {
const connectBtn = e.target.closest("[data-voice-link-connect], [data-voice-enable]");
if (connectBtn) {
await handleVoiceConnect(connectBtn);
return;
}
const disconnectBtn = e.target.closest("[data-voice-link-disconnect]");
if (disconnectBtn) await handleVoiceDisconnect(disconnectBtn);
});
apic.voiceStatus().then(renderVoiceStatus).catch(() => {});
apic.onVoiceStatus((s) => renderVoiceStatus(s));
}
async function voiceLoad() {
if (voiceLoaded) return;
voiceLoaded = true;
try {
const s0 = await apic.voiceStatus();
renderVoiceStatus(s0);
} catch {}
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();
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, "<br>");
} 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) => `<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;
let grp1mMarks = new Set(); // 当前编辑分组里被标记为「1M 长上下文」的模型名
// 渲染 1M 勾选清单:把拉取到的模型(并入已保存标记)逐个列出,勾选态来自 grp1mMarks
function grpRender1mList(models) {
const all = Array.from(new Set([...(models || []), ...grp1mMarks]));
const box = $("#grp-1m-list");
if (!all.length) { $("#grp-1m-field").style.display = "none"; box.innerHTML = ""; return; }
$("#grp-1m-field").style.display = "";
box.innerHTML = all
.map((m) => `<label class="grp-1m-item"><input type="checkbox" value="${esc(m)}"${grp1mMarks.has(m) ? " checked" : ""} /><span>${esc(m)}</span></label>`)
.join("");
// 勾选变化实时同步进 grp1mMarks
box.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.addEventListener("change", () => {
if (cb.checked) grp1mMarks.add(cb.value);
else grp1mMarks.delete(cb.value);
});
});
}
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;
}
// 按协议族分两栏:Codex/GPT(OpenAI 协议)与 Claude Code(Anthropic 协议)各一个单选,
// 两栏互不影响 —— 可同时激活一个 GPT 分组 + 一个 Claude 分组一起用。
const isClaude = (g) => g.mode === "claude";
const oai = st.groups.filter((g) => !isClaude(g));
const claude = st.groups.filter((g) => isClaude(g));
const itemHtml = (g, radioName, activeId) => {
// chat 模式(国产:DeepSeek/GLM/Kimi/通义)需协议转换,无法直连 Codex,必须开本地路由才能用
const needsRouter = !g.isDefault && g.mode === "chat";
const badges =
(g.id === activeId ? '<span class="pc-badge skill">当前</span>' : "") +
(g.isDefault ? ' <span class="pc-badge">默认·不可删</span>' : "") +
(needsRouter ? ' <span class="pc-badge warn">需路由</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>`;
// chat 分组被选中但路由没在跑 → 醒目提示去启动
const routerHint = needsRouter && g.id === activeId && !st.running
? '<div class="cl-warn">⚠️ 该分组需协议转换,请先在上方「启动」本地路由,否则 Codex 无法使用</div>'
: "";
return `
<div class="cl-item">
<input type="radio" name="${radioName}" ${g.id === activeId ? "checked" : ""} data-active="${g.id}" />
<div>
<div class="cl-name">${esc(g.name)} ${badges}${g.codexImage ? ' <span class="agent-badge mm">🎨 生图</span>' : ""}${g.stt ? ' <span class="agent-badge mm">🎤 转文字' + (g.sttModel ? " " + esc(g.sttModel) : "") + '</span>' : ""}</div>
<div class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${needsRouter ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
${routerHint}
</div>
<div class="btn-group" style="margin-left:auto">${actions}</div>
</div>`;
};
const colHtml = (title, hint, list, radioName, activeId) => `
<div class="router-col">
<div class="router-col-head"><b>${title}</b><span class="muted small">${hint}</span></div>
${list.length ? list.map((g) => itemHtml(g, radioName, activeId)).join("") : '<div class="empty small">该类型下暂无分组</div>'}
</div>`;
box.innerHTML =
colHtml("Codex / GPT", "OpenAI 协议 · Responses / Chat", oai, "active-grp-oai", st.activeId) +
colHtml("Claude Code", "Anthropic 协议 · /v1/messages", claude, "active-grp-claude", st.activeClaudeId);
box.querySelectorAll("[data-active]").forEach((el) =>
el.addEventListener("change", async () => {
const r = await apic.routerSetActive(el.dataset.active);
const g = (r.groups || []).find((x) => x.id === el.dataset.active);
renderRouter(r);
if (g && g.mode === "claude") {
if (r._claudeSkipped) toast("已选 Claude 分组,但未写入:" + (r._claudeReason || "缺地址或密钥"), "warn");
else if (r._mode === "direct") toast("已切 Claude Code 分组 ✅ 新开 claude 会话生效", "ok");
else toast("已秒切 Claude Code 分组 ✅ 无需重启", "ok");
} else 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 = '<option value="">— 从下拉选择模型 —</option>';
$("#grp-model-hint").textContent = "";
// 1M 标记:载入该分组已保存的名单;有则先渲染出来(未拉取模型也能看到已勾选的),否则隐藏
grp1mMarks = new Set((g && Array.isArray(g.oneMillionModels)) ? g.oneMillionModels : []);
if (grp1mMarks.size) grpRender1mList([...grp1mMarks]);
else { $("#grp-1m-field").style.display = "none"; $("#grp-1m-list").innerHTML = ""; }
grpUpdateClaudeDesk();
$("#grp-modal").classList.remove("hidden");
}
function closeGrpModal() {
$("#grp-modal").classList.add("hidden");
}
// 刷新「接入 Claude 桌面 App」区:按分组协议启用/禁用 + 显示接入/备份状态
async function grpUpdateClaudeDesk() {
const block = $("#grp-claudedesk");
if (!block) return;
const isClaude = $("#grp-mode").value === "claude";
const hint = $("#grp-cd-hint");
const applyBtn = $("#grp-cd-apply");
const exportBtn = $("#grp-cd-export");
const copyBtn = $("#grp-cd-copy");
// 未保存的新分组不能接入(需要 groupId)
const canApply = isClaude && !!grpEditing;
[applyBtn, exportBtn, copyBtn].forEach((b) => { if (b) b.disabled = !canApply; });
if (!isClaude) {
hint.textContent = "⚠ Claude 桌面 App 只接受 Anthropic 协议分组——请把上面「接口类型」改成 Claude Code 再接入。";
} else if (!grpEditing) {
hint.textContent = "先保存这个分组,再回来一键接入 Claude 桌面 App。";
} else {
hint.textContent = "把该分组一键写入 Claude 桌面 App,重启 App 即用你的中转。接入前会自动备份官方配置。";
}
// 状态行
const statusEl = $("#grp-cd-status");
try {
if (!apic.claudedeskStatus) { statusEl.textContent = ""; return; }
const s = await apic.claudedeskStatus();
if (!s || !s.supported) { statusEl.textContent = "当前系统暂不支持(仅 macOS / Windows)。"; return; }
const parts = [];
parts.push(s.currentApplied ? "🟢 Claude 桌面当前:已接入第三方中转" : "⚪ Claude 桌面当前:官方登录");
if (s.hasBackup) parts.push("已备份官方配置,可一键还原");
statusEl.textContent = parts.join(" · ");
} catch { statusEl.textContent = ""; }
}
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);
// 接口类型改变时,实时刷新「接入 Claude 桌面」区的可用性
$("#grp-mode").addEventListener("change", grpUpdateClaudeDesk);
// —— 接入 Claude 桌面 App 按钮 ——
$("#grp-cd-apply").addEventListener("click", async (ev) => {
if (!grpEditing) { toast("请先保存分组", "warn"); return; }
const btn = ev.currentTarget;
busy(btn, true, "接入中…");
try {
const r = await apic.claudedeskApply(grpEditing.id);
if (!r || !r.ok) throw new Error((r && r.error) || "接入失败");
toast("已接入 Claude 桌面 App ✅ 重启 App 生效" + (r.has1m ? "(已带 1M 头)" : "") + "。官方配置已备份,可一键还原。", "ok");
grpUpdateClaudeDesk();
} catch (e) {
toast("接入失败:" + (e.message || e), "err");
} finally { busy(btn, false); }
});
$("#grp-cd-export").addEventListener("click", async () => {
if (!grpEditing) { toast("请先保存分组", "warn"); return; }
try {
const r = await apic.claudedeskExport(grpEditing.id, "json");
if (r && r.ok) toast("已导出到 " + r.path, "ok");
else if (r && r.canceled) { /* 取消,静默 */ }
else throw new Error((r && r.error) || "导出失败");
} catch (e) { toast("导出失败:" + (e.message || e), "err"); }
});
$("#grp-cd-copy").addEventListener("click", async () => {
if (!grpEditing) { toast("请先保存分组", "warn"); return; }
try {
const r = await apic.claudedeskSnippet(grpEditing.id);
if (r && r.ok) toast("配置已复制到剪贴板", "ok");
else throw new Error((r && r.error) || "复制失败");
} catch (e) { toast("复制失败:" + (e.message || e), "err"); }
});
$("#grp-cd-restore").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "还原中…");
try {
const r = await apic.claudedeskRestore();
if (!r || !r.ok) throw new Error((r && r.error) || "还原失败");
toast(r.message || "已还原官方配置,请重启 Claude 桌面 App", "ok");
grpUpdateClaudeDesk();
} catch (e) {
toast("还原失败:" + (e.message || e), "err");
} finally { busy(btn, false); }
});
$("#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") : "",
oneMillionModels: [...grp1mMarks],
};
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 {
const st = await apic.routerUpsertGroup(group);
renderRouter(st);
closeGrpModal();
// 组合提示:Codex 直连结果 + Claude Code 接入结果
let codexMsg = "";
if (st._directApplied) codexMsg = "已写入 Codex 配置(重启 Codex 即用)";
else if (st._directError) codexMsg = "写入 Codex 失败:" + st._directError;
else if (st._directSkipped) codexMsg = st._directReason || "未写入 Codex(需启用路由/补全密钥)";
let claudeMsg = "";
if (st._claudeApplied) claudeMsg = "已接入 Claude Code";
else if (st._claudeError) claudeMsg = "接入 Claude Code 失败:" + st._claudeError;
else if (st._claudeSkipped) claudeMsg = "Claude Code 未接入:" + (st._claudeReason || "缺地址或密钥");
else if (st._claudeRestored) claudeMsg = "已取消 Claude Code 接入";
const parts = [codexMsg, claudeMsg].filter(Boolean);
const hasErr = st._directError || st._claudeError;
const hasWarn = st._directSkipped || st._claudeSkipped;
if (parts.length) {
toast("已保存分组 ✅ " + parts.join(";"), hasErr ? "err" : hasWarn ? "warn" : "ok");
} else {
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");
grpRender1mList(models); // 渲染 1M 勾选清单(合并已保存标记)
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\n普通使用无需此项——保存分组时已把中转配置直接写进 Codex,重启即用。\n仅当你需要「切分组免重启」或使用 Chat 协议转换分组(如 DeepSeek/GLM/Kimi)时才需要启用路由。\n\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认启用?")) return;
const btn = ev.currentTarget;
busy(btn, true, "配置中…");
try {
const r = await apic.routerApplyCodex();
renderRouter(r.status);
toast("已启用本地路由 ✅ 重启 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="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>
<select class="cv-field cv-field-group" title="中转分组"></select>
<div class="cv-model-row">
<select class="cv-field cv-field-model" title="CLMM 模型"><option value="">先拉取模型</option></select>
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
</div>
<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-vquality" title="画质">
<option value="auto">画质 自动</option>
<option value="hd">HD</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>
</div>
<div class="cv-node-hint">CLMM 文档接口:模型名、时长和参考图按上游能力校验</div>
<details class="cv-adv"><summary>⚙️ 高级</summary>
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" />
<select class="cv-field cv-field-refsource" title="多参考图字段">
<option value="images">多参考图字段 images</option>
<option value="ref_assets">全能参考字段 ref_assets</option>
</select>
</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>
<option value="reference">多参考图</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>
<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>
<textarea class="cv-field cv-vmat-urls" data-slot="refs" rows="2" placeholder="可选:每行一个公网图片URL;会和上传图一起作为参考"></textarea>
</div>
<select class="cv-field cv-field-group" title="中转分组"></select>
<div class="cv-model-row">
<select class="cv-field cv-field-model" title="CLMM 模型"><option value="">先拉取模型</option></select>
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
</div>
<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="4:3">4:3 横屏</option>
<option value="3:4">3:4 竖屏</option>
<option value="2:3">2:3 竖屏</option>
<option value="3:2">3:2 横屏</option>
<option value="1:1">1:1 方形</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>
</select>
</div>
<div class="cv-row2">
<select class="cv-field cv-field-duration" title="时长">
<option value="6">6s</option>
<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">通过 CLMM 聚合接口提交,模型请使用拉取到的完整名称</div>
<details class="cv-adv"><summary>⚙️ 高级</summary>
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" />
</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 = "";
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 = `<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;
}
function cvSetNodeModelOptions(nodeEl, models) {
if (!nodeEl) return;
const model = nodeEl.querySelector(".cv-field-model");
const list = Array.isArray(models) ? models.filter(Boolean) : [];
if (model && model.tagName === "SELECT") {
const current = model.value.trim();
const options = ['<option value="">请选择模型</option>']
.concat(list.map((item) => `<option value="${esc(item)}">${esc(item)}</option>`));
if (current && !list.includes(current)) {
options.splice(1, 0, `<option value="${esc(current)}">${esc(current)}(当前)</option>`);
}
model.innerHTML = options.join("");
if (current) model.value = current;
return;
}
const dl = nodeEl.querySelector(".cv-node-datalist");
if (dl) dl.innerHTML = list.map((item) => `<option value="${esc(item)}"></option>`).join("");
}
// 节点创建后统一初始化:视频模型迁移为真正的下拉框,其它节点继续使用 datalist。
function cvSetupNode(id) {
const nodeEl = document.getElementById(`node-${id}`);
if (!nodeEl) return;
let model = nodeEl.querySelector(".cv-field-model");
const isVideo = !!nodeEl.querySelector(".cv-node-video, .cv-node-grokVideo");
if (isVideo && model && model.tagName !== "SELECT") {
const current = model.value.trim();
const replacement = document.createElement("select");
replacement.className = model.className;
replacement.title = "CLMM 模型";
replacement.innerHTML = '<option value="">请选择模型</option>' + (current
? `<option value="${esc(current)}">${esc(current)}(当前)</option>`
: "");
model.replaceWith(replacement);
model = replacement;
}
const dl = nodeEl.querySelector(".cv-node-datalist");
if (dl && model && model.tagName !== "SELECT") {
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);
}
// 分组下拉变化:清空旧模型候选,不自动锁定第一个模型,保留手动选择。
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.tagName === "SELECT") model.innerHTML = '<option value="">请点击“拉取”选择模型</option>';
}
// 拉取某节点所选分组的模型 → 填充完整模型下拉/候选列表
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;
}
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 : [];
cvSetNodeModelOptions(nodeEl, models);
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",
};
["first", "last", "refs"].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.src || media.dataset.sourceUrl;
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");
}
function cvDataUrlToBlob(dataUrl) {
const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(dataUrl);
if (!m) throw new Error("不是合法的 data URL");
const mime = m[1] || "application/octet-stream";
if (m[2]) {
// base64 编码 → 直接 atob 解码(+ 是合法 base64 字符,不要 decodeURIComponent)
const bin = atob(m[3]);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new Blob([u8], { type: mime });
}
// 非 base64 的 data URL(百分号编码)
const raw = decodeURIComponent(m[3].replace(/\+/g, " "));
return new Blob([raw], { type: mime });
}
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.src || media.dataset.sourceUrl;
try {
let blob;
if (/^data:/i.test(src)) {
// data URL 直接解析,不走 fetch(避免 CSP 拦截 + 节省内存)
blob = cvDataUrlToBlob(src);
} else {
// 远程 URL 仍需 fetch
const res = await fetch(src);
if (!res.ok) throw new Error("HTTP " + res.status);
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");
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 = "video";
params = {
mode,
prompt: ownText || up.text,
ratio: val(".cv-field-ratio"),
quality: val(".cv-field-vquality"),
referenceField: val(".cv-field-refsource") || "images",
};
if (model) params.model = model;
if (!(params.prompt && params.prompt.trim())) {
return cvSetErr(nodeEl, "Seedance 文生/图生视频都必须填写提示词");
}
// 按模式收集素材:优先手填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 (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");
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",
aspectRatio: val(".cv-field-ratio") || "16:9",
};
if (!params.prompt) return cvSetErr(nodeEl, "请填写 Grok 视频提示词,或连接一个文本节点作为输入");
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.aspectRatio = 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 = `<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"));
// 工具条「🔄 拉取模型」:用当前激活分组拉取,一次性灌进所有节点的模型选择器
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 nodes = document.querySelectorAll("#drawflow .drawflow-node");
nodes.forEach((node) => cvSetNodeModelOptions(node, models));
toast(`已按当前分组拉取 ${models.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);
}
});
// ============================================================================
// 员工团队:需求 → AI 员工团队 → 打包 zip
// ============================================================================
const TEAM = { tool: "codex", team: null, groups: [], activeGroupId: "", loaded: false };
async function tmEnter() {
if (!TEAM.loaded) {
TEAM.loaded = true;
tmSetView(TEAM.team ? "content" : "empty"); // 归一初始层,避免 empty/loading/content 叠在一起
await tmLoadGroups();
}
}
// 拉取中转分组,填充分组下拉
async function tmLoadGroups() {
try {
if (!apic.canvasGroups) throw new Error("no api");
const r = await apic.canvasGroups();
if (r && r.ok) {
TEAM.groups = Array.isArray(r.groups) ? r.groups : [];
TEAM.activeGroupId = r.activeId || (TEAM.groups[0] && TEAM.groups[0].id) || "";
} else {
TEAM.groups = [];
TEAM.activeGroupId = "";
}
} catch (e) {
TEAM.groups = [];
TEAM.activeGroupId = "";
}
const sel = document.getElementById("tm-group");
if (sel) {
sel.innerHTML = TEAM.groups.length
? TEAM.groups.map((g) => `<option value="${esc(g.id)}">${esc(g.name || g.id)}${g.host ? " · " + esc(g.host) : ""}</option>`).join("")
: `<option value="">未配置可用分组(去『路由中转』添加)</option>`;
sel.value = TEAM.activeGroupId || "";
sel.onchange = () => { TEAM.activeGroupId = sel.value; };
}
tmUpdateGenBtn();
}
// 拉取所选分组的模型列表,填充模型下拉
async function tmFetchModels() {
const btn = document.getElementById("tm-fetch");
const sel = document.getElementById("tm-model");
const groupId = TEAM.activeGroupId || undefined;
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 (sel) {
sel.innerHTML = models.length
? models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
: `<option value="">该分组没有可用模型</option>`;
sel.onchange = tmUpdateGenBtn;
}
document.getElementById("tm-model-hint").textContent = `已拉取 ${models.length} 个模型,选一个用于分析需求`;
tmUpdateGenBtn();
toast(`已拉取 ${models.length} 个模型`, "ok");
} catch (e) {
toast("拉取模型失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
}
function tmUpdateGenBtn() {
const btn = document.getElementById("tm-gen");
if (btn.dataset.busy === "1") return; // 生成中不改动禁用态(由 tmGenerate 收尾复位)
const req = document.getElementById("tm-req").value.trim();
const model = document.getElementById("tm-model").value;
btn.disabled = !(req && model);
}
function tmSetView(w) {
// 用内联 display 直接控制,避免 [hidden] 被 .tm-empty/.tm-loading 的 display:flex 覆盖
const views = { empty: "tm-empty", loading: "tm-loading", content: "tm-content", error: "tm-error" };
Object.entries(views).forEach(([k, id]) => {
const el = document.getElementById(id);
if (!el) return;
const on = k === w;
el.hidden = !on;
el.style.display = on ? (k === "content" ? "block" : "flex") : "none";
});
}
async function tmGenerate() {
const requirement = document.getElementById("tm-req").value.trim();
const model = document.getElementById("tm-model").value;
if (!requirement || !model) return;
const genBtn = document.getElementById("tm-gen");
document.getElementById("tm-download").hidden = true;
// 生成期间禁用按钮并改文案,防止误触重复提交
genBtn.disabled = true;
genBtn.dataset.busy = "1";
genBtn.textContent = "正在生成员工团队…";
tmSetView("loading");
try {
const r = await apic.teamGenerate({
requirement,
tool: TEAM.tool,
groupId: TEAM.activeGroupId || undefined,
model,
agentModel: document.getElementById("tm-agent-model").value.trim(),
});
if (!r || !r.ok) throw new Error((r && r.error) || "生成失败");
TEAM.team = r.result;
tmRenderTeam(r.result);
tmSetView("content");
document.getElementById("tm-download").hidden = false;
} catch (err) {
document.getElementById("tm-error").innerHTML = "⚠ " + esc(err.message || String(err));
tmSetView("error");
} finally {
genBtn.textContent = "生成员工团队";
genBtn.dataset.busy = "";
tmUpdateGenBtn();
}
}
function tmRenderTeam(team) {
const head = document.getElementById("tm-team-head");
const stack = (team.stack || []).map((s) => `<span class="tm-stack-chip">${esc(s)}</span>`).join("");
head.innerHTML =
`<div class="tm-team-name">${esc(team.project_name || "AI 员工团队")}</div>` +
(team.summary ? `<div class="tm-team-summary">${esc(team.summary)}</div>` : "") +
(stack ? `<div class="tm-team-stack">${stack}</div>` : "");
const list = document.getElementById("tm-team-list");
list.innerHTML = "";
list.appendChild(tmConvCard(team));
if (team.orchestrator) list.appendChild(tmAgentCard(team.orchestrator, true));
(team.employees || []).forEach((e) => list.appendChild(tmAgentCard(e, false)));
}
function tmConvCard(team) {
const owner = (team.employees || []).find((e) => e.id === team.contract_owner);
const li = (arr) => (arr || []).map((x) => `<li>${esc(x)}</li>`).join("");
const div = document.createElement("div");
div.className = "tm-conv-card";
div.innerHTML =
`<div class="tm-conv-title">团队协同规范</div>
<div class="tm-conv-owner">接口契约 owner:<b>${esc(owner ? owner.name : team.contract_owner || "—")}</b> · 全部规范已写进每个 agent 的 prompt,并随包附 CONVENTIONS.md</div>
<div class="tm-conv-grid">
<div class="tm-conv-col"><div class="tm-cc-label">统一产出格式</div><ul>${li(team.output_format)}</ul></div>
<div class="tm-conv-col"><div class="tm-cc-label">完成定义 (DoD)</div><ul>${li(team.definition_of_done)}</ul></div>
<div class="tm-conv-col"><div class="tm-cc-label">Git / 文件边界</div><ul>${li(team.git_rules)}</ul></div>
${(team.security_checklist || []).length
? `<div class="tm-conv-col"><div class="tm-cc-label">安全 / 权限清单</div><ul>${li(team.security_checklist)}</ul></div>`
: `<div class="tm-conv-col"><div class="tm-cc-label">任务交接协议</div><ul>${li(team.handoff_protocol)}</ul></div>`}
</div>`;
return div;
}
function tmAgentCard(a, isLead) {
const div = document.createElement("div");
div.className = "tm-emp-card" + (isLead ? " lead" : "");
const resp = (a.responsibilities || []).map((r) => `<li>${esc(r)}</li>`).join("");
const cons = (a.constraints || []).map((c) => `<span class="tm-con-chip">${esc(c)}</span>`).join("");
let extra = "";
if (isLead && (a.workflow || []).length) {
extra = `<div class="tm-sec-label">调度流程</div><ol class="tm-flow">${a.workflow.map((w) => `<li>${esc(w)}</li>`).join("")}</ol>`;
} else if (!isLead) {
const collab = (a.collaborators || []).map((c) => `<code>${esc(c)}</code>`).join("");
const rows = [];
if (collab) rows.push(`<div class="tm-ec-row"><span class="tm-ec-k">协作</span><span>${collab}</span></div>`);
if (a.handoff_in) rows.push(`<div class="tm-ec-row"><span class="tm-ec-k">接收</span><span>${esc(a.handoff_in)}</span></div>`);
if (a.handoff_out) rows.push(`<div class="tm-ec-row"><span class="tm-ec-k">交付</span><span>${esc(a.handoff_out)}</span></div>`);
if (rows.length) extra = `<div class="tm-collab">${rows.join("")}</div>`;
}
div.innerHTML =
`<div class="tm-emp-top">
<span class="tm-emp-name">${esc(a.name || "")}</span>
${isLead ? '<span class="tm-emp-badge">总控</span>' : ""}
<span class="tm-emp-id">${esc(a.id || "")}</span>
</div>
<div class="tm-emp-duty">${esc(a.duty || "")}</div>
${resp ? `<div class="tm-sec-label">职责</div><ul class="tm-resp">${resp}</ul>` : ""}
${extra}
${cons ? `<div class="tm-sec-label">约束</div><div class="tm-cons">${cons}</div>` : ""}
<div class="tm-file-block">
<button class="tm-file-toggle"></button>
<div class="tm-file-view" hidden>
<div class="tm-file-head"><span class="tm-file-name"></span><button class="tm-file-copy">复制</button></div>
<pre class="tm-file-pre"></pre>
</div>
</div>`;
tmWirePreview(div, a);
return div;
}
function tmWirePreview(div, a) {
const ext = TEAM.tool === "claude" ? ".md" : ".toml";
const toggle = div.querySelector(".tm-file-toggle");
const view = div.querySelector(".tm-file-view");
const pre = div.querySelector(".tm-file-pre");
const nameEl = div.querySelector(".tm-file-name");
let loaded = false;
toggle.textContent = `预览 ${ext} 全文`;
toggle.addEventListener("click", async () => {
const show = view.hidden;
view.hidden = !show;
toggle.textContent = `${show ? "收起" : "预览"} ${ext} 全文`;
if (show && !loaded) {
pre.textContent = "加载中…";
try {
const r = await apic.teamPreview({ result: TEAM.team, tool: TEAM.tool, id: a.id });
if (!r || !r.ok) throw new Error((r && r.error) || "预览失败");
nameEl.textContent = r.filename;
pre.textContent = r.content;
loaded = true;
} catch (err) {
pre.textContent = "⚠ " + (err.message || err);
}
}
});
div.querySelector(".tm-file-copy").addEventListener("click", () => {
if (pre.textContent && loaded) navigator.clipboard.writeText(pre.textContent).then(() => toast("已复制", "ok"));
});
}
async function tmDownload() {
if (!TEAM.team) return;
const btn = document.getElementById("tm-download");
busy(btn, true, "打包中…");
try {
const r = await apic.teamPack({ result: TEAM.team, tool: TEAM.tool });
if (r && r.ok) toast("已保存到 " + r.path, "ok");
else if (r && r.canceled) { /* 用户取消,静默 */ }
else throw new Error((r && r.error) || "打包失败");
} catch (err) {
toast("打包失败:" + (err.message || err), "err");
} finally {
busy(btn, false);
}
}
// —— 事件绑定 ——
(function tmWireEvents() {
// 入口在「插件市场」的工具卡片(data-open-panel="team"),懒加载由该处理器触发 tmEnter
const req = document.getElementById("tm-req");
if (req) req.addEventListener("input", tmUpdateGenBtn);
const toolChips = document.getElementById("tm-tool");
if (toolChips) {
toolChips.querySelectorAll(".tm-chip").forEach((chip) => {
chip.addEventListener("click", () => {
toolChips.querySelectorAll(".tm-chip").forEach((c) => c.classList.remove("active"));
chip.classList.add("active");
TEAM.tool = chip.dataset.tool;
const isClaude = TEAM.tool === "claude";
document.getElementById("tm-tool-hint").textContent = isClaude
? "生成纯 Claude 包:.claude/agents/*.md + CLAUDE.md 总控入口"
: "生成 .toml 员工 + 总控(调度 / 审核)+ AGENTS.md";
const hint = document.getElementById("tm-agent-hint");
const inp = document.getElementById("tm-agent-model");
inp.placeholder = isClaude ? "留空=继承会话模型(推荐),或填 sonnet / opus / haiku" : "留空=用上面的分析模型,如 gpt-4o";
hint.textContent = isClaude
? "Claude 只认 sonnet / opus / haiku / claude-*;留空则省略 model 字段=自动继承"
: "写进每个员工的 model 字段,改成你运行器支持的模型名";
// 已生成的团队:切工具后重新渲染(预览扩展名随之变)
if (TEAM.team) tmRenderTeam(TEAM.team);
});
});
}
const fetchBtn = document.getElementById("tm-fetch");
if (fetchBtn) fetchBtn.addEventListener("click", tmFetchModels);
const genBtn = document.getElementById("tm-gen");
if (genBtn) genBtn.addEventListener("click", tmGenerate);
const dlBtn = document.getElementById("tm-download");
if (dlBtn) dlBtn.addEventListener("click", tmDownload);
})();
// ===========================================================================
// ②.⑥ 提示词提取(图片 → 反推生图提示词)
// ===========================================================================
let pmLoaded = false;
const PM = {
groups: [],
activeGroupId: "",
models: [],
imageDataUrl: null,
};
function pmUpdateAnalyzeBtn() {
const btn = document.getElementById("pm-analyze");
if (!btn) return;
const model = document.getElementById("pm-model").value;
btn.disabled = !(PM.imageDataUrl && model);
}
async function pmEnter() {
if (pmLoaded) return;
pmLoaded = true;
// 加载分组
try {
const r = await apic.promptGroups();
if (r && r.ok) PM.groups = r.groups || [];
} catch { PM.groups = []; }
const sel = document.getElementById("pm-group");
if (sel) {
sel.innerHTML = PM.groups.length
? PM.groups.map((g) => `<option value="${esc(g.id)}">${esc(g.name || g.id)}${g.host ? " · " + esc(g.host) : ""}</option>`).join("")
: `<option value="">未配置可用分组(去『路由中转』添加)</option>`;
sel.value = PM.activeGroupId || "";
sel.onchange = () => { PM.activeGroupId = sel.value; };
}
pmUpdateAnalyzeBtn();
}
// 拉取所选分组的模型
async function pmFetchModels() {
const btn = document.getElementById("pm-fetch");
const sel = document.getElementById("pm-model");
const groupId = PM.activeGroupId || undefined;
busy(btn, true, "…");
try {
const r = await apic.promptModels(groupId);
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
PM.models = Array.isArray(r.models) ? r.models : [];
if (sel) {
sel.innerHTML = PM.models.length
? PM.models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
: `<option value="">该分组没有可用模型</option>`;
sel.onchange = pmUpdateAnalyzeBtn;
}
document.getElementById("pm-model-hint").textContent = `已拉取 ${PM.models.length} 个模型(需要视觉/多模态)`;
pmUpdateAnalyzeBtn();
toast(`已拉取 ${PM.models.length} 个模型`, "ok");
} catch (e) {
toast("拉取模型失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
}
// 拖拽 / 选择图片
function pmHandleFile(file) {
if (!file) return;
if (file.type.startsWith("video/")) {
pmGrabVideoFrame(file);
return;
}
if (!file.type.startsWith("image/")) { alert("请上传图片或视频文件"); return; }
const reader = new FileReader();
reader.onload = () => {
PM.imageDataUrl = reader.result;
document.getElementById("pm-previewImg").src = PM.imageDataUrl;
document.getElementById("pm-dzEmpty").hidden = true;
document.getElementById("pm-dzPreview").hidden = false;
pmUpdateAnalyzeBtn();
};
reader.onerror = () => toast("读取文件失败", "err");
reader.readAsDataURL(file);
}
// 视频取帧
function pmGrabVideoFrame(file) {
const video = document.createElement("video");
video.preload = "metadata"; video.muted = true; video.src = URL.createObjectURL(file);
video.onloadedmetadata = () => { video.currentTime = Math.min(1, video.duration / 2 || 0.5); };
video.onseeked = () => {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
URL.revokeObjectURL(video.src);
PM.imageDataUrl = canvas.toDataURL("image/jpeg", 0.92);
document.getElementById("pm-previewImg").src = PM.imageDataUrl;
document.getElementById("pm-dzEmpty").hidden = true;
document.getElementById("pm-dzPreview").hidden = false;
pmUpdateAnalyzeBtn();
};
video.onerror = () => { URL.revokeObjectURL(video.src); toast("无法读取视频文件", "err"); };
}
async function pmAnalyze() {
if (!PM.imageDataUrl) return;
const model = document.getElementById("pm-model").value;
if (!model) return;
const btn = document.getElementById("pm-analyze");
btn.disabled = true;
btn.textContent = "分析中…";
pmRenderLoading();
try {
const r = await apic.promptAnalyze({
image: PM.imageDataUrl,
model,
groupId: PM.activeGroupId || undefined,
});
if (!r || !r.ok) throw new Error((r && r.error) || "分析失败");
pmRenderResult(r.result);
} catch (err) {
pmRenderError(err.message || String(err));
} finally {
btn.textContent = "开始分析";
pmUpdateAnalyzeBtn();
}
}
// 清空右侧 body 渲染空状态
function pmRenderEmpty() {
const body = document.getElementById("pm-result-body");
if (!body) return;
body.innerHTML = `
<div class="pm-empty">
<div class="pm-empty-ico">🖼️→📝</div>
<p class="pm-empty-title">上传素材,反推它的生图提示词</p>
<p class="pm-empty-sub">精准分析九个视觉维度,输出可直接复现的双语提示词</p>
<div class="pm-empty-feats"><span>七维拆解</span><span>中英双语</span><span>负面提示词</span><span>关键词标签</span></div>
</div>`;
}
function pmRenderLoading() {
const body = document.getElementById("pm-result-body");
if (!body) return;
body.innerHTML = `
<div class="pm-loading">
<div class="pm-spinner"></div>
<div class="pm-loading-text">正在分析图片…</div>
</div>`;
}
function pmRenderError(msg) {
const body = document.getElementById("pm-result-body");
if (!body) return;
body.innerHTML = `<div class="pm-error">⚠ ${esc(msg)}</div>`;
}
function pmRenderResult(data) {
const body = document.getElementById("pm-result-body");
if (!body) return;
// 置信度徽标
const conf = data.confidence || "low";
const confLabel = { high: "高", medium: "中", low: "低" }[conf] || conf;
const confHTML = `<span class="pm-meta-conf pm-conf-${conf}">${confLabel}</span>`;
// 关键词标签
const tagsHTML = (data.tags_en || []).map((t) => `<span class="pm-tag">${esc(t)}</span>`).join("");
// 维度拆解
const breakdownHTML = (data.breakdown || []).map((d) => `
<div class="pm-bd-row">
<div class="pm-bd-label">
<span class="pm-bd-label-zh">${esc(d.label_zh || "")}</span>
<span class="pm-bd-label-en">${esc(d.label_en || "")}</span>
</div>
<div class="pm-bd-values">
<span class="pm-bd-zh">${esc(d.value_zh || "")}</span>
<span class="pm-bd-en">${esc(d.value_en || "")}</span>
</div>
</div>`).join("");
// 备注
const notesHTML = data.notes_zh ? `<p class="pm-notes">${esc(data.notes_zh)}</p>` : "";
body.innerHTML = `
<div class="pm-result-head">
<span class="pm-result-title">分析结果</span>
${confHTML}
</div>
<div class="pm-content">
<div class="pm-card primary">
<div class="pm-card-head">
<span class="pm-card-title">英文提示词 <em>推荐使用</em></span>
<button class="pm-copy-btn">复制</button>
</div>
<p class="pm-prompt-text">${esc(data.prompt_en || "")}</p>
</div>
<div class="pm-card">
<div class="pm-card-head">
<span class="pm-card-title">中文对照</span>
<button class="pm-copy-btn">复制</button>
</div>
<p class="pm-prompt-text">${esc(data.prompt_zh || "")}</p>
</div>
<div class="pm-card subtle">
<div class="pm-card-head">
<span class="pm-card-title">负面提示词 <em>Negative</em></span>
<button class="pm-copy-btn">复制</button>
</div>
<p class="pm-prompt-text mono">${esc(data.negative_prompt_en || "")}</p>
<p class="pm-prompt-text zh-sub">${esc(data.negative_prompt_zh || "")}</p>
</div>
${tagsHTML ? `<div class="pm-tags-wrap">${tagsHTML}</div>` : ""}
${breakdownHTML ? `<div class="pm-breakdown">${breakdownHTML}</div>` : ""}
${notesHTML}
</div>`;
}
// ---- 事件绑定 ----
(function pmWireEvents() {
const dropzone = document.getElementById("pm-dropzone");
const fileInput = document.getElementById("pm-fileInput");
if (dropzone) {
dropzone.addEventListener("click", () => fileInput && fileInput.click());
["dragover", "dragenter"].forEach((ev) =>
dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.add("drag"); })
);
["dragleave", "drop"].forEach((ev) =>
dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.remove("drag"); })
);
dropzone.addEventListener("drop", (e) => {
const f = e.dataTransfer.files[0];
if (f) pmHandleFile(f);
});
}
if (fileInput) fileInput.addEventListener("change", (e) => pmHandleFile(e.target.files[0]));
const clearBtn = document.getElementById("pm-clearBtn");
if (clearBtn) {
clearBtn.addEventListener("click", (e) => {
e.stopPropagation();
PM.imageDataUrl = null;
if (fileInput) fileInput.value = "";
document.getElementById("pm-dzPreview").hidden = true;
document.getElementById("pm-dzEmpty").hidden = false;
pmRenderEmpty(); // 重置右侧为空状态
pmUpdateAnalyzeBtn();
});
}
const fetchBtn = document.getElementById("pm-fetch");
if (fetchBtn) fetchBtn.addEventListener("click", pmFetchModels);
const analyzeBtn = document.getElementById("pm-analyze");
if (analyzeBtn) analyzeBtn.addEventListener("click", pmAnalyze);
// 复制按钮 —— 事件委托(CSP 禁止内联 onclick)
const resultBody = document.getElementById("pm-result-body");
if (resultBody) {
resultBody.addEventListener("click", (e) => {
const btn = e.target.closest(".pm-copy-btn");
if (!btn) return;
const card = btn.closest(".pm-card");
if (!card) return;
const textEl = card.querySelector(".pm-prompt-text");
if (!textEl?.textContent) return;
navigator.clipboard.writeText(textEl.textContent).then(() => toast("已复制", "ok"));
});
}
})();
// ===========================================================================
// ②.⑦ Token 用量监控面板
// ===========================================================================
let tkLoaded = false;
let tkPollTimer = null;
function fmtTokens(n) {
if (!n || n < 0) return "0";
if (n < 1000) return String(n);
if (n < 1000000) return (n / 1000).toFixed(1) + "K";
if (n < 1000000000) return (n / 1000000).toFixed(2) + "M";
return (n / 1000000000).toFixed(2) + "B";
}
function fmtBadgeTokens(n) {
n = Math.max(0, Math.round(Number(n) || 0));
if (n < 1000) return n + " token";
if (n < 1000000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k token";
if (n < 1000000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M token";
return (n / 1000000000).toFixed(1).replace(/\.0$/, "") + "B token";
}
const TOKEN_PROGRESS_CAP = 5000000000;
function tokenVisualProgressPct(total) {
total = Math.max(0, Math.round(Number(total) || 0));
if (!total) return 0;
const scale = 100000;
const pct = Math.log1p(total / scale) / Math.log1p(TOKEN_PROGRESS_CAP / scale) * 100;
return Math.max(2.4, Math.min(100, pct));
}
function paintTokenPreviewSegments(total) {
const pct = tokenVisualProgressPct(total);
const segs = Array.from(document.querySelectorAll(".tk-preview-seg"));
if (!segs.length) return;
const span = 100 / segs.length;
segs.forEach((seg, i) => {
const start = i * span;
const fill = Math.max(0, Math.min(100, ((pct - start) / span) * 100));
seg.style.setProperty("--tk-preview-seg-fill", fill + "%");
});
const badge = document.getElementById("tk-preview-badge-text");
if (badge) badge.textContent = fmtBadgeTokens(total);
}
function tkRenderStatus(s) {
const dot = document.getElementById("tk-dot");
const phaseText = document.getElementById("tk-phase-text");
if (dot) dot.dataset.state = (s && s.phase) || "unknown";
if (phaseText) {
const map = { connected: "已连接", disconnected: "未连接", transcribing: "已连接", recording: "已连接" };
phaseText.textContent = map[(s && s.phase)] || (s && s.phase) || "—";
}
}
async function tkRefresh() {
const totalEl = document.getElementById("tk-total");
const inputEl = document.getElementById("tk-input");
const outputEl = document.getElementById("tk-output");
const cachedEl = document.getElementById("tk-cached");
const estimateEl = document.getElementById("tk-estimate");
const unknownEl = document.getElementById("tk-unknown");
const dateEl = document.getElementById("tk-date");
const diagEl = document.getElementById("tk-diag");
try {
const r = await apic.tokenStatus();
tkRenderStatus({ phase: r && r.phase });
if (r && r.ok) {
const total = Number(r.total) || 0;
const input = Number(r.input) || 0;
const output = Number(r.output) || 0;
const cached = Number(r.cached) || 0;
const estimate = Number(r.estimate || r.estimated) || 0;
const unknown = Number(r.unknown) || 0;
if (totalEl) totalEl.textContent = fmtTokens(total);
if (inputEl) inputEl.textContent = fmtTokens(input);
if (outputEl) outputEl.textContent = fmtTokens(output);
if (cachedEl) cachedEl.textContent = fmtTokens(cached);
if (estimateEl) estimateEl.textContent = fmtTokens(estimate);
if (unknownEl) unknownEl.textContent = fmtTokens(unknown);
if (dateEl) dateEl.textContent = [r.date || "", r.lastSource ? "来源:" + r.lastSource : ""].filter(Boolean).join(" · ");
if (diagEl) {
const net = r.network || {};
const parts = [
r.lastSource === "session" ? "本地 session" : "页面 usage",
"worker " + (Number(net.attachedWorkers) || 0),
"Network 命中 " + (Number(net.matchedCount) || 0),
"usage " + (Number(net.usageCount) || 0),
"无 usage " + (Number(net.noUsageCount) || 0),
];
if (net.lastUsageTotal) parts.push("最近 +" + fmtTokens(Number(net.lastUsageTotal) || 0));
if (net.lastError) parts.push("错误:" + net.lastError);
diagEl.textContent = parts.join(" · ");
diagEl.title = [net.lastUsageUrl || "", net.lastNoUsageUrl || "", net.lastMatchedUrl || ""].filter(Boolean).join("\n");
}
paintTokenPreviewSegments(total);
} else {
if (totalEl) totalEl.textContent = "—";
if (inputEl) inputEl.textContent = "0";
if (outputEl) outputEl.textContent = "0";
if (cachedEl) cachedEl.textContent = "0";
if (estimateEl) estimateEl.textContent = "0";
if (unknownEl) unknownEl.textContent = "0";
if (dateEl) dateEl.textContent = r && r.error ? r.error : "";
if (diagEl) diagEl.textContent = "";
paintTokenPreviewSegments(0);
}
} catch (e) {
if (totalEl) totalEl.textContent = "—";
if (inputEl) inputEl.textContent = "0";
if (outputEl) outputEl.textContent = "0";
if (cachedEl) cachedEl.textContent = "0";
if (estimateEl) estimateEl.textContent = "0";
if (unknownEl) unknownEl.textContent = "0";
if (dateEl) dateEl.textContent = "读取失败";
if (diagEl) diagEl.textContent = "";
paintTokenPreviewSegments(0);
}
}
async function tkReset() {
if (!confirm("确定手动清零今日 Token 用量吗?此操作不可恢复。")) return;
try {
const r = await apic.tokenReset();
if (r && r.ok) { toast("已清零", "ok"); tkRefresh(); }
else throw new Error((r && r.error) || "重置失败");
} catch (e) {
toast("重置失败:" + (e.message || e), "err");
}
}
async function tkEnter() {
tkLoaded = true;
await tkRefresh();
// 每 5 秒轮询一次(Codex 已连接且用量在变化时)
if (!tkPollTimer) tkPollTimer = setInterval(tkRefresh, 5000);
}
(function tkWireEvents() {
const refreshBtn = document.getElementById("tk-refresh");
if (refreshBtn) refreshBtn.addEventListener("click", tkRefresh);
const resetBtn = document.getElementById("tk-reset");
if (resetBtn) resetBtn.addEventListener("click", tkReset);
})();
// —— 启动 ——
wireVoiceControls();
loadMarket();
loadSecrets();
loadVersion();
loadProxy();