// app.js —— 前端逻辑,只经 window.apic.* 与主进程通信。 // 绝不出现市场地址 / 密钥(那些只在主进程)。 "use strict"; const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); // 注意:preload 已在 window 上定义了不可配置的全局属性 `api`, // 顶层再用 const/let 声明同名会抛 "already been declared",故用 window.api 起个别名。 const apic = window.api; // —— 工具 —— function humanSize(n) { const u = ["B", "K", "M", "G"]; let x = n; for (const unit of u) { if (x < 1024) return unit === "B" ? `${Math.round(x)}${unit}` : `${x.toFixed(1)}${unit}`; x /= 1024; } return `${x.toFixed(1)}T`; } function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } let toastTimer = null; function toast(msg, kind = "") { const t = $("#toast"); t.textContent = msg; t.className = `toast ${kind}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.add("hidden"), 4200); } function busy(btn, on, textWhenBusy) { if (!btn) return; if (on) { btn.dataset.label = btn.textContent; btn.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, "
"); } catch (e) { if (out) out.textContent = "诊断失败:" + (e && e.message ? e.message : e); } }); } } // =========================================================================== // ① 插件市场 // =========================================================================== let skillCat = null; // 完整 catalog {total, categories, skills} let storeCat = "__all__"; // 当前分类(__all__ / __hot__ / 中文分类名) let storeSort = "hot"; // hot / downloads / stars let storeShown = 0; // 已渲染数量(分页) const STORE_PAGE = 60; const installed = new Set(JSON.parse(localStorage.getItem("installedSkills") || "[]")); let installedSkills = []; // 磁盘实况(~/.codex/skills 扫描结果) let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装” // 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)。统一小写,避免大小写变体导致"已安装"判断错位。 function safeSkillDir(name) { return (String(name || "skill") .toLowerCase() .replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill"; } async function loadInstalledSkills(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) => `${esc(x)}` ).join(""); const mm = s.mm ? '🎨 多模态' : ""; return `
适配${a}${mm}
`; } function skillCardHTML(s) { const sec = (s.sec || "").toUpperCase(); const secBadge = /^[SAB]$/.test(sec) ? `
${sec}
` : ""; const isIn = installedDirs.has(safeSkillDir(s.name)) || installed.has(String(s.id)); const meta = [ `⬇ ${esc(s.dl || "0")}`, s.stars ? `★ ${esc(s.stars)}` : "", s.fav ? `♥ ${esc(s.fav)}` : "", s.cred ? `${esc(s.cred)}` : "", ].join(""); return `
${esc(s.icon || "🧩")}
${esc(s.name)}
by ${esc(s.author || "未知")}
${secBadge}
${esc(s.subtitle || s.name)}
${esc(s.brief || "")}
${agentBadges(s)}
${meta}
`; } function renderStore(reset = true) { const grid = $("#market-grid"); const empty = $("#market-empty"); const more = $("#store-more"); if (storeCat === "__installed__") { renderInstalledSkills(); return; } const list = storeFiltered(); if (reset) { storeShown = 0; grid.innerHTML = ""; } if (!list.length) { grid.innerHTML = ""; empty.classList.remove("hidden"); empty.textContent = "没有匹配的技能,换个关键词或分类试试。"; more.classList.add("hidden"); return; } empty.classList.add("hidden"); const next = list.slice(storeShown, storeShown + STORE_PAGE); grid.insertAdjacentHTML("beforeend", next.map(skillCardHTML).join("")); storeShown += next.length; // 副标题 $("#store-cat-title").textContent = storeCat === "__all__" ? "全部技能" : storeCat === "__hot__" ? "热门技能" : storeCat; $("#store-cat-sub").textContent = `${list.length} 个技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`; // 加载更多 if (storeShown < list.length) { more.classList.remove("hidden"); more.innerHTML = `已显示 ${storeShown} / ${list.length} · `; $("#store-more-btn").addEventListener("click", () => renderStore(false)); } else { more.classList.remove("hidden"); more.innerHTML = `已全部显示 · 共 ${list.length} 个`; } bindStoreCards(); } function bindStoreCards() { const grid = $("#market-grid"); grid.querySelectorAll("[data-install]").forEach((btn) => { if (btn._bound) return; btn._bound = true; btn.addEventListener("click", () => installSkill(btn.dataset.install, btn)); }); grid.querySelectorAll("[data-detail]").forEach((btn) => { if (btn._bound) return; btn._bound = true; btn.addEventListener("click", () => openSkillDetail(btn.dataset.detail)); }); } // 「我已安装」视图:以磁盘 ~/.codex/skills 为准,可逐个卸载 function renderInstalledSkills() { const grid = $("#market-grid"); const empty = $("#market-empty"); const more = $("#store-more"); more.classList.add("hidden"); more.innerHTML = ""; $("#store-cat-title").textContent = "我已安装"; $("#store-cat-sub").textContent = `${installedSkills.length} 个已安装技能 · Codex 侧位于 ~/.codex/skills · 卸载会同时删除工具箱仓库实体`; if (!installedSkills.length) { grid.innerHTML = ""; empty.classList.remove("hidden"); empty.textContent = "还没有安装任何技能。去左侧分类里挑一个安装吧。"; return; } empty.classList.add("hidden"); grid.innerHTML = installedSkills.map((s) => `
🧩
${esc(s.name || s.dir)}
已安装${s.symlink ? " · 软链" : ""} · ${humanSize(s.size || 0)}
${esc(s.description || "(该技能未提供描述)")}
${esc(s.dir)}
`).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 ? `${count.toLocaleString()}` : ""; return ``; }; let html = ""; html += `
精选
`; html += item("__all__", "全部", skillCat.total); html += item("__hot__", "热门", null); html += item("__installed__", "我已安装", installedSkills.length); html += `
全部分类
`; for (const c of skillCat.categories) html += item(c.name, c.name, c.count); nav.innerHTML = html; nav.querySelectorAll(".nav-item").forEach((el) => el.addEventListener("click", async () => { storeCat = el.dataset.cat; nav.querySelectorAll(".nav-item").forEach((x) => x.classList.remove("active")); el.classList.add("active"); $("#market-grid").scrollTop = 0; if (storeCat === "__installed__") await loadInstalledSkills({ includeSize: true, includeMeta: true }); 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) => `${esc(t)}`).join(""); $("#modal-body").innerHTML = `
${esc(s.icon || "🧩")}

${esc(s.name)}

by ${esc(s.author || "未知")} · 分类 ${esc(s.category)}

${esc(s.subtitle || "")}

${esc(s.brief || "(暂无简介)")}

${tags ? `
${tags}
` : ""}
⬇ 下载 ${esc(s.dl || "0")} ${s.stars ? `★ 星标 ${esc(s.stars)}` : ""} ${s.fav ? `♥ 收藏 ${esc(s.fav)}` : ""} ${s.sec ? `🛡 安全级 ${esc(s.sec)}` : ""} ${s.cred ? `可信度 ${esc(s.cred)}` : ""}

适配 AI 模型 / Agent

${agentBadges(s)}

${esc(skillCat.model_note || "SKILL.md 标准技能")}${s.mm ? ";本技能涉及图片/视觉,建议搭配多模态强模型。" : "。"}

`; modalOverlay.classList.remove("hidden"); $("#sd-install").addEventListener("click", (e) => installSkill(s.id, e.currentTarget)); $("#sd-source").addEventListener("click", () => apic.openExternal(skillGitUrl(s))); } async function loadMarket() { const grid = $("#market-grid"); const empty = $("#market-empty"); empty.classList.add("hidden"); grid.innerHTML = '
正在加载技能商店…
'; try { skillCat = await apic.skillCatalog(); 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 ? `${platLabel}` : ""; return `
插件${platBadge}
${esc(e.displayName || e.name)}
${humanSize(e.size || 0)} tokendun 官方
${esc(e.description) || "(暂无描述)"}
`; } function renderPlugins() { const q = $("#plugin-search").value.trim().toLowerCase(); const grid = $("#plugin-grid"); const empty = $("#plugin-empty"); const list = pluginData.filter((e) => !q || (e.displayName || "").toLowerCase().includes(q) || (e.name || "").toLowerCase().includes(q) || (e.source || "").toLowerCase().includes(q) || (e.description || "").toLowerCase().includes(q) ); if (!list.length) { grid.innerHTML = ""; empty.classList.remove("hidden"); empty.innerHTML = pluginData.length ? "没有匹配的插件,换个关键词试试。" : "插件市场即将上线,敬请期待 —— tokendun 官方自研插件将陆续上架。"; return; } empty.classList.add("hidden"); grid.innerHTML = list.map(pluginCardHTML).join(""); grid.querySelectorAll("[data-install]").forEach((btn) => btn.addEventListener("click", () => installPlugin(btn))); grid.querySelectorAll("[data-open]").forEach((a) => a.addEventListener("click", (e) => { e.preventDefault(); apic.openExternal(a.dataset.open); }) ); } async function installPlugin(btn) { let entry; try { entry = JSON.parse(btn.dataset.install); } catch { return; } busy(btn, true, "安装中…"); btn.classList.add("installing"); try { await apic.pluginInstall(entry); toast(`已安装 ${entry.displayName || entry.name} ✅ 重启 codex 生效`, "ok"); btn.textContent = "已安装 ✓"; btn.dataset.label = "已安装 ✓"; loadInstalledPlugins(); } catch (e) { toast(`安装失败:${e.message || e}`, "err"); busy(btn, false); } finally { btn.classList.remove("installing"); } } // 已安装插件(以磁盘 ~/.codex/plugins/cache 为准,可卸载) let installedPlugins = []; function installedPluginCardHTML(p) { const uninstall = p.protected ? `` : ``; return `
已安装
${esc(p.displayName || p.name)}
${humanSize(p.size || 0)} 来源:${esc(p.source || "—")} ${p.symlink ? "软链" : ""}
${esc(p.description) || "(暂无描述)"}
${esc(p.rel)} ${uninstall}
`; } function renderInstalledPlugins() { const grid = $("#plugin-installed-grid"); const empty = $("#plugin-installed-empty"); $("#plugin-installed-count").textContent = installedPlugins.length ? `(${installedPlugins.length})` : ""; if (!installedPlugins.length) { grid.innerHTML = ""; empty.classList.remove("hidden"); empty.textContent = "还没有安装任何插件。"; return; } empty.classList.add("hidden"); grid.innerHTML = installedPlugins.map(installedPluginCardHTML).join(""); grid.querySelectorAll("[data-uninstall]").forEach((btn) => btn.addEventListener("click", () => uninstallPlugin(btn)) ); } async function loadInstalledPlugins() { try { installedPlugins = await apic.pluginInstalled(); } catch { installedPlugins = []; } renderInstalledPlugins(); } async function uninstallPlugin(btn) { let info; try { info = JSON.parse(btn.dataset.uninstall); } catch { return; } if (!confirm(`确认卸载插件「${info.label || info.name}」?\n\n将删除 ~/.codex/${info.rel} 以及对应工具箱仓库实体,此操作不可恢复。`)) return; busy(btn, true, "卸载中…"); try { await apic.pluginUninstall(info.rel); toast(`已卸载 ${info.name} ✅`, "ok"); loadInstalledPlugins(); } catch (e) { toast(`卸载失败:${e.message || e}`, "err"); busy(btn, false); } } async function loadPlugins() { const grid = $("#plugin-grid"); const empty = $("#plugin-empty"); empty.classList.add("hidden"); grid.innerHTML = '
正在加载插件…
'; try { const cat = await apic.pluginCatalog(); pluginData = (cat && cat.plugins) || []; pluginLoaded = true; renderPlugins(); loadInstalledPlugins(); } catch (e) { pluginData = []; pluginLoaded = true; grid.innerHTML = ""; empty.classList.remove("hidden"); empty.innerHTML = "插件市场即将上线,敬请期待 —— tokendun 官方自研插件将陆续上架。"; loadInstalledPlugins(); } } $("#plugin-search").addEventListener("input", renderPlugins); $("#plugin-refresh").addEventListener("click", loadPlugins); document.querySelector('.tab[data-tab="plugins"]').addEventListener("click", () => { if (!pluginLoaded) loadPlugins(); }); // =========================================================================== // 设置 —— 密钥 // =========================================================================== async function loadSecrets() { try { const gh = await apic.getSecret("github"); if (gh) $("#set-github").value = gh; const ak = await apic.getSecret("adminKey"); if (ak) $("#set-admin").value = ak; } catch { /* 忽略 */ } } $("#btn-save-admin").addEventListener("click", async (ev) => { const btn = ev.currentTarget; busy(btn, true, "保存中…"); try { await apic.setSecret("adminKey", $("#set-admin").value.trim()); toast("管理员密钥已保存 ✅", "ok"); } catch (e) { toast(`保存失败:${e.message || e}`, "err"); } finally { busy(btn, false); } }); $("#btn-save-secret").addEventListener("click", async (ev) => { const val = $("#set-github").value.trim(); const btn = ev.currentTarget; busy(btn, true, "保存中…"); try { await apic.setSecret("github", val); toast("已保存 ✅", "ok"); } catch (e) { toast(`保存失败:${e.message || e}`, "err"); } finally { busy(btn, false); } }); // =========================================================================== // 强制清除本机 Codex —— 检测安装位置 + 一键清干净 // =========================================================================== const KIND_LABEL = { data: "数据目录", bin: "可执行文件", "npm-package": "npm 全局包", }; function renderPurgeList(items) { const box = $("#purge-list"); const actions = $("#purge-actions"); if (!items || !items.length) { box.innerHTML = '

未检测到本机安装的 Codex(可能未安装,或不在常见位置)。

'; actions.style.display = "none"; return; } box.innerHTML = items .map((it, i) => { const tag = KIND_LABEL[it.kind] || it.kind; return ``; }) .join(""); box._items = items; actions.style.display = "flex"; } async function purgeScan(btn) { busy(btn, true, "检测中…"); try { const items = await apic.codexDetect(); renderPurgeList(items); toast(items.length ? `检测到 ${items.length} 个位置` : "未检测到 Codex", items.length ? "ok" : ""); } catch (e) { toast("检测失败:" + (e.message || e), "err"); } finally { busy(btn, false); } } $("#btn-purge-scan").addEventListener("click", (ev) => purgeScan(ev.currentTarget)); $("#btn-purge-do").addEventListener("click", async (ev) => { const box = $("#purge-list"); const items = box._items || []; const paths = $$('#purge-list input[data-purge]') .filter((el) => el.checked) .map((el) => items[+el.dataset.purge]?.path) .filter(Boolean); if (!paths.length) { toast("请先勾选要清除的位置", "err"); return; } const btn = ev.currentTarget; busy(btn, true, "清除中…"); try { const r = await apic.codexPurge(paths); if (r.canceled) { toast("已取消", ""); return; } const ok = r.results.filter((x) => x.ok).length; const fail = r.results.length - ok; toast(fail ? `清除完成:成功 ${ok},失败 ${fail}(详见运行日志)` : `已彻底清除 ${ok} 个位置 ✅ 可重新安装 Codex 了`, fail ? "err" : "ok"); await purgeScan(btn); // 刷新列表,确认已清空 } catch (e) { toast("清除失败:" + (e.message || e), "err"); } finally { busy(btn, false); } }); // =========================================================================== // 会话记录 —— 浏览 / 搜索 / 阅读 / 导出 / 备份 / 导入 // =========================================================================== let sessData = []; let sessCurrent = null; let sessLoaded = false; let sessAllMode = false; function 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 = `
${sessData.length ? "没有匹配的对话" : "没有找到会话记录"}
`; sessUpdateScope(); return; } box.innerHTML = list .map( (s) => { const tags = [ s.archived ? '归档' : "", s.threadSource === "subagent" ? '子任务' : "", ].filter(Boolean).join(""); return `
${esc(s.title)}
${esc(s.project || "—")}${(s.ts || "").slice(0, 10)}${tags}
`; } ) .join(""); box.querySelectorAll(".sess-item").forEach((el) => el.addEventListener("click", () => sessOpen(+el.dataset.idx, el)) ); sessUpdateScope(); } async function sessOpen(idx, el) { const item = sessData[idx]; sessCurrent = item; $$(".sess-item").forEach((x) => x.classList.remove("active")); if (el) el.classList.add("active"); $("#sess-empty").classList.add("hidden"); $("#sess-conv").classList.remove("hidden"); $("#sess-conv-title").textContent = item.title; $("#sess-messages").innerHTML = `
读取中…
`; try { const conv = await apic.sessionRead(item.path); const html = conv.messages .map((m) => { const who = m.role === "user" ? "👤 用户" : m.role === "assistant" ? "🤖 Codex" : "🔧 工具"; return `
${who}
${esc(m.text)}
`; }) .join(""); $("#sess-messages").innerHTML = html || `
这条对话没有可显示的消息
`; } catch (e) { $("#sess-messages").innerHTML = `
读取失败:${esc(e.message || e)}
`; } } async function sessLoad(opts = {}) { if (Object.prototype.hasOwnProperty.call(opts, "all")) sessAllMode = !!opts.all; const box = $("#sess-list"); box.innerHTML = `
加载中…
`; try { sessData = await apic.sessionsList( sessAllMode ? { includeSubagent: true, includeArchived: true } : { includeSubagent: false, includeArchived: false } ); sessLoaded = true; sessCurrent = null; $("#sess-empty").classList.remove("hidden"); $("#sess-conv").classList.add("hidden"); sessRender(); if (opts.toast) toast(`${sessAllMode ? "已获取全部记录" : "已刷新"}:${sessData.length} 条`, "ok"); } catch (e) { box.innerHTML = `
加载失败:${esc(e.message || e)}
`; sessUpdateScope(); } } $("#sess-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-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 = `
还没有任何项目的会话被摘要。去 Codex 里聊一会儿,静默 ${st.idleMinutes} 分钟后这里就有了。
`; } else { box.innerHTML = st.projects .map( (p) => `
${esc(p.project || "(未知)")}
${esc(p.cwd)}
${p.count} 条 · ${(p.last || "").slice(0, 10)} ${p.hasHandoff ? `` : ""}
` ) .join(""); box.querySelectorAll("[data-mem-proj]").forEach((el) => el.addEventListener("change", async () => { try { memRenderStatus(await apic.memoryToggleProject(el.dataset.memProj, el.checked)); toast(el.checked ? "已开启该项目的记忆写入 ✅" : "已关闭该项目的记忆写入", "ok"); } catch (e) { toast("操作失败:" + (e.message || e), "err"); } }) ); box.querySelectorAll("[data-mem-open]").forEach((el) => el.addEventListener("click", async () => { try { await apic.memoryOpenHandoff(el.dataset.memOpen); } catch (e) { toast(e.message || String(e), "err"); } }) ); } // 项目筛选下拉(保留当前选中) const sel = $("#mem-project-filter"); const cur = sel.value; sel.innerHTML = '' + st.projects.map((p) => ``).join(""); sel.value = cur; } async function memRenderList() { const box = $("#mem-list"); const empty = $("#mem-empty"); empty.classList.add("hidden"); try { const rows = await apic.memoryList({ project: $("#mem-project-filter").value, q: $("#mem-search").value.trim(), limit: 100, }); if (!rows.length) { box.innerHTML = ""; empty.classList.remove("hidden"); empty.textContent = "没有匹配的摘要。"; return; } box.innerHTML = rows .map((r) => { const when = (r.ts || "").slice(0, 16).replace("T", " "); const dec = r.decisions.length ? `
决策${r.decisions.map((d) => esc(d)).join(" · ")}
` : ""; const next = r.next_steps.length ? `
下一步${r.next_steps.map((d) => esc(d)).join(" · ")}
` : ""; const files = r.files.length ? `
文件${esc(r.files.join("、"))}
` : ""; return `
${esc(r.topic)} ${esc(r.project || "—")} ${when} ${r.engine === "local" ? "本地提取" : "LLM"}
${esc(r.summary)}
${dec}${next}${files}
`; }) .join(""); box.querySelectorAll("[data-mem-redo]").forEach((el) => el.addEventListener("click", async (ev) => { const btn = ev.currentTarget; busy(btn, true, "摘要中…"); try { await apic.memoryResummarize(el.dataset.memRedo); toast("已重新摘要 ✅", "ok"); } catch (e) { toast("摘要失败:" + (e.message || e), "err"); } finally { busy(btn, false); } }) ); box.querySelectorAll("[data-mem-del]").forEach((el) => el.addEventListener("click", async () => { if (!confirm("删除这条摘要?(不影响原始会话记录)")) return; try { await apic.memoryDelete(el.dataset.memDel); } catch (e) { toast("删除失败:" + (e.message || e), "err"); } }) ); } catch (e) { box.innerHTML = ""; empty.classList.remove("hidden"); empty.textContent = "加载失败:" + (e.message || e); } } async function memLoad() { memLoaded = true; try { 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: `

插件是「命令包」,不是单个命令

一个插件(如 creative-production)里通常包含多个技能命令。安装后不是输入插件名,而是输入里面的具体命令,比如 /explore、/ads-explorer。

三步开始使用

1在「插件市场」搜索并点 安装,或在「备份与还原 → 从 GitHub 拉取还原」里拉取仓库后还原到本机。
2重启 Codex,让新装的插件生效。
3在 Codex 输入框打 /,再输入命令关键词(如 ads、scan)即可定位并运行对应功能。

备份 / 分享给他人

每个插件具体能干嘛?

点插件卡片可查看它的功能说明;各插件仓库首页的 README 里也有完整的命令清单与示例。

`, }, notice: { title: "📄 版权声明 / NOTICE", html: `

内容归属

本市场收录并可分发的插件为 各自作者(含 OpenAI Codex 官方插件) 的作品,其源代码及相关知识产权归 原始著作权人 所有。本工具仅提供备份、分享与在已获授权环境中的还原,不修改插件源代码。

使用范围

凭证安全

导出 / 上传时,凭证文件(auth.json、config.toml、history 等)已自动排除;GitHub Token 仅经系统安全存储(safeStorage)加密后保存在本机。

免责声明

本工具及其分发的备份按「现状(AS IS)」提供,不附带任何明示或默示担保。作者不对因使用而产生的任何后果负责。本声明不构成对第三方代码的任何再授权。

`, }, }; const modalOverlay = $("#modal-overlay"); function openModal(key) { const c = MODAL_CONTENT[key]; if (!c) return; $("#modal-title").textContent = c.title; $("#modal-body").innerHTML = c.html; const m = modalOverlay.querySelector(".modal"); if (m) m.classList.remove("cv-media-modal"); modalOverlay.classList.remove("hidden"); } function closeModal() { modalOverlay.classList.add("hidden"); const m = modalOverlay.querySelector(".modal"); if (m && m.classList.contains("cv-media-modal")) $("#modal-body").innerHTML = ""; if (m) m.classList.remove("cv-media-modal"); } $("#open-usage").addEventListener("click", () => openModal("usage")); $("#open-notice").addEventListener("click", () => openModal("notice")); $("#modal-close").addEventListener("click", closeModal); modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !modalOverlay.classList.contains("hidden")) closeModal(); }); // =========================================================================== // 路由中转 // =========================================================================== let routerState = null; let grpEditing = null; 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) => ``) .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 = `
还没有分组,点右上「+ 新增分组」添加一个中转 API
`; 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 ? '当前' : "") + (g.isDefault ? ' 默认·不可删' : "") + (needsRouter ? ' 需路由' : "") + (toolOnly ? ' 插件专用' : ""); const actions = g.isDefault ? '切回这里 = 直连你的账号' : ``; // chat 分组被选中但路由没在跑 → 醒目提示去启动 const routerHint = needsRouter && g.id === activeId && !st.running ? '
⚠️ 该分组需协议转换,请先在上方「启动」本地路由,否则 Codex 无法使用
' : ""; return `
${esc(g.name)} ${badges}${g.codexImage ? ' 🎨 生图' : ""}${g.codexVideo ? ' 🎬 视频' : ""}${g.stt ? ' 🎤 语音' + (g.sttModel ? " " + esc(g.sttModel) : "") + '' : ""}${g.multimodal ? ' 多模态' : ""}
${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${needsRouter ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}
${toolOnly ? '
画布/插件按需调用,不作为 Codex/GPT 默认对话分组
' : ""} ${routerHint}
${actions}
`; }; const colHtml = (title, hint, list, radioName, activeId) => `
${title}${hint}
${list.length ? list.map((g) => itemHtml(g, radioName, activeId)).join("") : '
该类型下暂无分组
'}
`; 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 = ''; $("#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 = '
读取中正在读取 ~/.codex/config.toml...
'; 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 = [ `
当前配置Provider:${esc(cur.modelProvider || "Codex 原生登录态")}
Model:${esc(cur.model || "未设置")}
状态:${cur.managed ? "当前被中转/路由接管" : "官方直连"}
${esc(authLine(cur.auth))}
`, `
默认快照Provider:${esc(snap.modelProvider || "Codex 原生登录态")}
Model:${esc(snap.model || "未设置")}
状态:${snap.managed ? "异常:仍含接管配置" : "干净官方配置"}
修复时间:${esc(fmtTime(snap.repairedAt || snap.savedAt))}
`, `
config.toml${esc(info.configPath || "~/.codex/config.toml")}
`, `
auth.json${esc(authLine(snap.auth))}
${esc(info.authPath || "~/.codex/auth.json")}
`, ].join(""); current.value = info.currentConfigToml || "(未读取到当前 config.toml)"; snapshot.value = info.snapshotConfigToml || "(没有默认快照内容:可能是旧版本创建的默认项,或首次记录时 config.toml 为空)"; } catch (e) { summary.innerHTML = `
读取失败${esc(e.message || e)}
`; } } 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 = '' + models.map((m) => ``).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 ? `` : ``) + ``; if (mandatory) $("#upd-quit").addEventListener("click", () => apic.quitApp()); else $("#upd-later").addEventListener("click", closeUpdateModal); $("#upd-go").addEventListener("click", onGo); } async function onGo(ev) { if (!isHot) { apic.openExternal(link); return; } // 热更:下载 → 校验 → 应用 → 重启 const go = ev ? ev.currentTarget : $("#upd-go"); go.disabled = true; const later = $("#upd-later"); if (later) later.disabled = true; const stop = apic.onHotProgress((p) => { go.textContent = `下载中 ${p}%`; }); try { await apic.hotApply(); stop && stop(); updateMandatory = true; // 应用后锁定,引导重启 $("#update-notes").textContent = "更新已下载并校验完成 ✅ 重启后生效。"; foot.innerHTML = ``; $("#upd-restart").addEventListener("click", () => apic.hotRestart()); } catch (e) { stop && stop(); go.disabled = false; const later2 = $("#upd-later"); if (later2) later2.disabled = false; toast(`更新失败:${e.message || e}`, "err"); go.textContent = "重试"; } } renderButtons("立即更新"); updateModal.classList.remove("hidden"); } function closeUpdateModal() { if (updateMandatory) return; // 强制更新不允许关闭 updateModal.classList.add("hidden"); } // 一般更新:点背景关闭;强制更新:点背景无效 updateModal.addEventListener("click", (e) => { if (e.target === updateModal) closeUpdateModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !updateModal.classList.contains("hidden")) closeUpdateModal(); }); $("#btn-check-update").addEventListener("click", async (ev) => { const btn = ev.currentTarget; const info = $("#update-info"); busy(btn, true, "检查中…"); info.textContent = ""; try { // 先查热更(gitea,国内可达) const h = await apic.hotCheck(); if (h && h.canHot) { info.textContent = `发现可在线更新 v${h.contentVersion}`; showUpdateModal({ version: h.contentVersion, notes: h.notes, mandatory: h.mandatory, kind: "hot" }); return; } // 否则查完整安装包 const r = await apic.checkUpdate(); if (r.dev) info.textContent = "(开发版不检查更新)"; else if (r.network) { info.innerHTML = `⚠️ ${esc(r.error)} 前往下载页 ↗`; const a = $("#dl-manual"); if (a) a.addEventListener("click", () => apic.openExternal(r.url || RELEASE_FALLBACK)); } else if (r.error) info.textContent = "检查失败:" + r.error; else if (r.hasNew) { info.textContent = `发现新版本 v${r.version}`; showUpdateModal({ version: r.version, notes: r.notes, mandatory: r.mandatory, url: r.url, kind: "full" }); } else info.textContent = "已是最新版本 ✅"; } catch (e) { info.textContent = "检查失败:" + (e.message || e); } finally { busy(btn, false); } }); apic.onUpdateStatus((s) => { const info = $("#update-info"); if (s.state === "hot-available") { info.textContent = `发现可在线更新 v${s.version}`; showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, kind: "hot" }); } else if (s.state === "available-manual") { info.textContent = `发现新版本 v${s.version}`; showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, url: s.url, kind: "full" }); } else if (s.state === "available") info.textContent = `发现新版本 ${s.version},下载中…`; else if (s.state === "downloading") info.textContent = `下载新版本 ${s.percent}%…`; else if (s.state === "downloaded") info.textContent = `新版本 ${s.version} 已就绪,退出软件即自动更新`; else if (s.state === "none") info.textContent = "已是最新版本 ✅"; else if (s.state === "error") info.textContent = "更新检查失败:" + (s.message || ""); }); // ============================================================ // 创意画布(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, "", '
参考图 · 本地缓存
'); } } 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) => `参考图`).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 = '
还没有画布记录,新建一个开始创作。
'; return; } document.getElementById("cv-project-close")?.classList.toggle("hidden", !CANVAS.workspaceReady); list.innerHTML = items.map((item) => { const renaming = item.id === CANVAS.workspaceRenamingId; return `
${renaming ? `` : `
${esc(item.name || "未命名画布")}
`}
${esc(cvWorkspaceTime(item.updatedAt))} · ${Number(item.nodeCount) || 0} 个节点 · ${Number(item.mediaCount) || 0} 个本地素材
${renaming ? ` ` : ` `}
`; }).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 ? `` : ``; const ratioOptions = isGrok ? `` : ``; return `
▦${title}
▣
参考图 0 张(连线 / @ 同步) 待定
↖ + ⛶
文生 · 16:9 · 720p · 5s
`; } function cvCompactImageBody() { return `
▧图片
▧
参考图 0 张(连线 / @ 同步) 待定
↖ + ⛶
1:1 · 1K
`; } function cvCompactTtsBody() { return `
♪音频
♪
连接文本 / 手动输入 待定
↖ + ⛶
alloy · mp3
`; } function cvCompactTextBody() { return `
T文本
T
作为输入源输出文本 文本
↖ +
作为输入源,实时输出上面的文本
`; } // 各类节点的表单主体(Drawflow 以 HTML 字符串注入到 .drawflow_content_node) function cvNodeBody(kind) { if (kind === "text") { return cvCompactTextBody(); return `
作为输入源,实时输出上面的文本
`; } if (kind === "image") { return cvCompactImageBody(); return `
选填:运行时自动传图床换外链,作图生图参考
⚙️ 高级
`; } if (kind === "video") { return cvCompactVideoBody("video"); return `
支持 Seedance / Seedance V2:切换分组后自动匹配接口
⚙️ 高级
`; } if (kind === "grokVideo") { return cvCompactVideoBody("grokVideo"); return `
通过 CLMM 聚合接口提交,模型请使用拉取到的完整名称
⚙️ 高级
`; } if (kind === "tts") { return cvCompactTtsBody(); return `
`; } return ""; } // 整个节点的 HTML(外层卡片 + 头部标题 + 主体) function cvNodeHTML(kind) { const m = CV_NODES[kind]; const tag = m.tag ? `${m.tag}` : ""; return `
${m.icon} ${m.title}${tag}
${cvNodeBody(kind)}
`; } 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 = '
参考图 · 上传
'; 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 = '
首帧 · 上传
'; 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 = ``; 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 ``; }) .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 = [''] .concat(list.map((item) => ``)); if (current && !list.includes(current)) { options.splice(1, 0, ``); } model.innerHTML = options.join(""); if (current) model.value = current; return; } const dl = nodeEl.querySelector(".cv-node-datalist"); if (dl) dl.innerHTML = list.map((item) => ``).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 = '' + (current ? `` : ""); 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 ? "▦Seedance V2" : "▦视频"; } } // 分组下拉变化:清空旧模型候选,并按 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 = ''; 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 ? `` : `生成图片`; return `
${media}
${foot}`; } function cvAudioPreviewChrome(dataUrl, foot) { return `
${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 ? `
${esc(info)}
` : ""; if (kind === "image") { el.innerHTML = cvPreviewChrome(kind, r.dataUrl, r.url || "", foot); } else if (kind === "video" || kind === "grokVideo") { el.innerHTML = cvPreviewChrome(kind, r.dataUrl, r.url || "", foot); } else if (kind === "tts") { el.innerHTML = 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]; } } // 把预览区里的 换成新 dataUrl;若还是 skeleton 则替换成 function cvSetPreviewImage(prevEl, dataUrl) { if (!prevEl || !dataUrl) return; prevEl.classList.remove("hidden"); if (prevEl.classList.contains("cv-video-frame-inner")) { const foot = '
生成中…
'; prevEl.innerHTML = cvPreviewChrome("image", dataUrl, "", foot); return; } const img = prevEl.querySelector("img.cv-media"); if (img) img.src = dataUrl; else prevEl.innerHTML = `生成中…`; } // 订阅后端流式进度(幂等,页面生命周期内保留一次) function cvBindProgress() { if (CANVAS.progressBound) return; if (!apic.onCanvasProgress) return; // 后端未接入则不订阅 CANVAS.progressBound = true; apic.onCanvasProgress((p) => { try { if (!p || !p.runId || !p.dataUrl) return; const nodeId = CANVAS.runMap[p.runId]; if (!nodeId) return; // 无映射(已结束/已清理)→ 丢弃,避免串图 const nodeEl = document.getElementById(`node-${nodeId}`); if (!nodeEl) return; cvSetPreviewImage(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 = `
生成中…
`; } 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) => ``).join("") : ``; 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) => ``).join("") : ``; 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) => ``).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) => ``).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) => ``).join("") : ``; 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) => `${esc(s)}`).join(""); head.innerHTML = `
${esc(team.project_name || "AI 员工团队")}
` + (team.summary ? `
${esc(team.summary)}
` : "") + (stack ? `
${stack}
` : "") + (team.model_routing ? `
生成:${esc(team.model_routing.generation_model || team.model_routing.analysis_model || "—")} 总控执行:${esc(team.model_routing.orchestrator_model || "继承")} 子 Agent 执行:${esc(team.model_routing.worker_model || "继承")}
` : ""); 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) => `
  • ${esc(x)}
  • `).join(""); const div = document.createElement("div"); div.className = "tm-conv-card"; div.innerHTML = `
    团队协同规范
    接口契约 owner:${esc(owner ? owner.name : team.contract_owner || "—")} · 全部规范已写进每个 agent 的 prompt,并随包附 CONVENTIONS.md
    统一产出格式
    完成定义 (DoD)
    Git / 文件边界
    ${(team.security_checklist || []).length ? `
    安全 / 权限清单
    ` : `
    任务交接协议
    `}
    `; return div; } function tmAgentCard(a, isLead) { const div = document.createElement("div"); div.className = "tm-emp-card" + (isLead ? " lead" : ""); const resp = (a.responsibilities || []).map((r) => `
  • ${esc(r)}
  • `).join(""); const cons = (a.constraints || []).map((c) => `${esc(c)}`).join(""); let extra = ""; if (isLead && (a.workflow || []).length) { extra = `
    调度流程
      ${a.workflow.map((w) => `
    1. ${esc(w)}
    2. `).join("")}
    `; } else if (!isLead) { const collab = (a.collaborators || []).map((c) => `${esc(c)}`).join(""); const rows = []; if (collab) rows.push(`
    协作${collab}
    `); if (a.handoff_in) rows.push(`
    接收${esc(a.handoff_in)}
    `); if (a.handoff_out) rows.push(`
    交付${esc(a.handoff_out)}
    `); if (rows.length) extra = `
    ${rows.join("")}
    `; } div.innerHTML = `
    ${esc(a.name || "")} ${isLead ? '总控' : ""} ${esc(a.id || "")}
    ${a.model ? `
    模型:${esc(a.model)}
    ` : ""}
    ${esc(a.duty || "")}
    ${resp ? `
    职责
    ` : ""} ${extra} ${cons ? `
    约束
    ${cons}
    ` : ""}
    `; 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) => ``).join("") : ``; 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) => ``).join("") : ``; 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 = `
    🖼️→📝

    上传素材,反推它的生图提示词

    精准分析九个视觉维度,输出可直接复现的双语提示词

    七维拆解中英双语负面提示词关键词标签
    `; } function pmRenderLoading() { const body = document.getElementById("pm-result-body"); if (!body) return; body.innerHTML = `
    正在分析图片…
    `; } function pmRenderError(msg) { const body = document.getElementById("pm-result-body"); if (!body) return; body.innerHTML = `
    ⚠ ${esc(msg)}
    `; } 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 = `${confLabel}`; // 关键词标签 const tagsHTML = (data.tags_en || []).map((t) => `${esc(t)}`).join(""); // 维度拆解 const breakdownHTML = (data.breakdown || []).map((d) => `
    ${esc(d.label_zh || "")} ${esc(d.label_en || "")}
    ${esc(d.value_zh || "")} ${esc(d.value_en || "")}
    `).join(""); // 备注 const notesHTML = data.notes_zh ? `

    ${esc(data.notes_zh)}

    ` : ""; body.innerHTML = `
    分析结果 ${confHTML}
    英文提示词 推荐使用

    ${esc(data.prompt_en || "")}

    中文对照

    ${esc(data.prompt_zh || "")}

    负面提示词 Negative

    ${esc(data.negative_prompt_en || "")}

    ${esc(data.negative_prompt_zh || "")}

    ${tagsHTML ? `
    ${tagsHTML}
    ` : ""} ${breakdownHTML ? `
    ${breakdownHTML}
    ` : ""} ${notesHTML}
    `; } // ---- 事件绑定 ---- (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); });