Files

5666 lines
238 KiB
JavaScript
Raw Permalink 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;
}
}
function deferUI(fn, delay = 0) {
const run = () => {
try {
const r = fn();
if (r && typeof r.catch === "function") r.catch((e) => console.warn(e));
} catch (e) {
console.warn(e);
}
};
if (delay > 0) return setTimeout(run, delay);
if ("requestIdleCallback" in window) return window.requestIdleCallback(run, { timeout: 1200 });
return setTimeout(run, 0);
}
// —— 顶部广告:点击用系统浏览器打开官方推荐中转 ——
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;
}
if (name !== "canvas") {
const canvasPanel = document.getElementById("tab-canvas");
if (canvasPanel && canvasPanel.classList.contains("cv-fullscreen")) cvToggleFull(false);
}
$$(".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") {
loadSessionDataDir();
if (!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"));
});
// Codex 安装器:渲染层只触发固定主进程能力,不拼命令/路径。
let codexInstallerInfo = null;
const codexWinInstallBtn = $("#codex-win-install-btn");
const codexWinOpenUrlBtn = $("#codex-win-open-url-btn");
const codexMacInstallBtn = $("#codex-mac-install-btn");
const codexMacOpenUrlBtn = $("#codex-mac-open-url-btn");
const codexInstallerStatus = $("#codex-installer-status");
const codexInstallerProgressWrap = $("#codex-installer-progress-wrap");
const codexInstallerProgressBar = $("#codex-installer-progress-bar");
const codexInstallerCancelRow = $("#codex-installer-cancel-row");
const codexInstallerCancelBtn = $("#codex-installer-cancel-btn");
let codexInstallerActivePlatform = "";
function setCodexInstallerStatus(text) {
if (codexInstallerStatus) codexInstallerStatus.textContent = text;
}
function setCodexInstallerProgress(percent, active = true) {
if (!codexInstallerProgressWrap || !codexInstallerProgressBar) return;
codexInstallerProgressWrap.classList.toggle("hidden", !active);
const value = Math.max(0, Math.min(100, Number(percent) || 0));
codexInstallerProgressBar.style.width = `${value}%`;
}
function setCodexInstallerCancelVisible(visible, disabled = false) {
if (!codexInstallerCancelRow || !codexInstallerCancelBtn) return;
codexInstallerCancelRow.classList.toggle("hidden", !visible);
codexInstallerCancelBtn.disabled = !!disabled;
}
function setCodexInstallerControlsLocked(locked, activeBtn) {
[codexWinInstallBtn, codexMacInstallBtn, codexWinOpenUrlBtn, codexMacOpenUrlBtn].forEach((btn) => {
if (btn && btn !== activeBtn) btn.disabled = !!locked;
});
}
function isCodexInstallerCanceled(e) {
return /取消下载|已取消/.test(String(e && e.message ? e.message : e));
}
function handleCodexInstallerError(e) {
if (isCodexInstallerCanceled(e)) {
setCodexInstallerStatus("下载已取消");
setCodexInstallerProgress(0, false);
toast("下载已取消", "ok");
return;
}
const msg = e.message || e;
setCodexInstallerStatus(`安装失败:${msg}`);
toast(`安装失败:${msg}`, "err");
}
function codexInstallerProgressText(p) {
if (!p) return "";
const label = p.platform === "mac" ? "Mac" : "Windows";
if (p.phase === "download") {
const total = p.total ? ` / ${humanSize(p.total)}` : "";
const got = p.downloaded ? humanSize(p.downloaded) : "0B";
const percent = p.percent ? ` ${p.percent}%` : "";
return `${label} 下载中${percent}(${got}${total})`;
}
if (p.message) return p.message;
const map = {
checking: "读取安装包校验信息…",
cached: "已使用本地缓存",
verify: "校验安装包…",
downloaded: `${label} 安装包已下载并校验完成`,
install: p.platform === "mac" ? "正在安装 Mac 版…" : "正在调用 PowerShell 安装…",
done: p.platform === "mac" ? "Codex Mac 版安装完成" : "Codex Windows 版安装完成",
};
return map[p.phase] || "处理中…";
}
function updateCodexInstallerButton(p) {
const btn = p && p.platform === "mac" ? codexMacInstallBtn : codexWinInstallBtn;
if (!btn || btn.dataset.busyActive !== "1") return;
if (p.phase === "download" && p.percent) btn.textContent = `下载 ${p.percent}%`;
else if (p.phase === "verify") btn.textContent = "校验中…";
else if (p.phase === "downloaded") btn.textContent = "下载完成";
else if (p.phase === "install") btn.textContent = p.platform === "mac" ? "安装中…" : "安装中…";
}
if (apic.onCodexInstallerProgress) {
apic.onCodexInstallerProgress((p) => {
const text = codexInstallerProgressText(p);
if (text) setCodexInstallerStatus(text);
const active = p && !["done", "downloaded", "canceled"].includes(p.phase);
setCodexInstallerProgress(p && p.percent, active);
const cancelable = p && ["checking", "download", "verify"].includes(p.phase);
if (cancelable) {
codexInstallerActivePlatform = p.platform || codexInstallerActivePlatform;
setCodexInstallerCancelVisible(true, false);
} else if (p && p.phase === "canceling") {
setCodexInstallerCancelVisible(true, true);
} else if (p && ["install", "done", "downloaded", "canceled"].includes(p.phase)) {
setCodexInstallerCancelVisible(false);
}
updateCodexInstallerButton(p);
});
}
async function loadCodexInstallerInfo() {
if (!apic.codexInstallerInfo) return null;
codexInstallerInfo = await apic.codexInstallerInfo();
const platformLabel = { win32: "Windows", darwin: "macOS", linux: "Linux" }[codexInstallerInfo.platform] || codexInstallerInfo.platform;
if (codexInstallerInfo.isWindows) {
setCodexInstallerStatus("当前是 Windows:可远程下载、校验 SHA256 后安装。");
} else if (codexInstallerInfo.isMac) {
setCodexInstallerStatus("当前是 macOS:可远程下载 DMG、校验 SHA256 后安装到 /Applications。");
} else {
setCodexInstallerStatus(`当前是 ${platformLabel},可在软件内下载安装包后发给对应系统用户。`);
}
if (!codexInstallerInfo.isWindows && codexWinInstallBtn) {
if (codexWinInstallBtn) codexWinInstallBtn.textContent = "下载 Windows 包";
}
if (!codexInstallerInfo.isMac && codexMacInstallBtn) codexMacInstallBtn.textContent = "下载 Mac 包";
return codexInstallerInfo;
}
async function downloadCodexInstallerInApp(platform, btn) {
if (!apic.codexDownloadInstaller) throw new Error("当前版本缺少安装包下载能力,请先更新工具箱。");
const label = platform === "mac" ? "Mac" : "Windows";
codexInstallerActivePlatform = platform;
setCodexInstallerProgress(0, true);
setCodexInstallerCancelVisible(true, false);
setCodexInstallerControlsLocked(true, btn);
busy(btn, true, "准备下载…");
try {
const result = await apic.codexDownloadInstaller(platform);
setCodexInstallerStatus(`${label} 安装包已下载并校验完成:${result.path}`);
setCodexInstallerProgress(100, true);
toast(`${label} 安装包下载完成`, "ok");
return result;
} finally {
busy(btn, false);
setCodexInstallerControlsLocked(false);
setCodexInstallerCancelVisible(false);
}
}
if (codexInstallerCancelBtn) {
codexInstallerCancelBtn.addEventListener("click", async () => {
try {
if (!codexInstallerActivePlatform) return;
codexInstallerCancelBtn.disabled = true;
setCodexInstallerStatus("正在取消下载…");
await apic.codexCancelInstallerDownload(codexInstallerActivePlatform);
} catch (e) {
toast(`取消失败:${e.message || e}`, "err");
setCodexInstallerCancelVisible(true, false);
}
});
}
if (codexWinOpenUrlBtn) {
codexWinOpenUrlBtn.addEventListener("click", async () => {
try {
const info = codexInstallerInfo || await loadCodexInstallerInfo();
if (info && info.windowsUrl) {
await apic.openExternal(info.windowsUrl);
toast("已打开 Codex Windows 下载地址", "ok");
}
} catch (e) {
toast(`打开失败:${e.message || e}`, "err");
}
});
}
if (codexMacOpenUrlBtn) {
codexMacOpenUrlBtn.addEventListener("click", async () => {
try {
const info = codexInstallerInfo || await loadCodexInstallerInfo();
if (info && info.macUrl) {
await apic.openExternal(info.macUrl);
toast("已打开 Codex Mac 下载地址", "ok");
}
} catch (e) {
toast(`打开失败:${e.message || e}`, "err");
}
});
}
if (codexWinInstallBtn) {
codexWinInstallBtn.addEventListener("click", async () => {
try {
const info = codexInstallerInfo || await loadCodexInstallerInfo();
if (!info) throw new Error("安装器信息不可用");
if (!info.isWindows) {
await downloadCodexInstallerInApp("windows", codexWinInstallBtn);
return;
}
codexInstallerActivePlatform = "windows";
setCodexInstallerProgress(0, true);
setCodexInstallerCancelVisible(true, false);
setCodexInstallerControlsLocked(true, codexWinInstallBtn);
busy(codexWinInstallBtn, true, "准备中…");
await apic.codexInstallWindows();
setCodexInstallerStatus("Codex Windows 版安装完成");
setCodexInstallerProgress(100, true);
toast("Codex Windows 版安装完成", "ok");
} catch (e) {
handleCodexInstallerError(e);
} finally {
busy(codexWinInstallBtn, false);
setCodexInstallerControlsLocked(false);
setCodexInstallerCancelVisible(false);
}
});
}
if (codexMacInstallBtn) {
codexMacInstallBtn.addEventListener("click", async () => {
try {
const info = codexInstallerInfo || await loadCodexInstallerInfo();
if (!info) throw new Error("安装器信息不可用");
if (!info.isMac) {
await downloadCodexInstallerInApp("mac", codexMacInstallBtn);
return;
}
codexInstallerActivePlatform = "mac";
setCodexInstallerProgress(0, true);
setCodexInstallerCancelVisible(true, false);
setCodexInstallerControlsLocked(true, codexMacInstallBtn);
busy(codexMacInstallBtn, true, "准备中…");
await apic.codexInstallMac();
setCodexInstallerStatus("Codex Mac 版安装完成");
setCodexInstallerProgress(100, true);
toast("Codex Mac 版安装完成", "ok");
} catch (e) {
handleCodexInstallerError(e);
} finally {
busy(codexMacInstallBtn, false);
setCodexInstallerControlsLocked(false);
setCodexInstallerCancelVisible(false);
}
});
}
deferUI(() => loadCodexInstallerInfo().catch((e) => {
setCodexInstallerStatus(`安装器信息读取失败:${e.message || e}`);
}));
$$(".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") ? "▲" : "▼";
});
let logBuffer = [];
let logFlushTimer = 0;
let logLines = [];
function flushLogs() {
logFlushTimer = 0;
if (!logBuffer.length) return;
logLines.push(...logBuffer);
logBuffer = [];
if (logLines.length > 500) logLines = logLines.slice(-500);
const out = $("#log-out");
const panel = $("#log-panel");
out.textContent = logLines.join("\n") + "\n";
if (!panel.classList.contains("hidden")) panel.scrollTop = panel.scrollHeight;
}
apic.onLog((msg) => {
logBuffer.push(String(msg));
if (!logFlushTimer) logFlushTimer = setTimeout(flushLogs, 120);
});
// ===========================================================================
// 语音输入(CDP 注入 Codex 麦克风按钮)
// ===========================================================================
let voiceLoaded = false;
let voiceControlsWired = false;
let latestVoiceStatus = null;
const PHASE_TEXT = {
unknown: "未注入",
checking: "检测中…",
not_running: "Codex 未运行",
running_no_port: "需重启注入",
restarting: "正在重启 ChatGPT…",
waiting_debug_port: "等待调试端口…",
unsupported_packaged: "需官网版",
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-disable]").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.launchWarning) detailText += " " + s.launchWarning;
if (s && s.portDiagnostic) detailText += " " + s.portDiagnostic;
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语音输入、Token 用量和左侧任务行的彻底删除按钮都依赖此连接。当前 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");
else if (s.phase === "unsupported_packaged") toast(s.lastError || "当前 Store/MSIX 版不支持直接注入,请安装官网普通桌面版", "warn");
} 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 ? "ChatGPT 在运行,需重启以启用调试端口,请再次点击启用" : "ChatGPT 在运行,需重启以启用调试端口,请再次点击连接", "");
} else if (s.phase === "unsupported_packaged") {
toast(s.lastError || "当前 Store/MSIX 版不支持直接注入,请安装官网普通桌面版", "warn");
} else if (s.phase === "error") {
toast(s.lastError || "连接失败", "err");
} else if (s.phase === "connected") {
toast(isEnable ? "语音输入和左侧任务删除按钮已注入" : "已注入 Codex:语音、Token 与左侧任务删除按钮已启用");
}
} 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], [data-voice-disable]");
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 {}
}
deferUI(loadSttInfo, 1200);
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 storePage = 0;
const STORE_PAGE = 24;
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(opts = {}) {
try { installedSkills = await apic.skillInstalled(opts); }
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-foot">
<div class="sk-meta">${meta}</div>
<div class="sk-card-actions">
<button class="btn ghost small install" data-install="${esc(String(s.id))}" title="${isIn ? "重新安装技能" : "安装技能"}">${isIn ? "重新安装" : "安装"}</button>
</div>
</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) storePage = 0;
if (!list.length) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "没有匹配的技能,换个关键词或分类试试。";
more.classList.add("hidden");
return;
}
empty.classList.add("hidden");
const pageCount = Math.ceil(list.length / STORE_PAGE);
storePage = Math.min(storePage, pageCount - 1);
const first = storePage * STORE_PAGE;
const next = list.slice(first, first + STORE_PAGE);
grid.innerHTML = next.map(skillCardHTML).join("");
$("#store-cat-title").textContent =
storeCat === "__all__" ? "全部技能" : storeCat === "__hot__" ? "热门技能" : storeCat;
$("#store-count").textContent = `${list.length.toLocaleString()} 个`;
$("#store-cat-sub").textContent =
`通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`;
renderStorePager(more, pageCount, list.length);
bindStoreCards();
}
function renderStorePager(more, pageCount, total) {
more.classList.remove("hidden");
if (pageCount <= 1) {
more.innerHTML = `<span class="muted small">共 ${total.toLocaleString()} 个技能</span>`;
return;
}
const pages = new Set([0, pageCount - 1, storePage - 1, storePage, storePage + 1]);
const orderedPages = [...pages].filter((p) => p >= 0 && p < pageCount).sort((a, b) => a - b);
let last = -1;
const pageButtons = orderedPages.map((p) => {
const gap = p - last > 1 ? `<span class="store-page-gap">…</span>` : "";
last = p;
return `${gap}<button class="store-page-btn${p === storePage ? " active" : ""}" data-store-page="${p}" aria-label="第 ${p + 1} 页" aria-current="${p === storePage ? "page" : "false"}">${p + 1}</button>`;
}).join("");
more.innerHTML = `
<span class="store-page-summary">${storePage * STORE_PAGE + 1}-${Math.min((storePage + 1) * STORE_PAGE, total)} / ${total.toLocaleString()}</span>
<div class="store-pagination" aria-label="技能分页">
<button class="store-page-btn" data-store-page="${storePage - 1}" title="上一页" aria-label="上一页" ${storePage === 0 ? "disabled" : ""}>‹</button>
${pageButtons}
<button class="store-page-btn" data-store-page="${storePage + 1}" title="下一页" aria-label="下一页" ${storePage === pageCount - 1 ? "disabled" : ""}>›</button>
</div>`;
more.querySelectorAll("[data-store-page]").forEach((btn) => btn.addEventListener("click", () => {
const nextPage = Number(btn.dataset.storePage);
if (Number.isNaN(nextPage) || nextPage < 0 || nextPage >= pageCount || nextPage === storePage) return;
storePage = nextPage;
renderStore(false);
$("#market-grid").scrollIntoView({ block: "start", behavior: "smooth" });
}));
}
function bindStoreCards() {
const grid = $("#market-grid");
grid.querySelectorAll(".skill-card[data-id]").forEach((card) => {
if (card._bound) return; card._bound = true;
card.tabIndex = 0;
card.setAttribute("role", "button");
card.setAttribute("aria-label", `查看 ${card.querySelector(".sk-name")?.textContent || "技能"} 详情`);
const openDetail = () => openSkillDetail(card.dataset.id);
card.addEventListener("click", (ev) => {
if (ev.target.closest("[data-install]")) return;
openDetail();
});
card.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
openDetail();
});
});
grid.querySelectorAll("[data-install]").forEach((btn) => {
if (btn._bound) return; btn._bound = true;
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
installSkill(btn.dataset.install, btn);
});
});
}
// 「我已安装」视图:以磁盘 ~/.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-count").textContent = `${installedSkills.length.toLocaleString()} 个`;
$("#store-cat-sub").textContent =
`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({ includeSize: storeCat === "__installed__", includeMeta: true });
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 `<button type="button" class="nav-item${active}" data-cat="${esc(key)}" aria-pressed="${storeCat === key}">
<span class="nav-ico">${CAT_ICON[name] || "•"}</span>
<span class="nav-name">${esc(name)}</span>${c}</button>`;
};
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", () => selectStoreCategory(el.dataset.cat))
);
renderStoreCategoryChips();
}
function renderStoreCategoryChips() {
const chips = $("#store-category-chips");
if (!skillCat || !chips) return;
const quickCategories = skillCat.categories.slice(0, 9);
const item = (key, name) => `<button type="button" class="store-filter-chip${storeCat === key ? " active" : ""}" data-store-filter="${esc(key)}" aria-pressed="${storeCat === key}">${esc(name)}</button>`;
chips.innerHTML = [
item("__all__", "全部"),
item("__hot__", "热门"),
...quickCategories.map((c) => item(c.name, c.name)),
].join("");
chips.querySelectorAll("[data-store-filter]").forEach((el) =>
el.addEventListener("click", () => selectStoreCategory(el.dataset.storeFilter))
);
}
async function selectStoreCategory(category) {
storeCat = category;
$("#market-grid").scrollTop = 0;
if (storeCat === "__installed__") await loadInstalledSkills({ includeSize: true, includeMeta: true });
renderStoreNav();
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({ includeSize: storeCat === "__installed__", includeMeta: true });
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();
renderStoreNav();
renderStore(true);
deferUI(async () => {
await loadInstalledSkills({ includeSize: false, includeMeta: false });
renderStore(true);
}, 900);
} 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;
const SESS_DEFAULT_LIMIT = 500;
const SESS_ALL_LIMIT = 5000;
function renderSessionDataDir(info) {
if (!info || !info.path) return;
$("#session-data-dir-path").textContent = info.path;
$("#session-data-dir-path").title = info.path;
$("#mem-data-dir-path").textContent = info.path;
$("#mem-data-dir-path").title = info.path;
$("#session-data-dir-reset").classList.toggle("hidden", !!info.isDefault);
}
async function loadSessionDataDir() {
try {
renderSessionDataDir(await apic.sessionsDataDirInfo());
} catch {
$("#session-data-dir-path").textContent = "读取失败";
$("#mem-data-dir-path").textContent = "读取失败";
}
}
async function changeSessionDataDir(reset, btn) {
busy(btn, true, reset ? "恢复中…" : "切换中…");
try {
const info = reset ? await apic.sessionsDataDirReset() : await apic.sessionsDataDirSelect();
if (info.canceled) return;
renderSessionDataDir(info);
sessData = [];
sessCurrent = null;
sessLoaded = false;
$("#sess-conv").classList.add("hidden");
$("#sess-empty").classList.remove("hidden");
await sessLoad();
if (memLoaded) await memLoad();
toast(reset ? "已恢复默认 Codex 会话目录" : "已切换 Codex 会话目录,记忆监视已重启", "ok");
} catch (e) {
toast("切换目录失败:" + (e.message || e), "err");
} finally {
busy(btn, 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, limit: SESS_ALL_LIMIT }
: { includeSubagent: false, includeArchived: false, limit: SESS_DEFAULT_LIMIT }
);
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-delete").addEventListener("click", async (ev) => {
if (!sessCurrent) return;
const item = sessCurrent;
const confirmed = confirm("永久删除这条会话?\\n\\n将同时删除:会话正文、归档副本、Codex 本地索引、工具箱会话记忆。\\n此操作不可恢复,且需要先完全退出 Codex。");
if (!confirmed) return;
const btn = ev.currentTarget;
busy(btn, true, "删除中…");
try {
const r = await apic.sessionDeletePermanently(item.path);
if (!r || !r.ok) throw new Error((r && r.error) || "永久删除失败");
sessCurrent = null;
$("#sess-empty").classList.remove("hidden");
$("#sess-conv").classList.add("hidden");
await sessLoad();
if (r.cleanupWarning) toast("会话主体已删除,但工具箱记忆清理失败:" + r.cleanupWarning, "err");
else toast("会话及本地缓存已永久删除", "ok");
} catch (e) {
toast("永久删除失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#sess-search").addEventListener("input", sessRender);
$("#sess-clear-all").addEventListener("click", async (ev) => {
const status = await apic.providerSyncStatus();
if (status.running) {
toast("请先完全退出 Codex(Cmd+Q)再清除会话记录", "err");
return;
}
const confirmed = confirm(
"确定清除全部会话记录?\n\n将删除 sessions、archived_sessions、Codex 会话索引、状态库线程、侧栏目录和工具箱会话记忆中的全部对话和项目摘要(已固定的长期偏好会保留)。\n不会删除中转配置、登录凭证、项目源码、插件和自动化任务。\n\n执行前会自动备份,操作不可直接撤销。"
);
if (!confirmed) return;
const btn = ev.currentTarget;
busy(btn, true, "清除中…");
try {
const r = await apic.sessionsClearAll();
sessData = [];
sessCurrent = null;
$("#sess-empty").classList.remove("hidden");
$("#sess-conv").classList.add("hidden");
sessRender();
if (r.cleanupWarning) toast(`会话已清除,但会话记忆清理有警告:${r.cleanupWarning}`, "err");
else toast(`已清除 ${r.filesRemoved || 0} 个会话文件和 ${r.memoryRemoved || 0} 条会话记忆;执行前备份在 ${r.backup}`, "ok");
} catch (e) {
toast(`清除会话失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#sess-refresh").addEventListener("click", sessLoad);
$("#session-data-dir-select").addEventListener("click", (ev) => changeSessionDataDir(false, ev.currentTarget));
$("#session-data-dir-reset").addEventListener("click", (ev) => changeSessionDataDir(true, ev.currentTarget));
$("#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 catalogN = r.catalogRebuilt || 0;
const changedMsg = r.changedFiles > 0 || dbChanged > 0 || catalogN > 0
? `文件 ${r.changedFiles || 0} 条,数据库/项目索引 ${dbChanged} 项,侧栏目录 ${catalogN} 条`
: `全部已是最新,无需改动`;
$("#sync-progress-label").textContent = `完成 ✅ 共 ${r.totalConversations} 条会话,${changedMsg}`;
const tip = r.changedFiles > 0 || dbChanged > 0 || catalogN > 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 {
await loadSessionDataDir();
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 toolOnly = !g.isDefault && (g.codexImage || g.codexVideo || g.stt);
const badges =
(!toolOnly && g.id === activeId ? '<span class="pc-badge skill">当前</span>' : "") +
(g.isDefault ? ' <span class="pc-badge">默认·不可删</span>' : "") +
(needsRouter ? ' <span class="pc-badge warn">需路由</span>' : "") +
(toolOnly ? ' <span class="pc-badge plugin">插件专用</span>' : "");
const actions = g.isDefault
? '<span class="muted small">切回这里 = 直连你的账号</span><button class="btn small ghost" data-default-config="1">编辑</button>'
: `<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${toolOnly ? " is-disabled" : ""}">
<input type="radio" name="${radioName}" ${!toolOnly && g.id === activeId ? "checked" : ""} ${toolOnly ? "disabled" : ""} data-active="${g.id}" />
<div>
<div class="cl-name">${esc(g.name)} ${badges}${g.codexImage ? ' <span class="agent-badge mm">🎨 生图</span>' : ""}${g.codexVideo ? ' <span class="agent-badge mm">🎬 视频</span>' : ""}${g.stt ? ' <span class="agent-badge mm">🎤 语音' + (g.sttModel ? " " + esc(g.sttModel) : "") + '</span>' : ""}${g.multimodal ? ' <span class="agent-badge mm">多模态</span>' : ""}</div>
<div class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${needsRouter ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
${toolOnly ? '<div class="cl-tool-hint">画布/插件按需调用,不作为 Codex/GPT 默认对话分组</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 () => {
if (el.disabled) return;
const r = await apic.routerSetActive(el.dataset.active);
if (r && r._activeSkipped) {
renderRouter(r);
toast(r._activeReason || "该分组不能作为默认对话分组", "warn");
return;
}
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._needsCodexRefresh) {
toast("已秒切分组 ✅ 下一次 Codex 请求即用新模型;顶部模型选择器将在 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-default-config]").forEach((el) =>
el.addEventListener("click", openDefaultConfigModal)
);
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-codex-video").checked = !!(g && g.codexVideo);
$("#grp-stt").checked = !!(g && g.stt);
$("#grp-multimodal").checked = !!(g && g.multimodal);
$("#grp-stt-model").value = g && g.sttModel ? g.sttModel : "whisper-1";
grpUpdateSttModelField();
$("#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 = ""; }
grpUpdateMultimodalField();
grpUpdateClaudeDesk();
$("#grp-modal").classList.remove("hidden");
}
function closeGrpModal() {
$("#grp-modal").classList.add("hidden");
}
function grpUpdateSttModelField() {
const field = $("#grp-stt-model-field");
if (!field) return;
field.classList.toggle("hidden", !$("#grp-stt").checked);
}
function grpUpdateMultimodalField() {
const toggle = $("#grp-multimodal");
if (!toggle) return;
const enabled = $("#grp-mode").value === "chat";
if (!enabled) toggle.checked = false;
toggle.disabled = !enabled;
}
function fmtTime(ts) {
if (!ts) return "无快照时间";
try { return new Date(ts).toLocaleString(); } catch { return String(ts); }
}
function authLine(a) {
if (!a) return "凭证:未知";
const parts = [a.loginMode || "凭证:未知"];
if (a.hasOpenAIKey) parts.push("API Key " + (a.keyPreview || "已保存"));
if (a.hasTokens) parts.push("OAuth tokens 已保存");
return parts.join(" · ");
}
async function openDefaultConfigModal() {
const modal = $("#default-config-modal");
const summary = $("#default-config-summary");
const current = $("#default-config-current");
const snapshot = $("#default-config-snapshot");
modal.classList.remove("hidden");
summary.innerHTML = '<div><b>读取中</b>正在读取 ~/.codex/config.toml...</div>';
current.value = "";
snapshot.value = "";
try {
const info = await apic.routerDefaultConfig();
if (!info || !info.ok) throw new Error((info && info.error) || "读取失败");
const cur = info.current || {};
const snap = info.snapshot || {};
summary.innerHTML = [
`<div><b>当前配置</b>Provider:${esc(cur.modelProvider || "Codex 原生登录态")}<br>Model:${esc(cur.model || "未设置")}<br>状态:${cur.managed ? "当前被中转/路由接管" : "官方直连"}<br>${esc(authLine(cur.auth))}</div>`,
`<div><b>默认快照</b>Provider:${esc(snap.modelProvider || "Codex 原生登录态")}<br>Model:${esc(snap.model || "未设置")}<br>状态:${snap.managed ? "异常:仍含接管配置" : "干净官方配置"}<br>修复时间:${esc(fmtTime(snap.repairedAt || snap.savedAt))}</div>`,
`<div><b>config.toml</b>${esc(info.configPath || "~/.codex/config.toml")}</div>`,
`<div><b>auth.json</b>${esc(authLine(snap.auth))}<br>${esc(info.authPath || "~/.codex/auth.json")}</div>`,
].join("");
current.value = info.currentConfigToml || "(未读取到当前 config.toml)";
snapshot.value = info.snapshotConfigToml || "(没有默认快照内容:可能是旧版本创建的默认项,或首次记录时 config.toml 为空)";
} catch (e) {
summary.innerHTML = `<div><b>读取失败</b>${esc(e.message || e)}</div>`;
}
}
function closeDefaultConfigModal() {
$("#default-config-modal").classList.add("hidden");
}
// 刷新「接入 Claude 桌面 App」区:按分组协议启用/禁用 + 显示接入/备份状态
async function grpUpdateClaudeDesk() {
const block = $("#grp-claudedesk");
if (!block) return;
const isClaude = $("#grp-mode").value === "claude";
block.classList.toggle("hidden", !isClaude);
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);
$("#default-config-close").addEventListener("click", closeDefaultConfigModal);
$("#default-config-refresh").addEventListener("click", openDefaultConfigModal);
$("#default-config-copy").addEventListener("click", () => {
const text = $("#default-config-current").value;
if (text) navigator.clipboard.writeText(text).then(() => toast("已复制当前 config.toml", "ok"));
});
// 接口类型改变时,实时刷新「接入 Claude 桌面」区的可用性
$("#grp-mode").addEventListener("change", () => {
grpUpdateMultimodalField();
grpUpdateClaudeDesk();
});
$("#grp-stt").addEventListener("change", grpUpdateSttModelField);
// —— 接入 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();
});
$("#default-config-modal").addEventListener("click", (e) => {
if (e.target === $("#default-config-modal")) closeDefaultConfigModal();
});
$("#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,
codexVideo: $("#grp-codex-video").checked,
stt: $("#grp-stt").checked,
sttModel: $("#grp-stt").checked ? ($("#grp-stt-model").value.trim() || "whisper-1") : "",
multimodal: $("#grp-mode").value === "chat" && $("#grp-multimodal").checked,
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 {
if (routerState.running) {
const r = await apic.routerStop();
renderRouter(r.status || r);
if (r.releaseError) toast("本地路由已停止,但清理 Codex 接管配置失败:" + r.releaseError, "err");
else if (r.released && r.keptUserProvider) toast("本地路由已停止,已清理本工具配置并保留你手写的 provider", "ok");
else if (r.released) toast("本地路由已停止,已清理本工具配置", "ok");
else if (r.skippedRelease) toast("本地路由已停止,当前 Codex 配置未由本地路由接管", "warn");
} else {
renderRouter(await apic.routerStart());
}
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#router-refresh-codex").addEventListener("click", async (ev) => {
if (!confirm("将重启 Codex 以同步顶部模型选择器。\n\n登录态与历史会话保留,未保存的输入内容请先自行确认。是否继续?")) return;
const btn = ev.currentTarget;
busy(btn, true, "刷新中…");
try {
const s = await apic.voiceRestartCodex();
renderVoiceStatus(s);
if (s && s.ok && s.phase === "connected") toast("Codex 已重启,顶部模型选择器已刷新", "ok");
else toast((s && (s.error || s.lastError)) || "Codex 刷新失败", "warn");
} catch (e) {
toast("Codex 刷新失败:" + (e && e.message ? e.message : e), "err");
} finally {
busy(btn, false);
renderVoiceStatus(latestVoiceStatus || {});
}
});
$("#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;
}
if (h && (h.needFull || h.needFullAfterHot)) {
info.textContent = `需要安装完整包 v${h.fullAppVersion || h.contentVersion}`;
showUpdateModal({
version: h.fullAppVersion || h.contentVersion,
notes: `${h.notes || ""}${h.needFullAfterHot ? "\n\n本次更新还需要安装完整包,以注册 TD 工具箱深链协议。" : ""}`.trim(),
mandatory: h.mandatory,
url: RELEASE_FALLBACK,
kind: "full",
});
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 || "");
});
// ============================================================
// 创意画布(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,运行时传图床)
quickAddPos: null, // 双击空白处弹出菜单时记录的画布坐标
progressBound: false, // onCanvasProgress 是否已订阅(幂等)
portResizeObserver: null, // 预览框尺寸变化时同步端口位置
openConfigEl: null, // 当前展开参数配置的紧凑节点
openPopEl: null, // 当前展开的 details 参数弹层
portRefreshRAF: 0, // 全量端口刷新 rAF 合并锁
portRefreshNodeRAFs: {}, // 单节点端口刷新 rAF 合并锁
portResizeQueue: new Set(), // ResizeObserver 收集后统一刷新
portResizeRAF: 0,
portResizeTimer: 0,
dragStart: null, // 节点拖动阈值判断,避免拖动结束误触发配置展开
dragSuppressClick: false,
dragEndTimer: 0,
workspaceId: "",
workspaceName: "未命名画布",
workspaceReady: false,
workspaceRestoring: false,
workspaceSaveTimer: 0,
workspaceSaving: false,
workspaceResave: false,
workspaceSavePromise: null,
workspaceLoading: false,
workspaceList: [],
workspaceRenamingId: "",
outputRefs: {}, // { [nodeId]: { kind, assetId, sourceUrl?, meta? } },本地媒体引用
materialRefs: {}, // 上传的参考图 / 首尾帧 / 多参考素材引用
mediaCacheQueue: Promise.resolve(), // 多参考图批量上传时串行修改同一项目存档
};
function cvQueueMediaCache(payload) {
const run = () => apic.canvasMediaCache(payload);
CANVAS.mediaCacheQueue = CANVAS.mediaCacheQueue.then(run, run);
return CANVAS.mediaCacheQueue;
}
function cvWorkspaceTitle() {
const el = document.getElementById("cv-projects-btn");
if (el) el.textContent = CANVAS.workspaceName || "未命名画布";
}
function cvControlKey(el, index) {
const classes = Array.from(el.classList || []).filter((name) => name.startsWith("cv-")).sort().join(".");
return `${el.tagName}:${classes}:${el.dataset.slot || ""}:${index}`;
}
function cvCaptureNodeState() {
const out = {};
document.querySelectorAll("#drawflow .drawflow-node").forEach((nodeEl) => {
const id = nodeEl.id.replace("node-", "");
if (!id) return;
const fields = {};
Array.from(nodeEl.querySelectorAll("input, textarea, select")).forEach((el, index) => {
if (el.type === "file") return;
fields[cvControlKey(el, index)] = el.value;
});
out[id] = { fields };
});
return out;
}
function cvApplyNodeState(nodeEl, state) {
if (!nodeEl || !state || !state.fields) return;
Array.from(nodeEl.querySelectorAll("input, textarea, select")).forEach((el, index) => {
if (el.type === "file") return;
const value = state.fields[cvControlKey(el, index)];
if (value === undefined) return;
if (el.tagName === "SELECT" && value && !Array.from(el.options).some((option) => option.value === value)) {
const option = document.createElement("option");
option.value = value;
option.textContent = value + "(上次使用)";
el.appendChild(option);
}
el.value = value;
});
cvSyncVideoMode(nodeEl);
cvUpdateParamSummary(nodeEl);
}
function cvWorkspaceSnapshot() {
const ed = CANVAS.editor;
if (!ed || !CANVAS.workspaceId) return null;
let drawflow;
try { drawflow = ed.export(); } catch { return null; }
return {
id: CANVAS.workspaceId,
name: CANVAS.workspaceName,
canvas: {
drawflow,
module: ed.module || "Home",
zoom: ed.zoom || 1,
canvasX: ed.canvas_x || 0,
canvasY: ed.canvas_y || 0,
addCount: CANVAS.addCount || 0,
},
nodeState: cvCaptureNodeState(),
outputs: CANVAS.outputRefs,
materials: CANVAS.materialRefs,
};
}
function cvScheduleAutosave() {
if (!CANVAS.workspaceReady || CANVAS.workspaceRestoring || !CANVAS.workspaceId) return;
clearTimeout(CANVAS.workspaceSaveTimer);
CANVAS.workspaceSaveTimer = setTimeout(() => cvSaveWorkspace(), 700);
}
async function cvSaveWorkspace() {
clearTimeout(CANVAS.workspaceSaveTimer);
CANVAS.workspaceSaveTimer = 0;
if (!apic.canvasWorkspaceSave) return;
if (CANVAS.workspaceSaving) {
CANVAS.workspaceResave = true;
return CANVAS.workspaceSavePromise;
}
CANVAS.workspaceSaving = true;
CANVAS.workspaceSavePromise = (async () => {
try {
do {
CANVAS.workspaceResave = false;
await CANVAS.mediaCacheQueue;
const snapshot = cvWorkspaceSnapshot();
if (!snapshot) return;
const r = await apic.canvasWorkspaceSave(snapshot);
if (!r || !r.ok) throw new Error((r && r.error) || "本地画布保存失败");
CANVAS.workspaceName = r.workspace.name || CANVAS.workspaceName;
cvWorkspaceTitle();
} while (CANVAS.workspaceResave);
} catch (e) {
console.warn("canvas autosave failed", e);
} finally {
CANVAS.workspaceSaving = false;
CANVAS.workspaceSavePromise = null;
}
})();
return CANVAS.workspaceSavePromise;
}
function cvWorkspaceNameForNow() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `画布 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function cvRestoreOutputs() {
const entries = Object.entries(CANVAS.outputRefs || {});
await Promise.all(entries.map(async ([nodeId, ref]) => {
if (!ref || !ref.assetId || !apic.canvasMediaRead) return;
try {
const r = await apic.canvasMediaRead({ workspaceId: CANVAS.workspaceId, assetId: ref.assetId });
if (!r || !r.ok || !r.dataUrl) return;
const nodeEl = document.getElementById(`node-${nodeId}`);
if (!nodeEl) return;
const host = cvResultHost(nodeEl, ref.kind);
cvRenderResult(ref.kind, host, { dataUrl: r.dataUrl, url: ref.sourceUrl || "", meta: ref.meta || {} });
if (ref.kind === "image") CANVAS.outputs[nodeId] = { image: r.dataUrl };
} catch (e) {
console.warn("canvas media restore failed", e);
}
}));
}
async function cvReadAsset(assetId) {
if (!assetId || !apic.canvasMediaRead) return "";
const r = await apic.canvasMediaRead({ workspaceId: CANVAS.workspaceId, assetId });
return r && r.ok ? r.dataUrl || "" : "";
}
async function cvRestoreMaterials() {
const entries = Object.entries(CANVAS.materialRefs || {});
await Promise.all(entries.map(async ([nodeId, refs]) => {
const nodeEl = document.getElementById(`node-${nodeId}`);
if (!nodeEl || !refs) return;
try {
if (refs.image && refs.image.assetId) {
const dataUrl = await cvReadAsset(refs.image.assetId);
if (dataUrl) {
CANVAS.refImages[nodeId] = dataUrl;
const thumb = nodeEl.querySelector(".cv-refimg-thumb");
if (thumb) { thumb.src = dataUrl; thumb.classList.remove("hidden"); }
nodeEl.querySelector(".cv-refimg-clear")?.classList.remove("hidden");
const frame = nodeEl.querySelector(".cv-image-frame .cv-video-frame-inner");
if (frame && !CANVAS.outputRefs[nodeId]) frame.innerHTML = cvPreviewChrome("image", dataUrl, "", '<div class="cv-meta">参考图 · 本地缓存</div>');
}
}
const mats = (CANVAS.videoMaterials[nodeId] = { refs: [] });
for (const slot of ["first", "last"]) {
if (!refs[slot] || !refs[slot].assetId) continue;
const dataUrl = await cvReadAsset(refs[slot].assetId);
if (!dataUrl) continue;
mats[slot] = dataUrl;
const thumb = nodeEl.querySelector(`.cv-vmat-thumb[data-slot="${slot}"]`);
if (thumb) { thumb.src = dataUrl; thumb.classList.remove("hidden"); }
nodeEl.querySelector(`.cv-vmat-clear[data-slot="${slot}"]`)?.classList.remove("hidden");
}
if (Array.isArray(refs.refs)) {
const list = await Promise.all(refs.refs.map((item) => cvReadAsset(item.assetId)));
mats.refs = list.filter(Boolean);
const box = nodeEl.querySelector(".cv-vmat-refs-thumbs");
if (box) {
box.innerHTML = mats.refs.map((src) => `<img src="${src}" alt="参考图" style="max-width:40px;max-height:40px;border-radius:4px;object-fit:cover;margin:2px">`).join("");
}
if (mats.refs.length) nodeEl.querySelector('.cv-vmat-clear[data-slot="refs"]')?.classList.remove("hidden");
}
} catch (e) {
console.warn("canvas material restore failed", e);
}
}));
}
async function cvApplyWorkspace(workspace) {
const ed = CANVAS.editor;
if (!ed || !workspace) return;
CANVAS.workspaceRestoring = true;
try {
CANVAS.workspaceId = workspace.id;
CANVAS.workspaceName = workspace.name || "未命名画布";
CANVAS.outputs = {};
CANVAS.outputRefs = workspace.outputs && typeof workspace.outputs === "object" ? workspace.outputs : {};
CANVAS.materialRefs = workspace.materials && typeof workspace.materials === "object" ? workspace.materials : {};
CANVAS.refImages = {};
CANVAS.videoMaterials = {};
const canvas = workspace.canvas || {};
const drawflow = canvas.drawflow && canvas.drawflow.drawflow ? canvas.drawflow : { drawflow: { Home: { data: {} } } };
// 存档只继承拓扑/位置/数据;节点 HTML 使用当前版本模板,避免热更新后旧画布卡在旧 UI。
Object.values(drawflow.drawflow || {}).forEach((moduleData) => {
Object.values((moduleData && moduleData.data) || {}).forEach((node) => {
if (node && CV_NODES[node.name]) {
node.html = cvNodeHTML(node.name);
node.class = `cv-df-${node.name}`;
}
});
});
ed.import(drawflow);
ed.module = canvas.module && drawflow.drawflow[canvas.module] ? canvas.module : "Home";
CANVAS.addCount = Number(canvas.addCount) || 0;
ed.zoom = Number(canvas.zoom) || 1;
ed.canvas_x = Number(canvas.canvasX) || 0;
ed.canvas_y = Number(canvas.canvasY) || 0;
ed.zoom_last_value = ed.zoom;
ed.precanvas.style.transform = `translate(${ed.canvas_x}px, ${ed.canvas_y}px) scale(${ed.zoom})`;
Object.keys(cvAllNodes()).forEach((id) => {
cvSetupNode(id);
cvApplyNodeState(document.getElementById(`node-${id}`), (workspace.nodeState || {})[id]);
});
cvWorkspaceTitle();
cvSyncZoomControls(true);
cvUpdateEmptyHint();
cvRefreshAllNodePorts();
await cvRestoreMaterials();
await cvRestoreOutputs();
} finally {
CANVAS.workspaceRestoring = false;
CANVAS.workspaceReady = true;
}
}
async function cvCreateWorkspace() {
if (!apic.canvasWorkspaceCreate) throw new Error("画布本地存储能力未就绪,请先热更新");
await cvSaveWorkspace();
const r = await apic.canvasWorkspaceCreate(cvWorkspaceNameForNow());
if (!r || !r.ok) throw new Error((r && r.error) || "新建画布失败");
await cvApplyWorkspace(r.workspace);
cvCloseWorkspacePicker();
toast("已新建画布,编辑内容会自动保存到本机", "ok");
}
function cvWorkspaceTime(value) {
const date = new Date(value || 0);
return Number.isNaN(date.getTime()) ? "刚刚保存" : date.toLocaleString();
}
function cvRenderWorkspaceList() {
const list = document.getElementById("cv-project-list");
if (!list) return;
const items = CANVAS.workspaceList || [];
if (!items.length) {
list.innerHTML = '<div class="cv-project-empty">还没有画布记录,新建一个开始创作。</div>';
return;
}
document.getElementById("cv-project-close")?.classList.toggle("hidden", !CANVAS.workspaceReady);
list.innerHTML = items.map((item) => {
const renaming = item.id === CANVAS.workspaceRenamingId;
return `
<div class="cv-project-item ${item.id === CANVAS.workspaceId ? "active" : ""}">
<div class="cv-project-info">${renaming
? `<input class="cv-project-title-input" type="text" maxlength="60" value="${esc(item.name || "未命名画布")}" data-cv-rename-input="${esc(item.id)}" aria-label="画布名称" />`
: `<div class="cv-project-title">${esc(item.name || "未命名画布")}</div>`}
<div class="cv-project-meta">${esc(cvWorkspaceTime(item.updatedAt))} · ${Number(item.nodeCount) || 0} 个节点 · ${Number(item.mediaCount) || 0} 个本地素材</div></div>
<div class="cv-project-item-actions">
${renaming
? `<button class="cv-project-icon ok" type="button" data-cv-rename-save="${esc(item.id)}" title="保存名称" aria-label="保存名称">✓</button>
<button class="cv-project-icon" type="button" data-cv-rename-cancel title="取消改名" aria-label="取消改名">×</button>`
: `<button class="cv-project-icon" type="button" data-cv-rename-start="${esc(item.id)}" title="修改画布名称" aria-label="修改画布名称">✎</button>
<button class="btn small ghost cv-project-open" type="button" data-cv-workspace-id="${esc(item.id)}">${item.id === CANVAS.workspaceId ? "当前画布" : "继续"}</button>`}
</div>
</div>`;
}).join("");
const input = list.querySelector("[data-cv-rename-input]");
if (input) { input.focus(); input.select(); }
}
async function cvRenameWorkspace(id) {
const input = document.querySelector(`[data-cv-rename-input="${id}"]`);
const name = input ? input.value.trim() : "";
if (!name) {
toast("画布名称不能为空", "err");
input?.focus();
return;
}
const r = await apic.canvasWorkspaceRename(id, name);
if (!r || !r.ok) throw new Error((r && r.error) || "画布改名失败");
const item = CANVAS.workspaceList.find((entry) => entry.id === id);
if (item) Object.assign(item, r.workspace);
if (CANVAS.workspaceId === id) {
CANVAS.workspaceName = r.workspace.name;
cvWorkspaceTitle();
}
CANVAS.workspaceRenamingId = "";
cvRenderWorkspaceList();
toast("画布名称已修改", "ok");
}
async function cvOpenWorkspacePicker() {
const picker = document.getElementById("cv-project-picker");
if (!picker) return;
try {
const r = await apic.canvasWorkspaces();
CANVAS.workspaceList = r && r.ok && Array.isArray(r.workspaces) ? r.workspaces : [];
} catch (e) {
CANVAS.workspaceList = [];
}
cvRenderWorkspaceList();
picker.classList.remove("hidden");
}
function cvCloseWorkspacePicker() {
if (!CANVAS.workspaceReady) return;
document.getElementById("cv-project-picker")?.classList.add("hidden");
}
async function cvLoadWorkspace(id) {
if (!id || id === CANVAS.workspaceId) return cvCloseWorkspacePicker();
await cvSaveWorkspace();
const r = await apic.canvasWorkspaceLoad(id);
if (!r || !r.ok) throw new Error((r && r.error) || "读取画布记录失败");
await cvApplyWorkspace(r.workspace);
cvCloseWorkspacePicker();
toast("已恢复画布记录和本地生成素材", "ok");
}
async function cvCacheOutput(nodeId, kind, result, context) {
const workspaceId = (context && context.workspaceId) || CANVAS.workspaceId;
if (!workspaceId || !result || !result.dataUrl || !apic.canvasMediaCache) return;
const r = await cvQueueMediaCache({
workspaceId, nodeId, kind, dataUrl: result.dataUrl, role: "output",
sourceUrl: result.url || "", meta: result.meta || {},
workspaceState: CANVAS.workspaceId === workspaceId ? cvWorkspaceSnapshot() : null,
});
if (!r || !r.ok) throw new Error((r && r.error) || "生成结果本地缓存失败");
if (CANVAS.workspaceId !== workspaceId) return;
const previous = CANVAS.outputRefs[nodeId];
const history = previous && Array.isArray(previous.history) ? previous.history.slice() : [];
if (previous && previous.assetId && previous.assetId !== r.assetId) {
history.push({ ...previous, history: undefined });
}
CANVAS.outputRefs[nodeId] = {
kind, assetId: r.assetId, sourceUrl: result.url || "", meta: result.meta || {},
cachedAt: new Date().toISOString(), history,
};
await cvSaveWorkspace();
}
async function cvCacheMaterial(nodeId, slot, dataUrl, context) {
const workspaceId = (context && context.workspaceId) || CANVAS.workspaceId;
if (!workspaceId || !dataUrl || !apic.canvasMediaCache) return;
try {
const r = await cvQueueMediaCache({
workspaceId, nodeId, kind: "image", dataUrl, role: "material", slot,
workspaceState: (context && context.workspaceState) || cvWorkspaceSnapshot(),
});
if (!r || !r.ok) throw new Error((r && r.error) || "素材缓存失败");
if (CANVAS.workspaceId !== workspaceId) return;
const refs = (CANVAS.materialRefs[nodeId] = CANVAS.materialRefs[nodeId] || { refs: [] });
const value = { assetId: r.assetId };
if (slot === "refs") refs.refs = (refs.refs || []).concat(value);
else refs[slot] = value;
cvScheduleAutosave();
} catch (e) {
toast("参考素材本地缓存失败:" + (e.message || e), "err");
}
}
// 4 类节点的元信息:标题 / 图标 / 输入端口数 / 输出端口数 / 角标
const CV_NODES = {
text: { title: "文本 / 提示词", icon: "📝", inputs: 0, outputs: 1, tag: "" },
image: { title: "文生图", icon: "🖼️", inputs: 1, outputs: 1, tag: "" },
video: { title: "Seedance 视频", icon: "🎬", inputs: 1, outputs: 0, tag: "V1/V2" },
grokVideo: { title: "Grok 视频", icon: "🎞️", inputs: 1, outputs: 0, tag: "Grok" },
tts: { title: "TTS 配音", icon: "🔊", inputs: 1, outputs: 0, tag: "" },
};
function cvCompactVideoBody(kind) {
const isGrok = kind === "grokVideo";
const title = isGrok ? "Grok 视频" : "视频";
const modelHint = isGrok ? "Grok 模型" : "Seedance / V2 模型";
const resolutionOptions = isGrok
? `<option value="720p">720p</option><option value="1080p">1080p</option><option value="480p">480p</option>`
: `<option value="720p">720p</option><option value="1080p">1080p</option><option value="2k">2K(需上游支持)</option><option value="4k">4K(需上游支持)</option><option value="480p">480p</option>`;
const ratioOptions = isGrok
? `<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>`
: `<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>`;
return `
<div class="cv-video-compact">
<div class="cv-video-label"><span>▦</span>${title}</div>
<div class="cv-video-frame" data-port-anchor>
<div class="cv-video-frame-inner">▣</div>
</div>
<div class="cv-video-foot">
<span>参考图 0 张(连线 / @ 同步)</span>
<b>待定</b>
</div>
<div class="cv-video-composer">
<div class="cv-video-compose-tools">
<span>↖</span>
<span>+</span>
<span class="cv-video-expand">⛶</span>
</div>
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="2" placeholder="描述任何你想要生成的内容"></textarea>
<div class="cv-video-chip-row">
<select class="cv-field cv-field-group cv-chip-select" title="中转分组"></select>
<select class="cv-field cv-field-model cv-chip-select" title="${modelHint}"><option value="">${modelHint}</option></select>
<button class="cv-fetch-models cv-chip-action" title="拉取该分组的模型">拉取</button>
<details class="cv-video-pop">
<summary>文生 · 16:9 · 720p · 5s</summary>
<div class="cv-video-pop-body">
<label>生成方式<select class="cv-field cv-field-vmode" title="模式">
<option value="text">文生视频</option>
<option value="first">首帧图生视频</option>
<option value="reference">多参考图</option>
${isGrok ? "" : '<option value="firstlast">双图参考</option>'}
</select></label>
<label>比例<select class="cv-field cv-field-ratio" title="比例">${ratioOptions}</select></label>
<label>清晰度<select class="cv-field cv-field-vsize" title="分辨率">
${resolutionOptions}
</select></label>
<label>时长<select class="cv-field cv-field-duration" title="时长">
<option value="5">5s</option><option value="6">6s</option><option value="8">8s</option>
<option value="10">10s</option><option value="15">15s</option>
</select></label>
<label>运镜<select class="cv-field cv-field-vquality" title="画质">
<option value="auto">自动</option><option value="hd">高清</option>
</select></label>
<label class="cv-pop-wide">高级<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" /></label>
<select class="cv-field cv-field-refsource hidden" title="多参考图字段">
<option value="images">images</option>
<option value="ref_assets">ref_assets</option>
</select>
<button type="button" class="cv-pop-close">收起</button>
</div>
</details>
<button class="cv-run cv-send-run" title="生成">↑</button>
</div>
<div class="cv-video-materials">
<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="首帧" />
<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="尾帧" />
<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>
<textarea class="cv-field cv-vmat-urls" data-slot="refs" rows="2" placeholder="可选:每行一个公网图片URL"></textarea>
</div>
</div>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>
</div>
</div>`;
}
function cvCompactImageBody() {
return `
<div class="cv-video-compact cv-image-compact">
<div class="cv-video-label"><span>▧</span>图片</div>
<div class="cv-video-frame cv-image-frame" data-port-anchor>
<div class="cv-video-frame-inner">▧</div>
</div>
<div class="cv-video-foot">
<span>参考图 0 张(连线 / @ 同步)</span>
<b>待定</b>
</div>
<div class="cv-video-composer">
<div class="cv-video-compose-tools">
<span>↖</span>
<span>+</span>
<span class="cv-video-expand">⛶</span>
</div>
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="2" placeholder="描述任何你想要生成的画面"></textarea>
<div class="cv-video-chip-row">
<select class="cv-field cv-field-group cv-chip-select" title="中转分组"></select>
<input class="cv-field cv-field-model cv-chip-select" placeholder="图片模型" />
<button class="cv-fetch-models cv-chip-action" title="拉取该分组的模型">拉取</button>
<datalist class="cv-node-datalist"></datalist>
<details class="cv-video-pop">
<summary>1:1 · 1K</summary>
<div class="cv-video-pop-body">
<label>比例<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></label>
<label>清晰度<select class="cv-field cv-field-quality" title="清晰度">
<option value="1K">1K</option><option value="2K">2K</option><option value="4K">4K</option>
</select></label>
<label class="cv-pop-wide">接口<input class="cv-field cv-field-endpoint" placeholder="/v1/images/generations" /></label>
<label class="cv-pop-wide">参考字段<input class="cv-field cv-field-reffield" placeholder="image" /></label>
<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="参考图" />
<button type="button" class="cv-refimg-clear hidden" title="移除参考图">✕</button>
</div>
<button type="button" class="cv-pop-close">收起</button>
</div>
</details>
<button class="cv-run cv-send-run" title="生成">↑</button>
</div>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>
</div>
</div>`;
}
function cvCompactTtsBody() {
return `
<div class="cv-video-compact cv-tts-compact">
<div class="cv-video-label"><span>♪</span>音频</div>
<div class="cv-video-frame cv-audio-frame" data-port-anchor>
<div class="cv-video-frame-inner">♪</div>
</div>
<div class="cv-video-foot">
<span>连接文本 / 手动输入</span>
<b>待定</b>
</div>
<div class="cv-video-composer">
<div class="cv-video-compose-tools">
<span>↖</span>
<span>+</span>
<span class="cv-video-expand">⛶</span>
</div>
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="2" placeholder="输入要配音的文本,或连接上游文本节点"></textarea>
<div class="cv-video-chip-row">
<select class="cv-field cv-field-group cv-chip-select" title="中转分组"></select>
<input class="cv-field cv-field-model cv-chip-select" placeholder="TTS 模型" />
<button class="cv-fetch-models cv-chip-action" title="拉取该分组的模型">拉取</button>
<datalist class="cv-node-datalist"></datalist>
<details class="cv-video-pop">
<summary>alloy · mp3</summary>
<div class="cv-video-pop-body">
<label>音色<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></label>
<label>格式<select class="cv-field cv-field-format" title="格式">
<option value="mp3">mp3</option><option value="wav">wav</option><option value="opus">opus</option>
</select></label>
<button type="button" class="cv-pop-close">收起</button>
</div>
</details>
<button class="cv-run cv-send-run" title="生成">↑</button>
</div>
<div class="cv-node-err hidden"></div>
<div class="cv-node-preview hidden"></div>
</div>
</div>`;
}
function cvCompactTextBody() {
return `
<div class="cv-video-compact cv-text-compact">
<div class="cv-video-label"><span>T</span>文本</div>
<div class="cv-video-frame cv-text-frame" data-port-anchor>
<div class="cv-video-frame-inner">T</div>
</div>
<div class="cv-video-foot">
<span>作为输入源输出文本</span>
<b>文本</b>
</div>
<div class="cv-video-composer">
<div class="cv-video-compose-tools">
<span>↖</span>
<span>+</span>
</div>
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="3" placeholder="输入文本 / 提示词,连线到下游节点作为输入"></textarea>
<div class="cv-node-hint">作为输入源,实时输出上面的文本</div>
</div>
</div>`;
}
// 各类节点的表单主体(Drawflow 以 HTML 字符串注入到 .drawflow_content_node)
function cvNodeBody(kind) {
if (kind === "text") {
return cvCompactTextBody();
return `
<textarea class="cv-field cv-field-prompt" rows="4" placeholder="输入文本 / 提示词,连线到下游节点作为输入…"></textarea>
<div class="cv-node-hint">作为输入源,实时输出上面的文本</div>`;
}
if (kind === "image") {
return cvCompactImageBody();
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 cvCompactVideoBody("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">支持 Seedance / Seedance V2:切换分组后自动匹配接口</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 cvCompactVideoBody("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 cvCompactTtsBody();
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>`;
}
function cvCloseConfig(compact) {
const box = compact || CANVAS.openConfigEl;
if (!box) return;
box.classList.remove("cv-config-open");
box.closest(".drawflow-node")?.classList.remove("cv-node-config-open");
if (CANVAS.openConfigEl === box) CANVAS.openConfigEl = null;
}
function cvOpenConfig(compact) {
if (!compact) return;
if (CANVAS.openConfigEl && CANVAS.openConfigEl !== compact) cvCloseConfig(CANVAS.openConfigEl);
compact.classList.add("cv-config-open");
compact.closest(".drawflow-node")?.classList.add("cv-node-config-open");
CANVAS.openConfigEl = compact;
}
function cvCloseOpenPop(except) {
const pop = CANVAS.openPopEl;
if (!pop || pop === except) return;
pop.open = false;
if (CANVAS.openPopEl === pop) CANVAS.openPopEl = null;
}
function cvMarkCanvasDragging(on) {
const el = document.getElementById("drawflow");
if (!el) return;
el.classList.toggle("cv-dragging", !!on);
}
// 懒初始化 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; });
editor.on("connectionStart", () => el.classList.add("cv-linking"));
editor.on("connectionCancel", () => el.classList.remove("cv-linking"));
editor.on("connectionCreated", () => { el.classList.remove("cv-linking"); cvScheduleAutosave(); });
editor.on("connectionRemoved", () => cvScheduleAutosave());
editor.on("nodeCreated", () => cvScheduleAutosave());
editor.on("nodeMoved", () => cvScheduleAutosave());
editor.on("translate", () => cvScheduleAutosave());
editor.on("mouseUp", () => el.classList.remove("cv-linking"));
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) => {
if (CANVAS.openConfigEl?.closest(".drawflow-node")?.id === `node-${id}`) CANVAS.openConfigEl = null;
if (CANVAS.openPopEl?.closest(".drawflow-node")?.id === `node-${id}`) CANVAS.openPopEl = null;
delete CANVAS.outputs[id];
delete CANVAS.refImages[id];
delete CANVAS.videoMaterials[id];
delete CANVAS.outputRefs[id];
cvClearRunMapForNode(id);
cvUpdateEmptyHint();
cvScheduleAutosave();
});
// 点击空白画布直接进入沉浸式全屏;点节点、连线、工具条则不触发
const panel = document.getElementById("tab-canvas");
const stage = panel ? panel.querySelector(".cv-stage") : null;
if (stage && !stage.dataset.fullscreenBound) {
stage.dataset.fullscreenBound = "1";
let blankDown = null;
stage.addEventListener("pointerdown", (e) => {
blankDown = { x: e.clientX, y: e.clientY };
});
stage.addEventListener("click", (e) => {
if (panel && panel.classList.contains("cv-fullscreen")) return;
if (!cvIsBlankCanvasClick(e)) return;
if (blankDown) {
const dx = e.clientX - blankDown.x;
const dy = e.clientY - blankDown.y;
if ((dx * dx + dy * dy) > 36) return;
}
cvToggleFull(true);
});
stage.addEventListener("dblclick", (e) => {
if (!cvIsBlankCanvasClick(e)) return;
e.preventDefault();
e.stopPropagation();
cvOpenQuickMenu(e.clientX, e.clientY);
});
const quickMenu = document.getElementById("cv-quick-menu");
if (quickMenu && !quickMenu.dataset.bound) {
quickMenu.dataset.bound = "1";
quickMenu.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cv-quick-add]");
if (!btn) return;
cvAddNode(btn.dataset.cvQuickAdd, CANVAS.quickAddPos || undefined);
cvCloseQuickMenu();
});
}
el.addEventListener("pointerdown", (e) => {
const nodeEl = e.target.closest(".drawflow-node");
if (!nodeEl || e.button !== 0) {
CANVAS.dragStart = null;
return;
}
clearTimeout(CANVAS.dragEndTimer);
CANVAS.dragStart = { x: e.clientX, y: e.clientY, moved: false };
CANVAS.dragSuppressClick = false;
}, true);
el.addEventListener("pointermove", (e) => {
const start = CANVAS.dragStart;
if (!start) return;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
if (!start.moved && (dx * dx + dy * dy) > 16) {
start.moved = true;
CANVAS.dragSuppressClick = true;
cvMarkCanvasDragging(true);
}
}, true);
document.addEventListener("pointerup", () => {
if (!CANVAS.dragStart) return;
CANVAS.dragStart = null;
clearTimeout(CANVAS.dragEndTimer);
CANVAS.dragEndTimer = setTimeout(() => {
CANVAS.dragSuppressClick = false;
cvMarkCanvasDragging(false);
}, 80);
}, true);
panel.addEventListener("click", (e) => {
if (!CANVAS.editor || !panel.classList.contains("active")) return;
const menu = document.getElementById("cv-quick-menu");
if (menu && !menu.classList.contains("hidden") && !e.target.closest("#cv-quick-menu")) cvCloseQuickMenu();
const compact = e.target.closest("#drawflow .cv-video-compact");
if (!e.target.closest(".cv-video-pop")) cvCloseOpenPop();
if (!compact) {
if (!e.target.closest("#cv-quick-menu, .cv-toolbar, .cv-zoom, .cv-tool-rail")) cvCloseConfig();
return;
}
if (CANVAS.openConfigEl && CANVAS.openConfigEl !== compact) cvCloseConfig(CANVAS.openConfigEl);
if (CANVAS.dragSuppressClick) return;
if (e.target.closest(".cv-video-composer, .cv-video-pop, .cv-preview-actions, button, input, textarea, select, label")) return;
cvOpenConfig(compact);
});
panel.addEventListener("toggle", (e) => {
const pop = e.target;
if (!pop || !pop.classList || !pop.classList.contains("cv-video-pop")) return;
if (pop.open) {
cvCloseOpenPop(pop);
CANVAS.openPopEl = pop;
} else if (CANVAS.openPopEl === pop) {
CANVAS.openPopEl = null;
}
const compact = pop.closest(".cv-video-compact");
if (compact) compact.classList.toggle("cv-pop-open", !!pop.open);
}, true);
panel.addEventListener("input", (e) => {
if (e.target.closest("#drawflow .cv-field")) cvScheduleAutosave();
});
panel.addEventListener("change", (e) => {
if (e.target.closest("#drawflow .cv-field")) cvScheduleAutosave();
});
}
// 事件委托:点击节点内「运行」/「拉取模型」按钮
el.addEventListener("click", (e) => {
const popClose = e.target.closest(".cv-pop-close");
if (popClose) {
const box = popClose.closest("details");
if (box) box.open = false;
const compact = popClose.closest(".cv-video-compact");
if (compact) {
compact.classList.remove("cv-pop-open");
cvCloseConfig(compact);
}
return;
}
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, .cv-video-frame-inner"));
return;
}
const downloadPreviewBtn = e.target.closest(".cv-preview-download");
if (downloadPreviewBtn) {
cvDownloadPreview(downloadPreviewBtn.closest(".cv-node-preview, .cv-video-frame-inner"));
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);
}
});
el.addEventListener("change", (e) => {
const nodeEl = e.target.closest(".drawflow-node");
if (nodeEl && e.target.closest(".cv-video-pop")) cvUpdateParamSummary(nodeEl);
});
// 缩放变化(按钮 / Ctrl+滚轮)→ 同步右下角百分比
editor.on("zoom", cvUpdateZoomLabel);
window.addEventListener("resize", cvRefreshAllNodePorts);
requestAnimationFrame(cvRefreshAllNodePorts);
// 普通鼠标滚轮直接缩放(无需 Ctrl);Ctrl+滚轮也统一走这里,避免原生步进越界。
// 用 rAF 把「一次滚动的多次事件」合并成每帧一次,避免一帧内多写 transform / 多次 dispatch。
let wheelAccum = 0;
let wheelRAF = 0;
el.addEventListener(
"wheel",
(e) => {
e.preventDefault();
e.stopImmediatePropagation();
if (!CANVAS.editor) return;
wheelAccum += e.deltaY;
if (wheelRAF) return; // 一帧内只处理一次
wheelRAF = requestAnimationFrame(() => {
wheelRAF = 0;
const STEP = 100;
const steps = Math.trunc(wheelAccum / STEP);
wheelAccum -= steps * STEP;
if (steps) cvSetZoomPercent(cvZoomPercent() - steps * 5);
});
},
{ passive: false, capture: true }
);
cvUpdateEmptyHint();
}
// 刷新右下角缩放百分比显示
function cvUpdateZoomLabel() {
cvSyncZoomControls();
}
function cvIsBlankCanvasClick(e) {
const t = e.target;
if (!t || !t.closest) return false;
if (
t.closest(
".drawflow-node, .parent-node, .input, .output, .cv-zoom, .cv-mask, .cv-toolbar, .cv-tool-rail, .cv-quick-menu, .cv-empty-hint, .modal, .connection, .point, .cv-field, .cv-preview-shell, .cv-node-preview, .cv-adv, .cv-refimg-pick, .cv-vmat, .drawflow-delete"
)
) return false;
return !!t.closest(".cv-stage");
}
// 图片节点选了参考图(图生图):读成 dataURL 存缓存 + 显示缩略图
function cvOnRefImagePicked(nodeEl, file) {
if (!file) return;
const id = nodeEl.id.replace("node-", "");
const workspaceContext = { workspaceId: CANVAS.workspaceId, workspaceState: cvWorkspaceSnapshot() };
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");
const frame = nodeEl.querySelector(".cv-image-frame .cv-video-frame-inner");
if (frame) {
const foot = '<div class="cv-meta">参考图 · 上传</div>';
frame.innerHTML = cvPreviewChrome("image", reader.result, "", foot);
}
cvCacheMaterial(id, "image", reader.result, workspaceContext);
cvScheduleAutosave();
};
reader.readAsDataURL(file);
}
function cvResetFramePlaceholder(nodeEl, kind, text) {
if (!nodeEl) return;
const frame = nodeEl.querySelector(".cv-video-frame-inner");
if (!frame) return;
const symbol = text || (kind === "image" ? "▧" : kind === "tts" ? "♪" : "▣");
frame.innerHTML = symbol;
}
// 清除图片节点参考图
function cvClearRefImage(nodeEl) {
const id = nodeEl.id.replace("node-", "");
delete CANVAS.refImages[id];
if (CANVAS.materialRefs[id]) delete CANVAS.materialRefs[id].image;
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 = "";
cvResetFramePlaceholder(nodeEl, "image");
cvScheduleAutosave();
}
// 视频节点选了素材(首帧/尾帧/多参考图):读成 dataURL 存缓存 + 显示缩略图
function cvOnVideoMatPicked(nodeEl, slot, fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
const id = nodeEl.id.replace("node-", "");
const workspaceContext = { workspaceId: CANVAS.workspaceId, workspaceState: cvWorkspaceSnapshot() };
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");
cvCacheMaterial(id, "refs", reader.result, workspaceContext);
cvScheduleAutosave();
};
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");
if (slot === "first" && nodeEl.querySelector(".cv-video-frame-inner")) {
const frame = nodeEl.querySelector(".cv-video-frame-inner");
const foot = '<div class="cv-meta">首帧 · 上传</div>';
frame.innerHTML = cvPreviewChrome("image", reader.result, "", foot);
}
cvCacheMaterial(id, slot, reader.result, workspaceContext);
cvScheduleAutosave();
};
reader.readAsDataURL(files[0]);
}
// 清除视频节点某槽位素材
function cvClearVideoMat(nodeEl, slot) {
const id = nodeEl.id.replace("node-", "");
const store = CANVAS.videoMaterials[id];
const refs = CANVAS.materialRefs[id];
if (store) {
if (slot === "refs") store.refs = [];
else delete store[slot];
}
if (refs) {
if (slot === "refs") refs.refs = [];
else delete refs[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 = "";
if (slot === "first") cvResetFramePlaceholder(nodeEl, "video");
cvScheduleAutosave();
}
function cvIsSeedanceV2Group(g) {
if (!g) return false;
if (g.seedanceV2) return true;
const text = `${g.name || ""} ${g.model || ""}`.toLowerCase();
return /seedance[\s_-]*v\s*2|seedancev2|seedance_v2/.test(text) || cvIsSeedanceV2Model(g.model);
}
function cvIsSeedanceV2Model(model) {
return /^(?:bh2\.0-|sd2\.0-|sdvip|gz-sd)/i.test(String(model || "").trim());
}
function cvIsSeedanceGroup(g) {
if (!g) return false;
if (g.seedance) return true;
return /seedance/i.test(`${g.name || ""} ${g.model || ""}`);
}
function cvIsGrokGroup(g) {
if (!g) return false;
if (g.grok) return true;
return /grok/i.test(`${g.name || ""} ${g.model || ""}`) || /grok-imagine-video/i.test(g.model || "");
}
function cvGroupChannelTag(g, nodeKind) {
if (!g) return "";
if (cvIsSeedanceV2Group(g)) return "Seedance V2";
if (cvIsGrokGroup(g)) return "Grok";
if (cvIsSeedanceGroup(g)) return "Seedance";
if (g.codexVideo && nodeKind === "video") return "视频";
if (g.codexImage && nodeKind === "image") return "生图";
return "";
}
function cvSortGroupsForNode(groups, nodeKind) {
const score = (g) => {
let s = 0;
if (nodeKind === "video") {
if (g.codexVideo) s += 100;
if (cvIsSeedanceV2Group(g)) s += 90;
if (cvIsSeedanceGroup(g)) s += 70;
if (cvIsGrokGroup(g)) s += 40;
} else if (nodeKind === "grokVideo") {
if (cvIsGrokGroup(g)) s += 100;
if (g.codexVideo) s += 50;
} else if (nodeKind === "image") {
if (g.codexImage) s += 100;
}
return s;
};
return groups.slice().sort((a, b) => score(b) - score(a));
}
// 用 CANVAS.groups 填充一个分组下拉,默认选中 activeGroupId(保留已有选择)
// Seedance 视频节点会优先展示 / 默认选中 Seedance V2 分组,方便直接生成。
function cvFillGroupSelect(sel) {
if (!sel) return;
const prev = sel.value;
const groups = CANVAS.groups || [];
if (!groups.length) {
sel.innerHTML = `<option value="">请先在『路由中转』添加分组</option>`;
return;
}
const nodeEl = sel.closest(".drawflow-node");
const isImage = !!(nodeEl && nodeEl.querySelector(".cv-node-image"));
const isGrokVideo = !!(nodeEl && nodeEl.querySelector(".cv-node-grokVideo"));
const isSeedanceVideo = !!(nodeEl && nodeEl.querySelector(".cv-node-video") && !isGrokVideo);
const isVideo = isSeedanceVideo || isGrokVideo;
const nodeKind = isGrokVideo ? "grokVideo" : isSeedanceVideo ? "video" : isImage ? "image" : "";
const ordered = cvSortGroupsForNode(groups, nodeKind);
sel.innerHTML = ordered
.map((g) => {
const tag = cvGroupChannelTag(g, nodeKind);
const label = esc(g.name)
+ (tag ? ` · ${esc(tag)}` : "");
return `<option value="${esc(g.id)}">${label}</option>`;
})
.join("");
const keep = prev && groups.some((g) => String(g.id) === prev);
const imageGroup = isImage ? groups.find((g) => g.codexImage) : null;
const videoDefault = isSeedanceVideo
? (
groups.find((g) => g.codexVideo && cvIsSeedanceV2Group(g))
|| groups.find((g) => cvIsSeedanceV2Group(g))
|| groups.find((g) => g.codexVideo && cvIsSeedanceGroup(g))
|| groups.find((g) => g.codexVideo)
|| groups.find((g) => cvIsSeedanceGroup(g))
)
: null;
const grokGroup = isGrokVideo
? (groups.find((g) => cvIsGrokGroup(g)) || groups.find((g) => g.codexVideo))
: null;
const want = keep
? prev
: String((videoDefault && videoDefault.id) || (imageGroup && imageGroup.id) || (grokGroup && grokGroup.id) || CANVAS.activeGroupId || "");
if (want) sel.value = want;
cvApplyGroupChannelHints(nodeEl, 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);
cvUpdateParamSummary(nodeEl);
cvObserveNodePortFrame(nodeEl);
requestAnimationFrame(() => cvAlignNodePorts(nodeEl));
}
function cvSyncImagePreviewRatio(nodeEl) {
const frame = nodeEl && nodeEl.querySelector(".cv-image-frame");
if (!frame) return;
const ratio = (nodeEl.querySelector(".cv-field-ratio")?.value || "1:1").trim();
const parts = ratio.split(":").map((n) => parseFloat(n));
const w = Number.isFinite(parts[0]) && parts[0] > 0 ? parts[0] : 1;
const h = Number.isFinite(parts[1]) && parts[1] > 0 ? parts[1] : 1;
frame.dataset.ratio = ratio;
frame.style.setProperty("--cv-image-preview-aspect", `${w} / ${h}`);
cvRefreshNodePorts(nodeEl);
}
function cvUpdateParamSummary(nodeEl) {
if (!nodeEl) return;
const sum = nodeEl.querySelector(".cv-video-pop > summary");
if (!sum) return;
const val = (sel) => {
const el = nodeEl.querySelector(sel);
return el ? el.value.trim() : "";
};
if (nodeEl.querySelector(".cv-image-compact")) {
cvSyncImagePreviewRatio(nodeEl);
sum.textContent = [val(".cv-field-ratio") || "1:1", val(".cv-field-quality") || "1K"].join(" · ");
return;
}
if (nodeEl.querySelector(".cv-tts-compact")) {
sum.textContent = [val(".cv-field-voice") || "alloy", val(".cv-field-format") || "mp3"].join(" · ");
return;
}
const modeMap = { text: "文生", first: "首帧", firstlast: "双图", reference: "参考" };
const mode = modeMap[val(".cv-field-vmode")] || "文生";
const resolutionMap = { "2k": "2K", "4k": "4K" };
const resolution = val(".cv-field-vsize") || "720p";
sum.textContent = [mode, val(".cv-field-ratio") || "16:9", resolutionMap[resolution.toLowerCase()] || resolution, val(".cv-field-duration") || "5s"].join(" · ");
}
// 按所选分组切换 Seedance / Seedance V2 的默认接口与提示。
function cvApplyGroupChannelHints(nodeEl, groupId) {
if (!nodeEl) return;
const isSeedanceVideo = !!(nodeEl.querySelector(".cv-node-video") && !nodeEl.querySelector(".cv-node-grokVideo"));
if (!isSeedanceVideo) return;
const g = (CANVAS.groups || []).find((item) => String(item.id) === String(groupId || ""));
const endpoint = nodeEl.querySelector(".cv-field-endpoint");
const hint = nodeEl.querySelector(".cv-node-hint");
const title = nodeEl.querySelector(".cv-video-label");
const isV2 = cvIsSeedanceV2Group(g);
if (endpoint) {
const cur = String(endpoint.value || "").trim();
const v1Path = "/v1/videos";
const v2Path = "/v1/video/generations";
// 仅在空或旧默认路径时自动切换,避免覆盖用户自定义
if (!cur || cur === v1Path || cur === v2Path || /\/v1\/videos(?:\/generations)?$/i.test(cur) || /\/v1\/video\/generations$/i.test(cur)) {
endpoint.value = isV2 ? v2Path : "";
}
endpoint.placeholder = isV2 ? v2Path : v1Path;
endpoint.title = isV2 ? "Seedance V2 原生接口" : "CLMM / 旧 Seedance 接口";
}
if (hint) {
hint.textContent = isV2
? "Seedance V2(Binghuo):模型请拉取 V2 分组完整名称,默认 /v1/video/generations"
: "Seedance / CLMM:模型名、时长和参考图按上游能力校验,默认 /v1/videos";
}
if (title) {
title.innerHTML = isV2 ? "<span>▦</span>Seedance V2" : "<span>▦</span>视频";
}
}
// 分组下拉变化:清空旧模型候选,并按 Seedance V1/V2 切换默认接口。
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>';
cvApplyGroupChannelHints(nodeEl, groupId);
}
// 拉取某节点所选分组的模型 → 填充完整模型下拉/候选列表
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);
cvRefreshAllNodePorts();
// 连线: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");
cvRefreshAllNodePorts();
cvUpdateEmptyHint();
} catch (e) {
// 预置失败不影响画布可用
}
}
// 全屏尺寸变化后触发一次重绘(节点绝对坐标不变,仅刷新缩放/连线)
function cvReflow() {
try {
if (CANVAS.editor && typeof CANVAS.editor.zoom_refresh === "function") {
CANVAS.editor.zoom_refresh();
}
cvRefreshAllNodePorts();
} catch (e) {}
}
function cvZoomPercent() {
return Math.round((CANVAS.editor && CANVAS.editor.zoom ? CANVAS.editor.zoom : 1) * 100);
}
function cvSyncZoomControls(forceRange) {
const ed = CANVAS.editor;
const pct = cvZoomPercent();
const label = document.getElementById("cv-zoom-reset");
const range = document.getElementById("cv-zoom-range");
if (label) label.textContent = pct + "%";
if (range && ed) {
range.min = String(Math.round((ed.zoom_min || 0.5) * 100));
range.max = String(Math.round((ed.zoom_max || 1.6) * 100));
if (forceRange || document.activeElement !== range) range.value = String(pct);
}
}
function cvSetZoomPercent(pct) {
const ed = CANVAS.editor;
if (!ed) return;
const minPct = Math.round((ed.zoom_min || 0.5) * 100);
const maxPct = Math.round((ed.zoom_max || 1.6) * 100);
const next = Math.min(maxPct, Math.max(minPct, Math.round(Number(pct) || 100)));
ed.zoom = next / 100;
if (typeof ed.zoom_refresh === "function") ed.zoom_refresh();
cvSyncZoomControls(true);
cvRefreshAllNodePorts();
cvScheduleAutosave();
}
function cvRequestAutoFull() {
const panel = document.getElementById("tab-canvas");
if (!panel || panel.classList.contains("cv-fullscreen")) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (panel.classList.contains("active") && !panel.classList.contains("cv-fullscreen")) {
cvToggleFull(true);
}
});
});
}
// 切换沉浸式全屏(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 ? "✕ 退出全屏" : "⛶ 全屏";
requestAnimationFrame(() => requestAnimationFrame(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 cvCanvasPosFromPoint(clientX, clientY) {
const c = document.getElementById("drawflow");
const ed = CANVAS.editor;
if (!c || !ed) return cvCenterPos();
const rect = c.getBoundingClientRect();
const zoom = ed.zoom || 1;
const x = (clientX - rect.left - (ed.canvas_x || 0)) / zoom - 125;
const y = (clientY - rect.top - (ed.canvas_y || 0)) / zoom - 70;
return { x: Math.max(20, x), y: Math.max(20, y) };
}
function cvUpdateEmptyHint() {
const hint = document.getElementById("cv-empty-hint");
if (!hint) return;
hint.classList.toggle("hidden", Object.keys(cvAllNodes()).length > 0);
}
function cvOpenQuickMenu(clientX, clientY) {
const menu = document.getElementById("cv-quick-menu");
const stage = document.querySelector("#tab-canvas .cv-stage");
if (!menu || !stage) return;
CANVAS.quickAddPos = cvCanvasPosFromPoint(clientX, clientY);
const rect = stage.getBoundingClientRect();
const width = 240;
const height = 310;
const left = Math.min(Math.max(14, clientX - rect.left + 12), Math.max(14, rect.width - width - 14));
const top = Math.min(Math.max(14, clientY - rect.top + 12), Math.max(14, rect.height - height - 14));
menu.style.left = left + "px";
menu.style.top = top + "px";
menu.classList.remove("hidden");
}
function cvCloseQuickMenu() {
const menu = document.getElementById("cv-quick-menu");
if (menu) menu.classList.add("hidden");
}
// 在画布中心添加一个指定类型的节点
function cvAddNode(kind, customPos) {
const m = CV_NODES[kind];
if (!CANVAS.editor || !m) return;
const pos = customPos || cvCenterPos();
const id = CANVAS.editor.addNode(
kind, m.inputs, m.outputs, pos.x, pos.y,
`cv-df-${kind}`, {}, cvNodeHTML(kind), false
);
cvSetupNode(id); // 唯一化 datalist + 回填分组下拉
cvRefreshNodePorts(document.getElementById(`node-${id}`));
cvUpdateEmptyHint();
cvScheduleAutosave();
}
function cvPortAnchor(nodeEl) {
if (!nodeEl) return null;
return nodeEl.querySelector("[data-port-anchor]") || nodeEl.querySelector(".cv-video-frame") || nodeEl.querySelector(".cv-video-composer");
}
function cvAlignNodePorts(nodeEl) {
if (!nodeEl) return false;
const frame = cvPortAnchor(nodeEl);
if (!frame) return false;
const nodeRect = nodeEl.getBoundingClientRect();
const frameRect = frame.getBoundingClientRect();
if (!nodeRect.width || !frameRect.width) return false;
const zoom = (CANVAS.editor && CANVAS.editor.zoom) || 1;
const midY = (frameRect.top - nodeRect.top + frameRect.height / 2) / zoom;
const leftX = (frameRect.left - nodeRect.left) / zoom;
const rightX = (frameRect.right - nodeRect.left) / zoom;
const next = `${Math.round(midY)}:${Math.round(leftX)}:${Math.round(rightX)}`;
if (nodeEl.dataset.cvPortPos === next) return false;
nodeEl.dataset.cvPortPos = next;
nodeEl.style.setProperty("--cv-port-y", `${midY}px`);
nodeEl.style.setProperty("--cv-port-left", `${leftX}px`);
nodeEl.style.setProperty("--cv-port-right", `${rightX}px`);
return true;
}
function cvAlignAllNodePorts() {
document.querySelectorAll("#drawflow .drawflow-node").forEach(cvAlignNodePorts);
}
function cvRefreshNodePorts(nodeEl) {
if (!nodeEl || !nodeEl.id) return;
const id = nodeEl.id;
if (CANVAS.portRefreshNodeRAFs[id]) return;
CANVAS.portRefreshNodeRAFs[id] = requestAnimationFrame(() => {
delete CANVAS.portRefreshNodeRAFs[id];
if (!nodeEl.isConnected) return;
const changed = cvAlignNodePorts(nodeEl);
if (changed && CANVAS.editor && typeof CANVAS.editor.updateConnectionNodes === "function") {
CANVAS.editor.updateConnectionNodes(nodeEl.id);
}
});
}
function cvRefreshAllNodePorts() {
if (CANVAS.portRefreshRAF) return;
CANVAS.portRefreshRAF = requestAnimationFrame(() => {
CANVAS.portRefreshRAF = 0;
if (document.getElementById("drawflow")?.classList.contains("cv-dragging")) {
setTimeout(cvRefreshAllNodePorts, 90);
return;
}
document.querySelectorAll("#drawflow .drawflow-node").forEach((nodeEl) => {
const changed = cvAlignNodePorts(nodeEl);
if (changed && CANVAS.editor && typeof CANVAS.editor.updateConnectionNodes === "function") {
CANVAS.editor.updateConnectionNodes(nodeEl.id);
}
});
});
}
function cvObserveNodePortFrame(nodeEl) {
if (!nodeEl) return;
const frame = cvPortAnchor(nodeEl);
if (!frame || frame.dataset.cvPortObserved) return;
frame.dataset.cvPortObserved = "1";
if (!CANVAS.portResizeObserver && typeof ResizeObserver !== "undefined") {
CANVAS.portResizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const next = `${Math.round(entry.contentRect.width)}:${Math.round(entry.contentRect.height)}`;
if (entry.target.dataset.cvResizeSize === next) return;
entry.target.dataset.cvResizeSize = next;
const node = entry.target.closest(".drawflow-node");
if (node) CANVAS.portResizeQueue.add(node);
});
if (CANVAS.portResizeTimer) return;
CANVAS.portResizeTimer = setTimeout(() => {
CANVAS.portResizeTimer = 0;
if (CANVAS.portResizeRAF) return;
CANVAS.portResizeRAF = requestAnimationFrame(() => {
CANVAS.portResizeRAF = 0;
const nodes = Array.from(CANVAS.portResizeQueue);
CANVAS.portResizeQueue.clear();
nodes.forEach(cvRefreshNodePorts);
});
}, 120);
});
}
if (CANVAS.portResizeObserver) CANVAS.portResizeObserver.observe(frame);
}
// 切换视频模式:按 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) {
try {
const node = CANVAS.editor.getNodeFromId(nodeId);
if (window.CanvasInputs) return window.CanvasInputs.collectUpstreamOutputs(node, cvNodeOutput);
} catch (e) {}
return { text: "", image: "", images: [] };
}
// 节点内错误红字
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";
if (/wav/i.test(mime)) return "wav";
if (/mpeg/i.test(mime) || /mp3/i.test(mime)) return "mp3";
if (/ogg/i.test(mime) || /opus/i.test(mime)) return "opus";
return kind === "image" ? "png" : kind === "audio" ? "mp3" : "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 ${isVideo ? "cv-preview-video" : "cv-preview-image"}">
<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 cvAudioPreviewChrome(dataUrl, foot) {
return `
<div class="cv-preview-shell cv-preview-audio">
<div class="cv-preview-actions">
<button type="button" class="cv-preview-open" title="放大预览">⛶</button>
<button type="button" class="cv-preview-download" title="下载">⇩</button>
</div>
<audio class="cv-media-audio" controls src="${dataUrl}"></audio>
</div>${foot}`;
}
function cvOpenPreview(previewEl) {
if (!previewEl) return;
const media = previewEl.querySelector("img.cv-media, video.cv-media, audio.cv-media-audio");
if (!media || !media.src) return;
const isVideo = media.tagName === "VIDEO";
const isAudio = media.tagName === "AUDIO";
const src = media.src || media.dataset.sourceUrl;
const modal = modalOverlay.querySelector(".modal");
const body = $("#modal-body");
$("#modal-title").textContent = isVideo ? "视频预览" : isAudio ? "音频预览" : "图片预览";
body.innerHTML = "";
const m = document.createElement(isVideo ? "video" : isAudio ? "audio" : "img");
m.className = "cv-modal-media";
m.src = src;
if (isVideo || isAudio) 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, audio.cv-media-audio");
if (!media || !media.src) return;
const isVideo = media.tagName === "VIDEO";
const isAudio = media.tagName === "AUDIO";
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" : isAudio ? "audio" : "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 = cvAudioPreviewChrome(r.dataUrl, foot);
}
}
function cvResultHost(nodeEl, kind) {
if (!nodeEl) return null;
if (kind === "image" || kind === "video" || kind === "grokVideo" || kind === "tts") {
return nodeEl.querySelector(".cv-video-frame-inner") || nodeEl.querySelector(".cv-node-preview");
}
return nodeEl.querySelector(".cv-node-preview");
}
function cvResetResultHost(el, kind) {
if (!el) return;
if (el.classList.contains("cv-video-frame-inner")) {
el.innerHTML = kind === "image" ? "▧" : kind === "tts" ? "♪" : "▣";
return;
}
el.classList.add("hidden");
el.innerHTML = "";
}
// 据 比例×清晰度 算出图像尺寸 "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");
if (prevEl.classList.contains("cv-video-frame-inner")) {
const foot = '<div class="cv-meta">生成中…</div>';
prevEl.innerHTML = cvPreviewChrome("image", dataUrl, "", foot);
return;
}
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(cvResultHost(nodeEl, "image"), 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 workspaceContext = { workspaceId: CANVAS.workspaceId, workspaceState: cvWorkspaceSnapshot() };
const prevEl = cvResultHost(nodeEl, kind);
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;
// 图生图:本节点上传的参考图优先,其次使用连接上游的首张图片。
const refImg = CANVAS.refImages[nodeId] || up.image;
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() : "";
};
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 = "video";
params = {
mode,
prompt: ownText || up.text,
ratio: val(".cv-field-ratio"),
quality: val(".cv-field-vquality"),
resolution: val(".cv-field-vsize") || "720p",
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 = CanvasInputs.uniqueNonEmpty([].concat(up.images, mats.refs || [], matUrls("refs")));
if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请连接上游图片、上传参考图,或填写公网图片URL");
params.referenceImages = refs;
}
if (duration) params.seconds = duration;
if (endpointPath) params.endpointPath = endpointPath;
else {
// 未填高级接口时:Seedance V2 分组自动走原生 /v1/video/generations
const gid = val(".cv-field-group");
const gg = (CANVAS.groups || []).find((item) => String(item.id) === String(gid || ""));
if (cvIsSeedanceV2Group(gg) || cvIsSeedanceV2Model(model)) params.endpointPath = "/v1/video/generations";
}
} 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 = CanvasInputs.uniqueNonEmpty([].concat(up.images, mats.refs || [], matUrls("refs")));
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 {
await cvSaveWorkspace();
if (!apic.canvasRun) throw new Error("画布运行能力尚未就绪(后端未接入)");
const r = await apic.canvasRun(runKind, params);
if (!r || !r.ok) throw new Error((r && r.error) || "生成失败");
if (CANVAS.workspaceId === workspaceContext.workspaceId) {
cvRenderResult(runKind, prevEl, r);
// 缓存产物供下游取用(图片可连到图生视频)
if (runKind === "image") CANVAS.outputs[nodeId] = { image: r.dataUrl };
}
try {
await cvCacheOutput(nodeId, runKind, r, workspaceContext);
} catch (cacheErr) {
toast("生成成功,但本地缓存失败:" + (cacheErr.message || cacheErr), "err");
}
} catch (e) {
cvResetResultHost(prevEl, runKind || kind);
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");
}
}
// 进入画布:懒初始化 + 加载分组 + 刷新状态
async function cvEnter() {
cvInit();
cvBindProgress(); // 幂等;若启动时后端未就绪,进入时再尝试订阅
cvLoadGroups();
cvRefreshStatus();
cvRequestAutoFull();
if (!CANVAS.workspaceReady && !CANVAS.workspaceLoading) {
CANVAS.workspaceLoading = true;
try {
const r = await apic.canvasWorkspaces();
CANVAS.workspaceList = r && r.ok && Array.isArray(r.workspaces) ? r.workspaces : [];
if (!CANVAS.workspaceList.length) {
await cvCreateWorkspace();
} else {
await cvOpenWorkspacePicker();
}
} catch (e) {
toast("画布记录加载失败:" + (e.message || e), "err");
} finally {
CANVAS.workspaceLoading = false;
}
}
}
const cvTabBtn = document.querySelector('.tab[data-tab="canvas"]');
if (cvTabBtn) cvTabBtn.addEventListener("click", cvEnter);
// 启动时订阅一次流式进度(幂等)
cvBindProgress();
document.getElementById("cv-projects-btn")?.addEventListener("click", cvOpenWorkspacePicker);
document.getElementById("cv-project-close")?.addEventListener("click", cvCloseWorkspacePicker);
document.getElementById("cv-project-create")?.addEventListener("click", () => {
cvCreateWorkspace().catch((e) => toast("新建画布失败:" + (e.message || e), "err"));
});
document.getElementById("cv-new-btn")?.addEventListener("click", () => {
cvCreateWorkspace().catch((e) => toast("新建画布失败:" + (e.message || e), "err"));
});
document.getElementById("cv-project-list")?.addEventListener("click", (e) => {
const renameStart = e.target.closest("[data-cv-rename-start]");
if (renameStart) {
CANVAS.workspaceRenamingId = renameStart.dataset.cvRenameStart;
cvRenderWorkspaceList();
return;
}
if (e.target.closest("[data-cv-rename-cancel]")) {
CANVAS.workspaceRenamingId = "";
cvRenderWorkspaceList();
return;
}
const renameSave = e.target.closest("[data-cv-rename-save]");
if (renameSave) {
cvRenameWorkspace(renameSave.dataset.cvRenameSave).catch((err) => toast("改名失败:" + (err.message || err), "err"));
return;
}
const btn = e.target.closest("[data-cv-workspace-id]");
if (!btn) return;
busy(btn, true, "载入中…");
cvLoadWorkspace(btn.dataset.cvWorkspaceId)
.catch((err) => toast("恢复画布失败:" + (err.message || err), "err"))
.finally(() => busy(btn, false));
});
document.getElementById("cv-project-list")?.addEventListener("keydown", (e) => {
const input = e.target.closest("[data-cv-rename-input]");
if (!input) return;
if (e.key === "Enter") {
e.preventDefault();
cvRenameWorkspace(input.dataset.cvRenameInput).catch((err) => toast("改名失败:" + (err.message || err), "err"));
} else if (e.key === "Escape") {
CANVAS.workspaceRenamingId = "";
cvRenderWorkspaceList();
}
});
// 顶部「添加节点」按钮
$$("[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% 重置 / -)统一走 cvSetZoomPercent,避免越过 Drawflow min/max
const cvZoomStep = (delta) => () => {
cvInit();
cvSetZoomPercent(cvZoomPercent() + delta);
};
document.getElementById("cv-zoom-in")?.addEventListener("click", cvZoomStep(5));
document.getElementById("cv-zoom-out")?.addEventListener("click", cvZoomStep(-5));
document.getElementById("cv-zoom-reset")?.addEventListener("click", () => {
cvInit();
cvSetZoomPercent(100);
});
const cvZoomRange = document.getElementById("cv-zoom-range");
if (cvZoomRange) {
cvZoomRange.addEventListener("input", () => cvSetZoomPercent(cvZoomRange.value));
}
// 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: "",
orchestratorGroupId: "",
agentGroupId: "",
agentModels: [],
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) || "";
TEAM.orchestratorGroupId = TEAM.orchestratorGroupId || TEAM.activeGroupId;
const preferredWorker = TEAM.groups.find((g) => /deepseek/i.test(
`${g.id || ""} ${g.name || ""} ${g.model || ""} ${(g.models || []).join(" ")}`
));
const fallbackWorker = TEAM.groups.find((g) => g.id !== TEAM.activeGroupId && !g.codexImage && !g.codexVideo && !g.stt)
|| TEAM.groups[0];
TEAM.agentGroupId = TEAM.agentGroupId || (preferredWorker && preferredWorker.id) || (fallbackWorker && fallbackWorker.id) || "";
} else {
TEAM.groups = [];
TEAM.activeGroupId = "";
TEAM.orchestratorGroupId = "";
TEAM.agentGroupId = "";
}
} catch (e) {
TEAM.groups = [];
TEAM.activeGroupId = "";
TEAM.orchestratorGroupId = "";
TEAM.agentGroupId = "";
}
tmRenderGroupSelect("tm-group", TEAM.activeGroupId, (id) => {
TEAM.activeGroupId = id;
const group = TEAM.groups.find((g) => g.id === id);
tmSetSelectModels("tm-model", TEAM.activeGroupId, group && (group.model || (group.models || [])[0]));
tmUpdateGenBtn();
});
tmRenderGroupSelect("tm-orch-group", TEAM.orchestratorGroupId, (id) => {
TEAM.orchestratorGroupId = id;
const group = TEAM.groups.find((g) => g.id === id);
tmSetSelectModels("tm-orch-model", TEAM.orchestratorGroupId, group && (group.model || (group.models || [])[0]));
tmUpdateGenBtn();
});
tmRenderGroupSelect("tm-agent-group", TEAM.agentGroupId, (id) => {
TEAM.agentGroupId = id;
const group = TEAM.groups.find((g) => g.id === id);
tmSetWorkerModels(group, group && (group.model || (group.models || [])[0]), true);
tmUpdateGenBtn();
});
const leadGroup = TEAM.groups.find((g) => g.id === TEAM.activeGroupId);
const orchestratorGroup = TEAM.groups.find((g) => g.id === TEAM.orchestratorGroupId);
const workerGroup = TEAM.groups.find((g) => g.id === TEAM.agentGroupId);
tmSetSelectModels("tm-model", TEAM.activeGroupId, leadGroup && (leadGroup.model || (leadGroup.models || [])[0]));
tmSetSelectModels("tm-orch-model", TEAM.orchestratorGroupId, orchestratorGroup && (orchestratorGroup.model || (orchestratorGroup.models || [])[0]));
tmSetWorkerModels(workerGroup, workerGroup && (workerGroup.model || (workerGroup.models || [])[0]));
tmUpdateGenBtn();
}
function tmRenderGroupSelect(id, selectedId, onChange) {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = TEAM.groups.length
? TEAM.groups.map((g) => `<option value="${esc(g.id)}">${esc(g.name || g.id)}</option>`).join("")
: `<option value="">未配置可用分组(去『路由中转』添加)</option>`;
sel.value = selectedId || "";
sel.onchange = () => onChange(sel.value);
}
function tmSetSelectModels(id, groupId, preferred) {
const sel = document.getElementById(id);
if (!sel) return;
const group = TEAM.groups.find((g) => g.id === groupId);
const models = Array.from(new Set([
preferred,
group && group.model,
...((group && group.models) || []),
].map((m) => String(m || "").trim()).filter(Boolean)));
sel.innerHTML = models.length
? models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
: `<option value="">先拉取模型</option>`;
if (preferred && models.includes(preferred)) sel.value = preferred;
sel.onchange = tmUpdateGenBtn;
}
function tmSetWorkerModels(group, preferred, force = false) {
const input = document.getElementById("tm-agent-model");
const list = document.getElementById("tm-agent-model-list");
if (!input || !list) return;
TEAM.agentModels = Array.from(new Set([
preferred,
group && group.model,
...((group && group.models) || []),
].map((m) => String(m || "").trim()).filter(Boolean)));
list.innerHTML = TEAM.agentModels.map((m) => `<option value="${esc(m)}"></option>`).join("");
if (preferred && (force || !input.value.trim())) input.value = preferred;
input.oninput = tmUpdateGenBtn;
}
// 拉取所选分组的模型列表,填充总控下拉或执行模型 datalist。
async function tmFetchModelsFor(kind) {
const isWorker = kind === "worker";
const isOrchestrator = kind === "orchestrator";
const btn = document.getElementById(isWorker ? "tm-agent-fetch" : (isOrchestrator ? "tm-orch-fetch" : "tm-fetch"));
const groupId = (isWorker ? TEAM.agentGroupId : (isOrchestrator ? TEAM.orchestratorGroupId : 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 (isWorker) {
TEAM.agentModels = models;
const input = document.getElementById("tm-agent-model");
const list = document.getElementById("tm-agent-model-list");
if (list) list.innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join("");
if (input && !input.value.trim()) input.value = models[0] || "";
} else {
const sel = document.getElementById(isOrchestrator ? "tm-orch-model" : "tm-model");
if (sel) {
sel.innerHTML = models.length
? models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
: `<option value="">该分组没有可用模型</option>`;
sel.onchange = tmUpdateGenBtn;
}
}
const hint = document.getElementById(isWorker ? "tm-agent-hint" : (isOrchestrator ? "tm-orch-hint" : "tm-model-hint"));
if (hint) hint.textContent = isWorker
? `已拉取 ${models.length} 个模型,选择一个作为子 Agent 执行模型`
: isOrchestrator
? `已拉取 ${models.length} 个模型,选择一个作为总控实际执行模型`
: `已拉取 ${models.length} 个模型,选择一个用于生成团队配置`;
tmUpdateGenBtn();
toast(`已拉取 ${models.length} 个模型`, "ok");
} catch (e) {
toast("拉取模型失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
}
function tmFetchModels() { return tmFetchModelsFor("lead"); }
function tmFetchOrchestratorModels() { return tmFetchModelsFor("orchestrator"); }
function tmFetchWorkerModels() { return tmFetchModelsFor("worker"); }
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;
const orchestratorModel = document.getElementById("tm-orch-model").value;
const workerModel = document.getElementById("tm-agent-model").value.trim();
btn.disabled = !(req && model && (TEAM.tool === "claude" || (orchestratorModel && workerModel)));
}
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;
const orchestratorModel = document.getElementById("tm-orch-model").value;
const workerModel = document.getElementById("tm-agent-model").value.trim();
if (!requirement || !model || (TEAM.tool === "codex" && (!orchestratorModel || !workerModel))) 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,
orchestratorGroupId: TEAM.orchestratorGroupId || undefined,
agentGroupId: TEAM.agentGroupId || undefined,
agentModel: workerModel || undefined,
orchestratorModel: orchestratorModel || undefined,
});
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>` : "") +
(team.model_routing ? `<div class="tm-team-models">生成:<code>${esc(team.model_routing.generation_model || team.model_routing.analysis_model || "—")}</code> 总控执行:<code>${esc(team.model_routing.orchestrator_model || "继承")}</code> 子 Agent 执行:<code>${esc(team.model_routing.worker_model || "继承")}</code></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>
${a.model ? `<div class="tm-emp-model">模型:<code>${esc(a.model)}</code></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" : "必须选择子 Agent 执行模型,如 deepseek-v4-flash";
hint.textContent = isClaude
? "Claude 只认 sonnet / opus / haiku / claude-*;留空则省略 model 字段=自动继承"
: "生成 Codex 团队时必须明确选择执行模型;总控和员工会分别按模型路由";
// 已生成的团队:切工具后重新渲染(预览扩展名随之变)
if (TEAM.team) tmRenderTeam(TEAM.team);
});
});
}
const fetchBtn = document.getElementById("tm-fetch");
if (fetchBtn) fetchBtn.addEventListener("click", tmFetchModels);
const orchFetchBtn = document.getElementById("tm-orch-fetch");
if (orchFetchBtn) orchFetchBtn.addEventListener("click", tmFetchOrchestratorModels);
const agentFetchBtn = document.getElementById("tm-agent-fetch");
if (agentFetchBtn) agentFetchBtn.addEventListener("click", tmFetchWorkerModels);
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)}</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: "未连接", waiting_debug_port: "等待调试端口…", unsupported_packaged: "需官网版", 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();
deferUI(loadSecrets, 800);
deferUI(loadVersion, 1400);
window.addEventListener("beforeunload", () => {
const snapshot = cvWorkspaceSnapshot();
if (snapshot && apic.canvasWorkspaceSaveNow) apic.canvasWorkspaceSaveNow(snapshot);
});