5666 lines
238 KiB
JavaScript
5666 lines
238 KiB
JavaScript
// 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, "<br>");
|
||
} catch (e) {
|
||
if (out) out.textContent = "诊断失败:" + (e && e.message ? e.message : e);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// ① 插件市场
|
||
// ===========================================================================
|
||
let skillCat = null; // 完整 catalog {total, categories, skills}
|
||
let storeCat = "__all__"; // 当前分类(__all__ / __hot__ / 中文分类名)
|
||
let storeSort = "hot"; // hot / downloads / stars
|
||
let storePage = 0;
|
||
const STORE_PAGE = 24;
|
||
const installed = new Set(JSON.parse(localStorage.getItem("installedSkills") || "[]"));
|
||
let installedSkills = []; // 磁盘实况(~/.codex/skills 扫描结果)
|
||
let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装”
|
||
|
||
// 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)。统一小写,避免大小写变体导致"已安装"判断错位。
|
||
function safeSkillDir(name) {
|
||
return (String(name || "skill")
|
||
.toLowerCase()
|
||
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
|
||
}
|
||
async function loadInstalledSkills(opts = {}) {
|
||
try { installedSkills = await apic.skillInstalled(opts); }
|
||
catch { installedSkills = []; }
|
||
installedDirs = new Set(installedSkills.map((x) => x.dir));
|
||
}
|
||
|
||
// 分类图标(与网页语义对应)
|
||
const CAT_ICON = {
|
||
"全部": "🗂️", "热门": "🔥", "API": "🔌", "办公": "📊", "设计": "🎨",
|
||
"效率": "⚡", "内容媒体": "📝", "文档处理": "📄", "开发": "🛠️",
|
||
"教育科研": "🎓", "图表": "📈", "客服": "🎧", "后端": "🗄️",
|
||
"数据分析": "📊", "数据库": "🗃️", "产品管理": "📋", "运维": "⚙️",
|
||
"财务": "💰", "健康": "🩺", "前端": "🖥️", "Git": "🔀", "人力资源": "👥",
|
||
"图片生成": "🖼️", "法律": "⚖️", "市场": "📣", "移动端": "📱", "运营": "🚀",
|
||
"支付": "💳", "项目管理": "🗓️", "React": "⚛️", "房产": "🏠", "销售": "🤝",
|
||
"测试": "🧪", "AI增强": "🤖", "其它": "🧩", "我已安装": "🧰",
|
||
};
|
||
const AGENT_CLASS = { Claude: "claude", Codex: "codex", OpenClaw: "openclaw" };
|
||
|
||
function numify(s) {
|
||
if (!s) return 0;
|
||
const n = parseFloat(String(s).replace(/,/g, ""));
|
||
if (isNaN(n)) return 0;
|
||
if (/k/i.test(s)) return n * 1e3;
|
||
if (/m/i.test(s)) return n * 1e6;
|
||
return n;
|
||
}
|
||
|
||
// 某个技能在「我的 gitea 仓库」里的文件页地址
|
||
function skillGitUrl(s) {
|
||
// 浏览走 update 域名(管理登录会话在此),clone 用 up 域名(git 直连)
|
||
const base = (skillCat.repo || "")
|
||
.replace(/\.git$/, "")
|
||
.replace("up.tokendun.cc", "update.tokendun.cc"); // https://update.tokendun.cc/mhgmh/skills
|
||
if (!base) return "https://hub.cocoloop.cn/popular";
|
||
if (!s.path) return base; // 无本地路径时退回仓库首页
|
||
// 中文分类目录需 URL 编码,路径分隔符保留
|
||
const enc = s.path.split("/").map(encodeURIComponent).join("/");
|
||
return `${base}/src/branch/main/${enc}`;
|
||
}
|
||
|
||
// 当前筛选后的技能列表
|
||
function storeFiltered() {
|
||
const q = $("#market-search").value.trim().toLowerCase();
|
||
let list = skillCat.skills;
|
||
if (storeCat === "__hot__") {
|
||
list = list.filter((s) => numify(s.dl) >= 50000 || numify(s.fav) >= 200000);
|
||
} else if (storeCat !== "__all__") {
|
||
list = list.filter((s) => s.category === storeCat || (s.cnTags || []).includes(storeCat));
|
||
}
|
||
if (q) {
|
||
list = list.filter((s) =>
|
||
(s.name || "").toLowerCase().includes(q) ||
|
||
(s.author || "").toLowerCase().includes(q) ||
|
||
(s.subtitle || "").toLowerCase().includes(q) ||
|
||
(s.brief || "").toLowerCase().includes(q)
|
||
);
|
||
}
|
||
if (storeSort === "downloads") list = [...list].sort((a, b) => numify(b.dl) - numify(a.dl));
|
||
else if (storeSort === "stars") list = [...list].sort((a, b) => numify(b.stars) - numify(a.stars));
|
||
// hot:保持 catalog 原序(已按下载量排)
|
||
return list;
|
||
}
|
||
|
||
function agentBadges(s) {
|
||
const a = (s.agents || []).map(
|
||
(x) => `<span class="agent-badge ${AGENT_CLASS[x] || ""}">${esc(x)}</span>`
|
||
).join("");
|
||
const mm = s.mm ? '<span class="agent-badge mm">🎨 多模态</span>' : "";
|
||
return `<div class="sk-agents"><span class="lab">适配</span>${a}${mm}</div>`;
|
||
}
|
||
|
||
function skillCardHTML(s) {
|
||
const sec = (s.sec || "").toUpperCase();
|
||
const secBadge = /^[SAB]$/.test(sec) ? `<div class="sk-sec ${sec}" title="安全评级 ${sec}">${sec}</div>` : "";
|
||
const isIn = installedDirs.has(safeSkillDir(s.name)) || installed.has(String(s.id));
|
||
const meta = [
|
||
`<span>⬇ ${esc(s.dl || "0")}</span>`,
|
||
s.stars ? `<span>★ ${esc(s.stars)}</span>` : "",
|
||
s.fav ? `<span>♥ ${esc(s.fav)}</span>` : "",
|
||
s.cred ? `<span class="cred">${esc(s.cred)}</span>` : "",
|
||
].join("");
|
||
return `
|
||
<div class="skill-card" data-id="${esc(String(s.id))}">
|
||
<div class="sk-top">
|
||
<div class="sk-ico">${esc(s.icon || "🧩")}</div>
|
||
<div class="sk-headings">
|
||
<div class="sk-name">${esc(s.name)}</div>
|
||
<div class="sk-author">by ${esc(s.author || "未知")}</div>
|
||
</div>
|
||
${secBadge}
|
||
</div>
|
||
<div class="sk-subtitle">${esc(s.subtitle || s.name)}</div>
|
||
<div class="sk-brief">${esc(s.brief || "")}</div>
|
||
${agentBadges(s)}
|
||
<div class="sk-foot">
|
||
<div class="sk-meta">${meta}</div>
|
||
<div class="sk-card-actions">
|
||
<button class="btn ghost small install" data-install="${esc(String(s.id))}" title="${isIn ? "重新安装技能" : "安装技能"}">${isIn ? "重新安装" : "安装"}</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderStore(reset = true) {
|
||
const grid = $("#market-grid");
|
||
const empty = $("#market-empty");
|
||
const more = $("#store-more");
|
||
if (storeCat === "__installed__") { renderInstalledSkills(); return; }
|
||
const list = storeFiltered();
|
||
if (reset) storePage = 0;
|
||
if (!list.length) {
|
||
grid.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.textContent = "没有匹配的技能,换个关键词或分类试试。";
|
||
more.classList.add("hidden");
|
||
return;
|
||
}
|
||
empty.classList.add("hidden");
|
||
const pageCount = Math.ceil(list.length / STORE_PAGE);
|
||
storePage = Math.min(storePage, pageCount - 1);
|
||
const first = storePage * STORE_PAGE;
|
||
const next = list.slice(first, first + STORE_PAGE);
|
||
grid.innerHTML = next.map(skillCardHTML).join("");
|
||
|
||
$("#store-cat-title").textContent =
|
||
storeCat === "__all__" ? "全部技能" : storeCat === "__hot__" ? "热门技能" : storeCat;
|
||
$("#store-count").textContent = `${list.length.toLocaleString()} 个`;
|
||
$("#store-cat-sub").textContent =
|
||
`通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`;
|
||
renderStorePager(more, pageCount, list.length);
|
||
bindStoreCards();
|
||
}
|
||
|
||
function renderStorePager(more, pageCount, total) {
|
||
more.classList.remove("hidden");
|
||
if (pageCount <= 1) {
|
||
more.innerHTML = `<span class="muted small">共 ${total.toLocaleString()} 个技能</span>`;
|
||
return;
|
||
}
|
||
const pages = new Set([0, pageCount - 1, storePage - 1, storePage, storePage + 1]);
|
||
const orderedPages = [...pages].filter((p) => p >= 0 && p < pageCount).sort((a, b) => a - b);
|
||
let last = -1;
|
||
const pageButtons = orderedPages.map((p) => {
|
||
const gap = p - last > 1 ? `<span class="store-page-gap">…</span>` : "";
|
||
last = p;
|
||
return `${gap}<button class="store-page-btn${p === storePage ? " active" : ""}" data-store-page="${p}" aria-label="第 ${p + 1} 页" aria-current="${p === storePage ? "page" : "false"}">${p + 1}</button>`;
|
||
}).join("");
|
||
more.innerHTML = `
|
||
<span class="store-page-summary">${storePage * STORE_PAGE + 1}-${Math.min((storePage + 1) * STORE_PAGE, total)} / ${total.toLocaleString()}</span>
|
||
<div class="store-pagination" aria-label="技能分页">
|
||
<button class="store-page-btn" data-store-page="${storePage - 1}" title="上一页" aria-label="上一页" ${storePage === 0 ? "disabled" : ""}>‹</button>
|
||
${pageButtons}
|
||
<button class="store-page-btn" data-store-page="${storePage + 1}" title="下一页" aria-label="下一页" ${storePage === pageCount - 1 ? "disabled" : ""}>›</button>
|
||
</div>`;
|
||
more.querySelectorAll("[data-store-page]").forEach((btn) => btn.addEventListener("click", () => {
|
||
const nextPage = Number(btn.dataset.storePage);
|
||
if (Number.isNaN(nextPage) || nextPage < 0 || nextPage >= pageCount || nextPage === storePage) return;
|
||
storePage = nextPage;
|
||
renderStore(false);
|
||
$("#market-grid").scrollIntoView({ block: "start", behavior: "smooth" });
|
||
}));
|
||
}
|
||
|
||
function bindStoreCards() {
|
||
const grid = $("#market-grid");
|
||
grid.querySelectorAll(".skill-card[data-id]").forEach((card) => {
|
||
if (card._bound) return; card._bound = true;
|
||
card.tabIndex = 0;
|
||
card.setAttribute("role", "button");
|
||
card.setAttribute("aria-label", `查看 ${card.querySelector(".sk-name")?.textContent || "技能"} 详情`);
|
||
const openDetail = () => openSkillDetail(card.dataset.id);
|
||
card.addEventListener("click", (ev) => {
|
||
if (ev.target.closest("[data-install]")) return;
|
||
openDetail();
|
||
});
|
||
card.addEventListener("keydown", (ev) => {
|
||
if (ev.key !== "Enter" && ev.key !== " ") return;
|
||
ev.preventDefault();
|
||
openDetail();
|
||
});
|
||
});
|
||
grid.querySelectorAll("[data-install]").forEach((btn) => {
|
||
if (btn._bound) return; btn._bound = true;
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.stopPropagation();
|
||
installSkill(btn.dataset.install, btn);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 「我已安装」视图:以磁盘 ~/.codex/skills 为准,可逐个卸载
|
||
function renderInstalledSkills() {
|
||
const grid = $("#market-grid");
|
||
const empty = $("#market-empty");
|
||
const more = $("#store-more");
|
||
more.classList.add("hidden");
|
||
more.innerHTML = "";
|
||
$("#store-cat-title").textContent = "我已安装";
|
||
$("#store-count").textContent = `${installedSkills.length.toLocaleString()} 个`;
|
||
$("#store-cat-sub").textContent =
|
||
`Codex 侧位于 ~/.codex/skills · 卸载会同时删除工具箱仓库实体`;
|
||
if (!installedSkills.length) {
|
||
grid.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.textContent = "还没有安装任何技能。去左侧分类里挑一个安装吧。";
|
||
return;
|
||
}
|
||
empty.classList.add("hidden");
|
||
grid.innerHTML = installedSkills.map((s) => `
|
||
<div class="skill-card installed" data-dir="${esc(s.dir)}">
|
||
<div class="sk-top">
|
||
<div class="sk-ico">🧩</div>
|
||
<div class="sk-headings">
|
||
<div class="sk-name">${esc(s.name || s.dir)}</div>
|
||
<div class="sk-author">已安装${s.symlink ? " · 软链" : ""} · ${humanSize(s.size || 0)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="sk-brief">${esc(s.description || "(该技能未提供描述)")}</div>
|
||
<div class="sk-foot">
|
||
<button class="btn danger small" data-uninstall="${esc(s.dir)}">卸载</button>
|
||
<span class="muted small mono">${esc(s.dir)}</span>
|
||
</div>
|
||
</div>`).join("");
|
||
grid.querySelectorAll("[data-uninstall]").forEach((btn) =>
|
||
btn.addEventListener("click", () => uninstallSkill(btn.dataset.uninstall, btn))
|
||
);
|
||
}
|
||
|
||
async function uninstallSkill(dir, btn) {
|
||
const item = installedSkills.find((x) => x.dir === dir);
|
||
const label = (item && item.name) || dir;
|
||
if (!confirm(`确认卸载技能「${label}」?\n\n将删除 ~/.codex/skills/${dir} 以及对应工具箱仓库实体,此操作不可恢复。`)) return;
|
||
busy(btn, true, "卸载中…");
|
||
try {
|
||
await apic.skillUninstall(dir);
|
||
// 清掉旧的 localStorage 标记(按 id 存的,尽力匹配)
|
||
await loadInstalledSkills({ includeSize: storeCat === "__installed__", includeMeta: true });
|
||
toast(`已卸载 ${label} ✅`, "ok");
|
||
renderInstalledSkills();
|
||
renderStoreNav();
|
||
} catch (e) {
|
||
toast(`卸载失败:${e.message || e}`, "err");
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
function renderStoreNav() {
|
||
const nav = $("#store-nav");
|
||
$("#store-total").textContent = skillCat.total.toLocaleString();
|
||
const item = (key, name, count) => {
|
||
const active = storeCat === key ? " active" : "";
|
||
const c = count != null ? `<span class="nav-count">${count.toLocaleString()}</span>` : "";
|
||
return `<button type="button" class="nav-item${active}" data-cat="${esc(key)}" aria-pressed="${storeCat === key}">
|
||
<span class="nav-ico">${CAT_ICON[name] || "•"}</span>
|
||
<span class="nav-name">${esc(name)}</span>${c}</button>`;
|
||
};
|
||
let html = "";
|
||
html += `<div class="store-nav-group">精选</div>`;
|
||
html += item("__all__", "全部", skillCat.total);
|
||
html += item("__hot__", "热门", null);
|
||
html += item("__installed__", "我已安装", installedSkills.length);
|
||
html += `<div class="store-nav-group">全部分类</div>`;
|
||
for (const c of skillCat.categories) html += item(c.name, c.name, c.count);
|
||
nav.innerHTML = html;
|
||
nav.querySelectorAll(".nav-item").forEach((el) =>
|
||
el.addEventListener("click", () => selectStoreCategory(el.dataset.cat))
|
||
);
|
||
renderStoreCategoryChips();
|
||
}
|
||
|
||
function renderStoreCategoryChips() {
|
||
const chips = $("#store-category-chips");
|
||
if (!skillCat || !chips) return;
|
||
const quickCategories = skillCat.categories.slice(0, 9);
|
||
const item = (key, name) => `<button type="button" class="store-filter-chip${storeCat === key ? " active" : ""}" data-store-filter="${esc(key)}" aria-pressed="${storeCat === key}">${esc(name)}</button>`;
|
||
chips.innerHTML = [
|
||
item("__all__", "全部"),
|
||
item("__hot__", "热门"),
|
||
...quickCategories.map((c) => item(c.name, c.name)),
|
||
].join("");
|
||
chips.querySelectorAll("[data-store-filter]").forEach((el) =>
|
||
el.addEventListener("click", () => selectStoreCategory(el.dataset.storeFilter))
|
||
);
|
||
}
|
||
|
||
async function selectStoreCategory(category) {
|
||
storeCat = category;
|
||
$("#market-grid").scrollTop = 0;
|
||
if (storeCat === "__installed__") await loadInstalledSkills({ includeSize: true, includeMeta: true });
|
||
renderStoreNav();
|
||
renderStore(true);
|
||
}
|
||
|
||
async function installSkill(id, btn) {
|
||
const s = skillCat.skills.find((x) => String(x.id) === String(id));
|
||
if (!s) return;
|
||
busy(btn, true, "安装中…");
|
||
btn.classList.add("installing");
|
||
try {
|
||
const r = await apic.skillInstall(s);
|
||
installed.add(String(s.id));
|
||
localStorage.setItem("installedSkills", JSON.stringify([...installed]));
|
||
await loadInstalledSkills({ includeSize: storeCat === "__installed__", includeMeta: true });
|
||
renderStoreNav();
|
||
toast(`已安装 ${s.name} ✅(${r.files} 个文件)重启 Codex 生效`, "ok");
|
||
btn.textContent = "已安装 ✓";
|
||
btn.dataset.label = "已安装 ✓";
|
||
} catch (e) {
|
||
toast(`安装失败:${e.message || e}`, "err");
|
||
busy(btn, false);
|
||
} finally {
|
||
btn.classList.remove("installing");
|
||
}
|
||
}
|
||
|
||
function openSkillDetail(id) {
|
||
const s = skillCat.skills.find((x) => String(x.id) === String(id));
|
||
if (!s) return;
|
||
$("#modal-title").textContent = "技能详情";
|
||
const tags = (s.cnTags || []).map((t) => `<span class="sd-tag">${esc(t)}</span>`).join("");
|
||
$("#modal-body").innerHTML = `
|
||
<div class="sd-hero">
|
||
<div class="sk-ico">${esc(s.icon || "🧩")}</div>
|
||
<div>
|
||
<h3>${esc(s.name)}</h3>
|
||
<p class="muted small" style="margin:3px 0 0">by ${esc(s.author || "未知")} · 分类 ${esc(s.category)}</p>
|
||
</div>
|
||
</div>
|
||
<p style="font-weight:600;font-size:14px">${esc(s.subtitle || "")}</p>
|
||
<p>${esc(s.brief || "(暂无简介)")}</p>
|
||
${tags ? `<div class="sd-tags">${tags}</div>` : ""}
|
||
<div class="sd-stats">
|
||
<span>⬇ 下载 ${esc(s.dl || "0")}</span>
|
||
${s.stars ? `<span>★ 星标 ${esc(s.stars)}</span>` : ""}
|
||
${s.fav ? `<span>♥ 收藏 ${esc(s.fav)}</span>` : ""}
|
||
${s.sec ? `<span>🛡 安全级 ${esc(s.sec)}</span>` : ""}
|
||
${s.cred ? `<span>可信度 ${esc(s.cred)}</span>` : ""}
|
||
</div>
|
||
<h4>适配 AI 模型 / Agent</h4>
|
||
${agentBadges(s)}
|
||
<p class="muted small" style="margin-top:8px">${esc(skillCat.model_note || "SKILL.md 标准技能")}${s.mm ? ";本技能涉及图片/视觉,建议搭配多模态强模型。" : "。"}</p>
|
||
<div class="sd-install-row">
|
||
<button class="btn primary" id="sd-install">安装到 ~/.codex/skills</button>
|
||
<button class="btn ghost" id="sd-source">在我的 Git 查看 ↗</button>
|
||
</div>`;
|
||
modalOverlay.classList.remove("hidden");
|
||
$("#sd-install").addEventListener("click", (e) => installSkill(s.id, e.currentTarget));
|
||
$("#sd-source").addEventListener("click", () => apic.openExternal(skillGitUrl(s)));
|
||
}
|
||
|
||
async function loadMarket() {
|
||
const grid = $("#market-grid");
|
||
const empty = $("#market-empty");
|
||
empty.classList.add("hidden");
|
||
grid.innerHTML = '<div class="empty"><div class="spinner"></div>正在加载技能商店…</div>';
|
||
try {
|
||
skillCat = await apic.skillCatalog();
|
||
renderStoreNav();
|
||
renderStore(true);
|
||
deferUI(async () => {
|
||
await loadInstalledSkills({ includeSize: false, includeMeta: false });
|
||
renderStore(true);
|
||
}, 900);
|
||
} catch (e) {
|
||
grid.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.textContent = `加载失败:${e.message || e}`;
|
||
}
|
||
}
|
||
|
||
let storeSearchTimer = null;
|
||
$("#market-search").addEventListener("input", () => {
|
||
clearTimeout(storeSearchTimer);
|
||
storeSearchTimer = setTimeout(() => renderStore(true), 180);
|
||
});
|
||
$("#store-sort").querySelectorAll(".seg").forEach((s) => {
|
||
s.addEventListener("click", () => {
|
||
$("#store-sort").querySelectorAll(".seg").forEach((x) => x.classList.remove("active"));
|
||
s.classList.add("active");
|
||
storeSort = s.dataset.sort;
|
||
renderStore(true);
|
||
});
|
||
});
|
||
|
||
// ===========================================================================
|
||
// ② 插件市场(tokendun 官方自研插件,数据源自 marketList)
|
||
// ===========================================================================
|
||
let pluginData = [];
|
||
let pluginLoaded = false;
|
||
|
||
function pluginCardHTML(e) {
|
||
const platLabel = { macos: "仅 macOS", windows: "仅 Windows", linux: "仅 Linux" }[(e.platform || "").toLowerCase()];
|
||
const platBadge = platLabel ? `<span class="pc-badge plat">${platLabel}</span>` : "";
|
||
return `
|
||
<div class="plugin-card">
|
||
<div class="pc-top"><span class="pc-badge plugin">插件</span>${platBadge}<div class="pc-name">${esc(e.displayName || e.name)}</div></div>
|
||
<div class="pc-meta">
|
||
<span>${humanSize(e.size || 0)}</span>
|
||
<span>tokendun 官方</span>
|
||
</div>
|
||
<div class="pc-desc">${esc(e.description) || "(暂无描述)"}</div>
|
||
<div class="pc-foot">
|
||
<span></span>
|
||
<button class="btn primary small" data-install='${esc(JSON.stringify(e))}'>安装</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderPlugins() {
|
||
const q = $("#plugin-search").value.trim().toLowerCase();
|
||
const grid = $("#plugin-grid");
|
||
const empty = $("#plugin-empty");
|
||
const list = pluginData.filter((e) =>
|
||
!q ||
|
||
(e.displayName || "").toLowerCase().includes(q) ||
|
||
(e.name || "").toLowerCase().includes(q) ||
|
||
(e.source || "").toLowerCase().includes(q) ||
|
||
(e.description || "").toLowerCase().includes(q)
|
||
);
|
||
if (!list.length) {
|
||
grid.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.innerHTML = pluginData.length
|
||
? "没有匹配的插件,换个关键词试试。"
|
||
: "插件市场即将上线,敬请期待 —— tokendun 官方自研插件将陆续上架。";
|
||
return;
|
||
}
|
||
empty.classList.add("hidden");
|
||
grid.innerHTML = list.map(pluginCardHTML).join("");
|
||
grid.querySelectorAll("[data-install]").forEach((btn) => btn.addEventListener("click", () => installPlugin(btn)));
|
||
grid.querySelectorAll("[data-open]").forEach((a) =>
|
||
a.addEventListener("click", (e) => { e.preventDefault(); apic.openExternal(a.dataset.open); })
|
||
);
|
||
}
|
||
|
||
async function installPlugin(btn) {
|
||
let entry;
|
||
try { entry = JSON.parse(btn.dataset.install); } catch { return; }
|
||
busy(btn, true, "安装中…");
|
||
btn.classList.add("installing");
|
||
try {
|
||
await apic.pluginInstall(entry);
|
||
toast(`已安装 ${entry.displayName || entry.name} ✅ 重启 codex 生效`, "ok");
|
||
btn.textContent = "已安装 ✓";
|
||
btn.dataset.label = "已安装 ✓";
|
||
loadInstalledPlugins();
|
||
} catch (e) {
|
||
toast(`安装失败:${e.message || e}`, "err");
|
||
busy(btn, false);
|
||
} finally {
|
||
btn.classList.remove("installing");
|
||
}
|
||
}
|
||
|
||
// 已安装插件(以磁盘 ~/.codex/plugins/cache 为准,可卸载)
|
||
let installedPlugins = [];
|
||
function installedPluginCardHTML(p) {
|
||
const uninstall = p.protected
|
||
? `<button class="btn small" disabled>系统插件</button>`
|
||
: `<button class="btn danger small" data-uninstall='${esc(JSON.stringify({ rel: p.rel, name: p.name, label: p.displayName || p.name }))}'>卸载</button>`;
|
||
return `
|
||
<div class="plugin-card installed">
|
||
<div class="pc-top"><span class="pc-badge plugin">已安装</span><div class="pc-name">${esc(p.displayName || p.name)}</div></div>
|
||
<div class="pc-meta">
|
||
<span>${humanSize(p.size || 0)}</span>
|
||
<span>来源:${esc(p.source || "—")}</span>
|
||
${p.symlink ? "<span>软链</span>" : ""}
|
||
</div>
|
||
<div class="pc-desc">${esc(p.description) || "(暂无描述)"}</div>
|
||
<div class="pc-foot">
|
||
<span class="muted small mono">${esc(p.rel)}</span>
|
||
${uninstall}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
function renderInstalledPlugins() {
|
||
const grid = $("#plugin-installed-grid");
|
||
const empty = $("#plugin-installed-empty");
|
||
$("#plugin-installed-count").textContent = installedPlugins.length ? `(${installedPlugins.length})` : "";
|
||
if (!installedPlugins.length) {
|
||
grid.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.textContent = "还没有安装任何插件。";
|
||
return;
|
||
}
|
||
empty.classList.add("hidden");
|
||
grid.innerHTML = installedPlugins.map(installedPluginCardHTML).join("");
|
||
grid.querySelectorAll("[data-uninstall]").forEach((btn) =>
|
||
btn.addEventListener("click", () => uninstallPlugin(btn))
|
||
);
|
||
}
|
||
async function loadInstalledPlugins() {
|
||
try { installedPlugins = await apic.pluginInstalled(); }
|
||
catch { installedPlugins = []; }
|
||
renderInstalledPlugins();
|
||
}
|
||
async function uninstallPlugin(btn) {
|
||
let info;
|
||
try { info = JSON.parse(btn.dataset.uninstall); } catch { return; }
|
||
if (!confirm(`确认卸载插件「${info.label || info.name}」?\n\n将删除 ~/.codex/${info.rel} 以及对应工具箱仓库实体,此操作不可恢复。`)) return;
|
||
busy(btn, true, "卸载中…");
|
||
try {
|
||
await apic.pluginUninstall(info.rel);
|
||
toast(`已卸载 ${info.name} ✅`, "ok");
|
||
loadInstalledPlugins();
|
||
} catch (e) {
|
||
toast(`卸载失败:${e.message || e}`, "err");
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
async function loadPlugins() {
|
||
const grid = $("#plugin-grid");
|
||
const empty = $("#plugin-empty");
|
||
empty.classList.add("hidden");
|
||
grid.innerHTML = '<div class="empty"><div class="spinner"></div>正在加载插件…</div>';
|
||
try {
|
||
const cat = await apic.pluginCatalog();
|
||
pluginData = (cat && cat.plugins) || [];
|
||
pluginLoaded = true;
|
||
renderPlugins();
|
||
loadInstalledPlugins();
|
||
} catch (e) {
|
||
pluginData = [];
|
||
pluginLoaded = true;
|
||
grid.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.innerHTML = "插件市场即将上线,敬请期待 —— tokendun 官方自研插件将陆续上架。";
|
||
loadInstalledPlugins();
|
||
}
|
||
}
|
||
$("#plugin-search").addEventListener("input", renderPlugins);
|
||
$("#plugin-refresh").addEventListener("click", loadPlugins);
|
||
document.querySelector('.tab[data-tab="plugins"]').addEventListener("click", () => {
|
||
if (!pluginLoaded) loadPlugins();
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 设置 —— 密钥
|
||
// ===========================================================================
|
||
async function loadSecrets() {
|
||
try {
|
||
const gh = await apic.getSecret("github");
|
||
if (gh) $("#set-github").value = gh;
|
||
const ak = await apic.getSecret("adminKey");
|
||
if (ak) $("#set-admin").value = ak;
|
||
} catch { /* 忽略 */ }
|
||
}
|
||
$("#btn-save-admin").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "保存中…");
|
||
try {
|
||
await apic.setSecret("adminKey", $("#set-admin").value.trim());
|
||
toast("管理员密钥已保存 ✅", "ok");
|
||
} catch (e) {
|
||
toast(`保存失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#btn-save-secret").addEventListener("click", async (ev) => {
|
||
const val = $("#set-github").value.trim();
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "保存中…");
|
||
try {
|
||
await apic.setSecret("github", val);
|
||
toast("已保存 ✅", "ok");
|
||
} catch (e) {
|
||
toast(`保存失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 强制清除本机 Codex —— 检测安装位置 + 一键清干净
|
||
// ===========================================================================
|
||
const KIND_LABEL = {
|
||
data: "数据目录",
|
||
bin: "可执行文件",
|
||
"npm-package": "npm 全局包",
|
||
};
|
||
function renderPurgeList(items) {
|
||
const box = $("#purge-list");
|
||
const actions = $("#purge-actions");
|
||
if (!items || !items.length) {
|
||
box.innerHTML =
|
||
'<p class="muted small" style="margin:6px 2px">未检测到本机安装的 Codex(可能未安装,或不在常见位置)。</p>';
|
||
actions.style.display = "none";
|
||
return;
|
||
}
|
||
box.innerHTML = items
|
||
.map((it, i) => {
|
||
const tag = KIND_LABEL[it.kind] || it.kind;
|
||
return `<label class="cl-item">
|
||
<input type="checkbox" data-purge="${i}" checked />
|
||
<div style="min-width:0">
|
||
<div class="cl-name">${esc(it.label)}</div>
|
||
<div class="cl-src" style="word-break:break-all">${esc(it.path)}</div>
|
||
</div>
|
||
<span class="cl-size">${esc(tag)}${it.isDir ? " · " + esc(it.sizeText) : ""}</span>
|
||
</label>`;
|
||
})
|
||
.join("");
|
||
box._items = items;
|
||
actions.style.display = "flex";
|
||
}
|
||
async function purgeScan(btn) {
|
||
busy(btn, true, "检测中…");
|
||
try {
|
||
const items = await apic.codexDetect();
|
||
renderPurgeList(items);
|
||
toast(items.length ? `检测到 ${items.length} 个位置` : "未检测到 Codex", items.length ? "ok" : "");
|
||
} catch (e) {
|
||
toast("检测失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
$("#btn-purge-scan").addEventListener("click", (ev) => purgeScan(ev.currentTarget));
|
||
$("#btn-purge-do").addEventListener("click", async (ev) => {
|
||
const box = $("#purge-list");
|
||
const items = box._items || [];
|
||
const paths = $$('#purge-list input[data-purge]')
|
||
.filter((el) => el.checked)
|
||
.map((el) => items[+el.dataset.purge]?.path)
|
||
.filter(Boolean);
|
||
if (!paths.length) {
|
||
toast("请先勾选要清除的位置", "err");
|
||
return;
|
||
}
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "清除中…");
|
||
try {
|
||
const r = await apic.codexPurge(paths);
|
||
if (r.canceled) {
|
||
toast("已取消", "");
|
||
return;
|
||
}
|
||
const ok = r.results.filter((x) => x.ok).length;
|
||
const fail = r.results.length - ok;
|
||
toast(fail ? `清除完成:成功 ${ok},失败 ${fail}(详见运行日志)` : `已彻底清除 ${ok} 个位置 ✅ 可重新安装 Codex 了`, fail ? "err" : "ok");
|
||
await purgeScan(btn); // 刷新列表,确认已清空
|
||
} catch (e) {
|
||
toast("清除失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 会话记录 —— 浏览 / 搜索 / 阅读 / 导出 / 备份 / 导入
|
||
// ===========================================================================
|
||
let sessData = [];
|
||
let sessCurrent = null;
|
||
let sessLoaded = false;
|
||
let sessAllMode = false;
|
||
const SESS_DEFAULT_LIMIT = 500;
|
||
const SESS_ALL_LIMIT = 5000;
|
||
|
||
function renderSessionDataDir(info) {
|
||
if (!info || !info.path) return;
|
||
$("#session-data-dir-path").textContent = info.path;
|
||
$("#session-data-dir-path").title = info.path;
|
||
$("#mem-data-dir-path").textContent = info.path;
|
||
$("#mem-data-dir-path").title = info.path;
|
||
$("#session-data-dir-reset").classList.toggle("hidden", !!info.isDefault);
|
||
}
|
||
|
||
async function loadSessionDataDir() {
|
||
try {
|
||
renderSessionDataDir(await apic.sessionsDataDirInfo());
|
||
} catch {
|
||
$("#session-data-dir-path").textContent = "读取失败";
|
||
$("#mem-data-dir-path").textContent = "读取失败";
|
||
}
|
||
}
|
||
|
||
async function changeSessionDataDir(reset, btn) {
|
||
busy(btn, true, reset ? "恢复中…" : "切换中…");
|
||
try {
|
||
const info = reset ? await apic.sessionsDataDirReset() : await apic.sessionsDataDirSelect();
|
||
if (info.canceled) return;
|
||
renderSessionDataDir(info);
|
||
sessData = [];
|
||
sessCurrent = null;
|
||
sessLoaded = false;
|
||
$("#sess-conv").classList.add("hidden");
|
||
$("#sess-empty").classList.remove("hidden");
|
||
await sessLoad();
|
||
if (memLoaded) await memLoad();
|
||
toast(reset ? "已恢复默认 Codex 会话目录" : "已切换 Codex 会话目录,记忆监视已重启", "ok");
|
||
} catch (e) {
|
||
toast("切换目录失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
function sessUpdateScope() {
|
||
const el = $("#sess-scope");
|
||
if (!el) return;
|
||
const archived = sessData.filter((s) => s.archived).length;
|
||
const subagents = sessData.filter((s) => s.threadSource === "subagent").length;
|
||
if (sessAllMode) {
|
||
el.textContent = `全部记录 ${sessData.length} 条(归档 ${archived},子任务 ${subagents})`;
|
||
} else {
|
||
el.textContent = `当前会话 ${sessData.length} 条`;
|
||
}
|
||
}
|
||
|
||
function sessRender() {
|
||
const q = $("#sess-search").value.trim().toLowerCase();
|
||
const box = $("#sess-list");
|
||
const list = sessData.filter(
|
||
(s) =>
|
||
!q ||
|
||
(s.title || "").toLowerCase().includes(q) ||
|
||
(s.project || "").toLowerCase().includes(q) ||
|
||
(s.cwd || "").toLowerCase().includes(q)
|
||
);
|
||
if (!list.length) {
|
||
box.innerHTML = `<div class="empty">${sessData.length ? "没有匹配的对话" : "没有找到会话记录"}</div>`;
|
||
sessUpdateScope();
|
||
return;
|
||
}
|
||
box.innerHTML = list
|
||
.map(
|
||
(s) => {
|
||
const tags = [
|
||
s.archived ? '<span class="si-badge">归档</span>' : "",
|
||
s.threadSource === "subagent" ? '<span class="si-badge">子任务</span>' : "",
|
||
].filter(Boolean).join("");
|
||
return `
|
||
<div class="sess-item" data-idx="${sessData.indexOf(s)}">
|
||
<div class="si-title">${esc(s.title)}</div>
|
||
<div class="si-meta"><span>${esc(s.project || "—")}</span><span>${(s.ts || "").slice(0, 10)}</span>${tags}</div>
|
||
</div>`;
|
||
}
|
||
)
|
||
.join("");
|
||
box.querySelectorAll(".sess-item").forEach((el) =>
|
||
el.addEventListener("click", () => sessOpen(+el.dataset.idx, el))
|
||
);
|
||
sessUpdateScope();
|
||
}
|
||
|
||
async function sessOpen(idx, el) {
|
||
const item = sessData[idx];
|
||
sessCurrent = item;
|
||
$$(".sess-item").forEach((x) => x.classList.remove("active"));
|
||
if (el) el.classList.add("active");
|
||
$("#sess-empty").classList.add("hidden");
|
||
$("#sess-conv").classList.remove("hidden");
|
||
$("#sess-conv-title").textContent = item.title;
|
||
$("#sess-messages").innerHTML = `<div class="empty"><div class="spinner"></div>读取中…</div>`;
|
||
try {
|
||
const conv = await apic.sessionRead(item.path);
|
||
const html = conv.messages
|
||
.map((m) => {
|
||
const who = m.role === "user" ? "👤 用户" : m.role === "assistant" ? "🤖 Codex" : "🔧 工具";
|
||
return `<div class="msg ${m.role}"><span class="who">${who}</span><div class="bubble">${esc(m.text)}</div></div>`;
|
||
})
|
||
.join("");
|
||
$("#sess-messages").innerHTML = html || `<div class="empty">这条对话没有可显示的消息</div>`;
|
||
} catch (e) {
|
||
$("#sess-messages").innerHTML = `<div class="empty">读取失败:${esc(e.message || e)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function sessLoad(opts = {}) {
|
||
if (Object.prototype.hasOwnProperty.call(opts, "all")) sessAllMode = !!opts.all;
|
||
const box = $("#sess-list");
|
||
box.innerHTML = `<div class="empty"><div class="spinner"></div>加载中…</div>`;
|
||
try {
|
||
sessData = await apic.sessionsList(
|
||
sessAllMode
|
||
? { includeSubagent: true, includeArchived: true, limit: SESS_ALL_LIMIT }
|
||
: { includeSubagent: false, includeArchived: false, limit: SESS_DEFAULT_LIMIT }
|
||
);
|
||
sessLoaded = true;
|
||
sessCurrent = null;
|
||
$("#sess-empty").classList.remove("hidden");
|
||
$("#sess-conv").classList.add("hidden");
|
||
sessRender();
|
||
if (opts.toast) toast(`${sessAllMode ? "已获取全部记录" : "已刷新"}:${sessData.length} 条`, "ok");
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="empty">加载失败:${esc(e.message || e)}</div>`;
|
||
sessUpdateScope();
|
||
}
|
||
}
|
||
|
||
$("#sess-delete").addEventListener("click", async (ev) => {
|
||
if (!sessCurrent) return;
|
||
const item = sessCurrent;
|
||
const confirmed = confirm("永久删除这条会话?\\n\\n将同时删除:会话正文、归档副本、Codex 本地索引、工具箱会话记忆。\\n此操作不可恢复,且需要先完全退出 Codex。");
|
||
if (!confirmed) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "删除中…");
|
||
try {
|
||
const r = await apic.sessionDeletePermanently(item.path);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "永久删除失败");
|
||
sessCurrent = null;
|
||
$("#sess-empty").classList.remove("hidden");
|
||
$("#sess-conv").classList.add("hidden");
|
||
await sessLoad();
|
||
if (r.cleanupWarning) toast("会话主体已删除,但工具箱记忆清理失败:" + r.cleanupWarning, "err");
|
||
else toast("会话及本地缓存已永久删除", "ok");
|
||
} catch (e) {
|
||
toast("永久删除失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
|
||
$("#sess-search").addEventListener("input", sessRender);
|
||
$("#sess-clear-all").addEventListener("click", async (ev) => {
|
||
const status = await apic.providerSyncStatus();
|
||
if (status.running) {
|
||
toast("请先完全退出 Codex(Cmd+Q)再清除会话记录", "err");
|
||
return;
|
||
}
|
||
const confirmed = confirm(
|
||
"确定清除全部会话记录?\n\n将删除 sessions、archived_sessions、Codex 会话索引、状态库线程、侧栏目录和工具箱会话记忆中的全部对话和项目摘要(已固定的长期偏好会保留)。\n不会删除中转配置、登录凭证、项目源码、插件和自动化任务。\n\n执行前会自动备份,操作不可直接撤销。"
|
||
);
|
||
if (!confirmed) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "清除中…");
|
||
try {
|
||
const r = await apic.sessionsClearAll();
|
||
sessData = [];
|
||
sessCurrent = null;
|
||
$("#sess-empty").classList.remove("hidden");
|
||
$("#sess-conv").classList.add("hidden");
|
||
sessRender();
|
||
if (r.cleanupWarning) toast(`会话已清除,但会话记忆清理有警告:${r.cleanupWarning}`, "err");
|
||
else toast(`已清除 ${r.filesRemoved || 0} 个会话文件和 ${r.memoryRemoved || 0} 条会话记忆;执行前备份在 ${r.backup}`, "ok");
|
||
} catch (e) {
|
||
toast(`清除会话失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#sess-refresh").addEventListener("click", sessLoad);
|
||
$("#session-data-dir-select").addEventListener("click", (ev) => changeSessionDataDir(false, ev.currentTarget));
|
||
$("#session-data-dir-reset").addEventListener("click", (ev) => changeSessionDataDir(true, ev.currentTarget));
|
||
$("#sess-fetch-all").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "读取中…");
|
||
try {
|
||
await sessLoad({ all: true, toast: true });
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
// 会话记录已改为从「插件市场」进入,懒加载由 [data-open-panel] 处理
|
||
$("#sess-export").addEventListener("click", async (ev) => {
|
||
if (!sessCurrent) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "导出中…");
|
||
try {
|
||
const r = await apic.sessionExport(sessCurrent);
|
||
if (!r.canceled) toast("已导出 ✅", "ok");
|
||
} catch (e) {
|
||
toast(`导出失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#sess-backup").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "备份中…");
|
||
try {
|
||
const r = await apic.sessionsBackup();
|
||
if (!r.canceled) toast(`已备份 ${r.count} 条会话 ✅`, "ok");
|
||
} catch (e) {
|
||
toast(`备份失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#sess-import").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "导入中…");
|
||
try {
|
||
const r = await apic.sessionsImport();
|
||
if (!r.canceled) {
|
||
toast(`导入完成:新增 ${r.added},跳过 ${r.skipped}`, "ok");
|
||
sessLoad();
|
||
}
|
||
} catch (e) {
|
||
toast(`导入失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
apic.onProviderSyncProgress((p) => {
|
||
const box = $("#sync-progress");
|
||
const fill = $("#sync-progress-fill");
|
||
const label = $("#sync-progress-label");
|
||
box.classList.remove("hidden");
|
||
if (p.phase === "完成") {
|
||
fill.style.width = "100%";
|
||
label.textContent = "完成 ✅";
|
||
} else if (p.total > 0) {
|
||
const pct = Math.round((p.done / p.total) * 100);
|
||
fill.style.width = pct + "%";
|
||
label.textContent = `${p.phase} ${p.done}/${p.total}(${pct}%)`;
|
||
} else {
|
||
fill.style.width = "8%";
|
||
label.textContent = p.phase + "…";
|
||
}
|
||
});
|
||
|
||
$("#sync-btn").addEventListener("click", async (ev) => {
|
||
const st = await apic.providerSyncStatus();
|
||
if (st.running) {
|
||
toast("请先完全退出 Codex(Cmd+Q)再同步", "err");
|
||
return;
|
||
}
|
||
if (!confirm(`将把 sessions 与 archived_sessions 的全部历史对话同步到当前账号(provider:${st.target}),使其在 Codex 侧栏重新显示。\n\n开始前会自动备份,可还原。确认继续?`)) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "同步中…");
|
||
$("#sync-progress").classList.remove("hidden");
|
||
$("#sync-progress-fill").style.width = "0%";
|
||
try {
|
||
const r = await apic.providerSync();
|
||
$("#sync-progress-fill").style.width = "100%";
|
||
const dbChanged = (r.ueRows || 0) + (r.provRows || 0) + (r.insertedRows || 0) + (r.projectRows || 0) + (r.projectlessRemoved || 0);
|
||
const catalogN = r.catalogRebuilt || 0;
|
||
const changedMsg = r.changedFiles > 0 || dbChanged > 0 || catalogN > 0
|
||
? `文件 ${r.changedFiles || 0} 条,数据库/项目索引 ${dbChanged} 项,侧栏目录 ${catalogN} 条`
|
||
: `全部已是最新,无需改动`;
|
||
$("#sync-progress-label").textContent = `完成 ✅ 共 ${r.totalConversations} 条会话,${changedMsg}`;
|
||
const tip = r.changedFiles > 0 || dbChanged > 0 || catalogN > 0
|
||
? `同步完成 ✅ ${changedMsg}。请完全退出并重新打开 Codex 查看侧栏`
|
||
: `全部 ${r.totalConversations} 条会话之前已同步过,本次无需改动 ✅`;
|
||
toast(tip, "ok");
|
||
setTimeout(() => $("#sync-progress").classList.add("hidden"), 4000);
|
||
} catch (e) {
|
||
$("#sync-progress").classList.add("hidden");
|
||
toast(`同步失败:${e.message || e}`, "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 会话记忆 —— 状态 / 项目开关 / 历史摘要检索
|
||
// ===========================================================================
|
||
let memStatus = null;
|
||
let memLoaded = false;
|
||
|
||
function memRenderStatus(st) {
|
||
memStatus = st;
|
||
const badge = $("#mem-state");
|
||
badge.textContent = st.enabled ? "已开启" : "已关闭";
|
||
badge.className = "pc-badge " + (st.enabled ? "skill" : "");
|
||
$("#mem-engine").textContent = st.engine + (st.pending ? ` · 待处理 ${st.pending}` : "");
|
||
$("#mem-count").textContent = `记忆库共 ${st.total} 条摘要`;
|
||
const tg = $("#mem-toggle");
|
||
tg.textContent = st.enabled ? "关闭记忆" : "开启记忆";
|
||
tg.className = "btn " + (st.enabled ? "ghost" : "primary");
|
||
|
||
// 项目列表(开关 + 打开 HANDOFF)
|
||
const box = $("#mem-projects");
|
||
if (!st.projects.length) {
|
||
box.innerHTML = `<div class="empty">还没有任何项目的会话被摘要。去 Codex 里聊一会儿,静默 ${st.idleMinutes} 分钟后这里就有了。</div>`;
|
||
} else {
|
||
box.innerHTML = st.projects
|
||
.map(
|
||
(p) => `
|
||
<div class="cl-item">
|
||
<input type="checkbox" ${p.enabled ? "checked" : ""} data-mem-proj="${esc(p.cwd)}" title="开/关该项目的 HANDOFF 写入" />
|
||
<div>
|
||
<div class="cl-name">${esc(p.project || "(未知)")}</div>
|
||
<div class="cl-src">${esc(p.cwd)}</div>
|
||
</div>
|
||
<div class="btn-group" style="margin-left:auto">
|
||
<span class="muted small">${p.count} 条 · ${(p.last || "").slice(0, 10)}</span>
|
||
${p.hasHandoff ? `<button class="btn small ghost" data-mem-open="${esc(p.cwd)}">HANDOFF ↗</button>` : ""}
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
box.querySelectorAll("[data-mem-proj]").forEach((el) =>
|
||
el.addEventListener("change", async () => {
|
||
try {
|
||
memRenderStatus(await apic.memoryToggleProject(el.dataset.memProj, el.checked));
|
||
toast(el.checked ? "已开启该项目的记忆写入 ✅" : "已关闭该项目的记忆写入", "ok");
|
||
} catch (e) {
|
||
toast("操作失败:" + (e.message || e), "err");
|
||
}
|
||
})
|
||
);
|
||
box.querySelectorAll("[data-mem-open]").forEach((el) =>
|
||
el.addEventListener("click", async () => {
|
||
try {
|
||
await apic.memoryOpenHandoff(el.dataset.memOpen);
|
||
} catch (e) {
|
||
toast(e.message || String(e), "err");
|
||
}
|
||
})
|
||
);
|
||
}
|
||
|
||
// 项目筛选下拉(保留当前选中)
|
||
const sel = $("#mem-project-filter");
|
||
const cur = sel.value;
|
||
sel.innerHTML =
|
||
'<option value="">全部项目</option>' +
|
||
st.projects.map((p) => `<option value="${esc(p.cwd)}">${esc(p.project || p.cwd)}</option>`).join("");
|
||
sel.value = cur;
|
||
}
|
||
|
||
async function memRenderList() {
|
||
const box = $("#mem-list");
|
||
const empty = $("#mem-empty");
|
||
empty.classList.add("hidden");
|
||
try {
|
||
const rows = await apic.memoryList({
|
||
project: $("#mem-project-filter").value,
|
||
q: $("#mem-search").value.trim(),
|
||
limit: 100,
|
||
});
|
||
if (!rows.length) {
|
||
box.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.textContent = "没有匹配的摘要。";
|
||
return;
|
||
}
|
||
box.innerHTML = rows
|
||
.map((r) => {
|
||
const when = (r.ts || "").slice(0, 16).replace("T", " ");
|
||
const dec = r.decisions.length
|
||
? `<div class="mem-line"><b>决策</b>${r.decisions.map((d) => esc(d)).join(" · ")}</div>`
|
||
: "";
|
||
const next = r.next_steps.length
|
||
? `<div class="mem-line"><b>下一步</b>${r.next_steps.map((d) => esc(d)).join(" · ")}</div>`
|
||
: "";
|
||
const files = r.files.length ? `<div class="mem-line"><b>文件</b>${esc(r.files.join("、"))}</div>` : "";
|
||
return `
|
||
<div class="mem-item">
|
||
<div class="mem-top">
|
||
<span class="mem-topic">${esc(r.topic)}</span>
|
||
<span class="pc-badge">${esc(r.project || "—")}</span>
|
||
<span class="muted small">${when}</span>
|
||
<span class="muted small">${r.engine === "local" ? "本地提取" : "LLM"}</span>
|
||
<div class="mem-actions">
|
||
<button class="btn small ghost" data-mem-redo="${esc(r.rollout_path)}">重新摘要</button>
|
||
<button class="btn small ghost" data-mem-del="${esc(r.id)}">删除</button>
|
||
</div>
|
||
</div>
|
||
<div class="mem-sum">${esc(r.summary)}</div>
|
||
${dec}${next}${files}
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
box.querySelectorAll("[data-mem-redo]").forEach((el) =>
|
||
el.addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "摘要中…");
|
||
try {
|
||
await apic.memoryResummarize(el.dataset.memRedo);
|
||
toast("已重新摘要 ✅", "ok");
|
||
} catch (e) {
|
||
toast("摘要失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
})
|
||
);
|
||
box.querySelectorAll("[data-mem-del]").forEach((el) =>
|
||
el.addEventListener("click", async () => {
|
||
if (!confirm("删除这条摘要?(不影响原始会话记录)")) return;
|
||
try {
|
||
await apic.memoryDelete(el.dataset.memDel);
|
||
} catch (e) {
|
||
toast("删除失败:" + (e.message || e), "err");
|
||
}
|
||
})
|
||
);
|
||
} catch (e) {
|
||
box.innerHTML = "";
|
||
empty.classList.remove("hidden");
|
||
empty.textContent = "加载失败:" + (e.message || e);
|
||
}
|
||
}
|
||
|
||
async function memLoad() {
|
||
memLoaded = true;
|
||
try {
|
||
await loadSessionDataDir();
|
||
memRenderStatus(await apic.memoryStatus());
|
||
await memRenderList();
|
||
} catch (e) {
|
||
toast("会话记忆读取失败:" + (e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
// 会话记忆已改为从「插件市场」进入,懒加载由 [data-open-panel] 处理
|
||
$("#mem-refresh").addEventListener("click", memLoad);
|
||
$("#mem-search").addEventListener("input", memRenderList);
|
||
$("#mem-project-filter").addEventListener("change", memRenderList);
|
||
$("#mem-toggle").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "处理中…");
|
||
try {
|
||
memRenderStatus(await apic.memorySetEnabled(!(memStatus && memStatus.enabled)));
|
||
} catch (e) {
|
||
toast("操作失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
apic.onMemoryChanged(() => {
|
||
if (memLoaded) memLoad();
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 说明弹窗 —— 使用说明 / 版权声明
|
||
// ===========================================================================
|
||
const MODAL_CONTENT = {
|
||
usage: {
|
||
title: "📖 使用说明",
|
||
html: `
|
||
<h4>插件是「命令包」,不是单个命令</h4>
|
||
<p>一个插件(如 <strong>creative-production</strong>)里通常包含多个技能命令。安装后不是输入插件名,而是输入里面的具体命令,比如 <code>/explore</code>、<code>/ads-explorer</code>。</p>
|
||
|
||
<h4>三步开始使用</h4>
|
||
<div class="step"><span class="n">1</span><span>在「插件市场」搜索并点 <strong>安装</strong>,或在「备份与还原 → 从 GitHub 拉取还原」里拉取仓库后还原到本机。</span></div>
|
||
<div class="step"><span class="n">2</span><span><strong>重启 Codex</strong>,让新装的插件生效。</span></div>
|
||
<div class="step"><span class="n">3</span><span>在 Codex 输入框打 <code>/</code>,再输入命令关键词(如 <code>ads</code>、<code>scan</code>)即可定位并运行对应功能。</span></div>
|
||
|
||
<h4>备份 / 分享给他人</h4>
|
||
<ul>
|
||
<li>「从本机导出 / 上传」可把你本机的插件打包推送到 GitHub 仓库(<strong>凭证文件已自动排除</strong>)。</li>
|
||
<li>把生成的仓库链接发给对方,对方在本 app 用「从 GitHub 拉取还原」即可一键装好。</li>
|
||
</ul>
|
||
|
||
<h4>每个插件具体能干嘛?</h4>
|
||
<p>点插件卡片可查看它的功能说明;各插件仓库首页的 README 里也有完整的命令清单与示例。</p>
|
||
`,
|
||
},
|
||
notice: {
|
||
title: "📄 版权声明 / NOTICE",
|
||
html: `
|
||
<h4>内容归属</h4>
|
||
<p>本市场收录并可分发的插件为 <strong>各自作者(含 OpenAI Codex 官方插件)</strong> 的作品,其源代码及相关知识产权归 <strong>原始著作权人</strong> 所有。本工具仅提供备份、分享与在已获授权环境中的还原,<strong>不修改插件源代码</strong>。</p>
|
||
|
||
<h4>使用范围</h4>
|
||
<ul>
|
||
<li>请在<strong>已获得相应使用授权</strong>的前提下备份与还原插件。</li>
|
||
<li>使用时请遵守 OpenAI 服务条款及各插件的原始许可条款。</li>
|
||
<li>如不确定是否有权使用某插件,请以其官方渠道说明为准。</li>
|
||
</ul>
|
||
|
||
<h4>凭证安全</h4>
|
||
<p>导出 / 上传时,<strong>凭证文件(auth.json、config.toml、history 等)已自动排除</strong>;GitHub Token 仅经系统安全存储(safeStorage)加密后保存在本机。</p>
|
||
|
||
<h4>免责声明</h4>
|
||
<p>本工具及其分发的备份按「<strong>现状(AS IS)</strong>」提供,不附带任何明示或默示担保。作者不对因使用而产生的任何后果负责。本声明不构成对第三方代码的任何再授权。</p>
|
||
`,
|
||
},
|
||
};
|
||
|
||
const modalOverlay = $("#modal-overlay");
|
||
function openModal(key) {
|
||
const c = MODAL_CONTENT[key];
|
||
if (!c) return;
|
||
$("#modal-title").textContent = c.title;
|
||
$("#modal-body").innerHTML = c.html;
|
||
const m = modalOverlay.querySelector(".modal");
|
||
if (m) m.classList.remove("cv-media-modal");
|
||
modalOverlay.classList.remove("hidden");
|
||
}
|
||
function closeModal() {
|
||
modalOverlay.classList.add("hidden");
|
||
const m = modalOverlay.querySelector(".modal");
|
||
if (m && m.classList.contains("cv-media-modal")) $("#modal-body").innerHTML = "";
|
||
if (m) m.classList.remove("cv-media-modal");
|
||
}
|
||
|
||
$("#open-usage").addEventListener("click", () => openModal("usage"));
|
||
$("#open-notice").addEventListener("click", () => openModal("notice"));
|
||
$("#modal-close").addEventListener("click", closeModal);
|
||
modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModal(); });
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && !modalOverlay.classList.contains("hidden")) closeModal();
|
||
});
|
||
|
||
// ===========================================================================
|
||
// 路由中转
|
||
// ===========================================================================
|
||
let routerState = null;
|
||
let grpEditing = null;
|
||
let grp1mMarks = new Set(); // 当前编辑分组里被标记为「1M 长上下文」的模型名
|
||
|
||
// 渲染 1M 勾选清单:把拉取到的模型(并入已保存标记)逐个列出,勾选态来自 grp1mMarks
|
||
function grpRender1mList(models) {
|
||
const all = Array.from(new Set([...(models || []), ...grp1mMarks]));
|
||
const box = $("#grp-1m-list");
|
||
if (!all.length) { $("#grp-1m-field").style.display = "none"; box.innerHTML = ""; return; }
|
||
$("#grp-1m-field").style.display = "";
|
||
box.innerHTML = all
|
||
.map((m) => `<label class="grp-1m-item"><input type="checkbox" value="${esc(m)}"${grp1mMarks.has(m) ? " checked" : ""} /><span>${esc(m)}</span></label>`)
|
||
.join("");
|
||
// 勾选变化实时同步进 grp1mMarks
|
||
box.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||
cb.addEventListener("change", () => {
|
||
if (cb.checked) grp1mMarks.add(cb.value);
|
||
else grp1mMarks.delete(cb.value);
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
function renderRouter(st) {
|
||
routerState = st;
|
||
$("#router-addr").textContent = st.address;
|
||
const badge = $("#router-state");
|
||
badge.textContent = st.running ? "运行中" : "未启动";
|
||
badge.className = "pc-badge " + (st.running ? "skill" : "");
|
||
$("#router-toggle").textContent = st.running ? "停止" : "启动";
|
||
$("#router-key").value = st.routerKey;
|
||
if (document.activeElement !== $("#router-port")) $("#router-port").value = st.port;
|
||
const claudeBtn = $("#router-apply-claude");
|
||
if (claudeBtn) {
|
||
claudeBtn.textContent = st.claudeApplied ? "取消接入 Claude Code" : "接入 Claude Code";
|
||
claudeBtn.classList.toggle("ghost", !!st.claudeApplied);
|
||
}
|
||
|
||
const box = $("#router-groups");
|
||
if (!st.groups.length) {
|
||
box.innerHTML = `<div class="empty">还没有分组,点右上「+ 新增分组」添加一个中转 API</div>`;
|
||
return;
|
||
}
|
||
// 按协议族分两栏:Codex/GPT(OpenAI 协议)与 Claude Code(Anthropic 协议)各一个单选,
|
||
// 两栏互不影响 —— 可同时激活一个 GPT 分组 + 一个 Claude 分组一起用。
|
||
const isClaude = (g) => g.mode === "claude";
|
||
const oai = st.groups.filter((g) => !isClaude(g));
|
||
const claude = st.groups.filter((g) => isClaude(g));
|
||
const itemHtml = (g, radioName, activeId) => {
|
||
// chat 模式(国产:DeepSeek/GLM/Kimi/通义)需协议转换,无法直连 Codex,必须开本地路由才能用
|
||
const needsRouter = !g.isDefault && g.mode === "chat";
|
||
const toolOnly = !g.isDefault && (g.codexImage || g.codexVideo || g.stt);
|
||
const badges =
|
||
(!toolOnly && g.id === activeId ? '<span class="pc-badge skill">当前</span>' : "") +
|
||
(g.isDefault ? ' <span class="pc-badge">默认·不可删</span>' : "") +
|
||
(needsRouter ? ' <span class="pc-badge warn">需路由</span>' : "") +
|
||
(toolOnly ? ' <span class="pc-badge plugin">插件专用</span>' : "");
|
||
const actions = g.isDefault
|
||
? '<span class="muted small">切回这里 = 直连你的账号</span><button class="btn small ghost" data-default-config="1">编辑</button>'
|
||
: `<button class="btn small ghost" data-edit="${g.id}">编辑</button><button class="btn small ghost" data-del="${g.id}">删除</button>`;
|
||
// chat 分组被选中但路由没在跑 → 醒目提示去启动
|
||
const routerHint = needsRouter && g.id === activeId && !st.running
|
||
? '<div class="cl-warn">⚠️ 该分组需协议转换,请先在上方「启动」本地路由,否则 Codex 无法使用</div>'
|
||
: "";
|
||
return `
|
||
<div class="cl-item${toolOnly ? " is-disabled" : ""}">
|
||
<input type="radio" name="${radioName}" ${!toolOnly && g.id === activeId ? "checked" : ""} ${toolOnly ? "disabled" : ""} data-active="${g.id}" />
|
||
<div>
|
||
<div class="cl-name">${esc(g.name)} ${badges}${g.codexImage ? ' <span class="agent-badge mm">🎨 生图</span>' : ""}${g.codexVideo ? ' <span class="agent-badge mm">🎬 视频</span>' : ""}${g.stt ? ' <span class="agent-badge mm">🎤 语音' + (g.sttModel ? " " + esc(g.sttModel) : "") + '</span>' : ""}${g.multimodal ? ' <span class="agent-badge mm">多模态</span>' : ""}</div>
|
||
<div class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${needsRouter ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
|
||
${toolOnly ? '<div class="cl-tool-hint">画布/插件按需调用,不作为 Codex/GPT 默认对话分组</div>' : ""}
|
||
${routerHint}
|
||
</div>
|
||
<div class="btn-group" style="margin-left:auto">${actions}</div>
|
||
</div>`;
|
||
};
|
||
const colHtml = (title, hint, list, radioName, activeId) => `
|
||
<div class="router-col">
|
||
<div class="router-col-head"><b>${title}</b><span class="muted small">${hint}</span></div>
|
||
${list.length ? list.map((g) => itemHtml(g, radioName, activeId)).join("") : '<div class="empty small">该类型下暂无分组</div>'}
|
||
</div>`;
|
||
box.innerHTML =
|
||
colHtml("Codex / GPT", "OpenAI 协议 · Responses / Chat", oai, "active-grp-oai", st.activeId) +
|
||
colHtml("Claude Code", "Anthropic 协议 · /v1/messages", claude, "active-grp-claude", st.activeClaudeId);
|
||
box.querySelectorAll("[data-active]").forEach((el) =>
|
||
el.addEventListener("change", async () => {
|
||
if (el.disabled) return;
|
||
const r = await apic.routerSetActive(el.dataset.active);
|
||
if (r && r._activeSkipped) {
|
||
renderRouter(r);
|
||
toast(r._activeReason || "该分组不能作为默认对话分组", "warn");
|
||
return;
|
||
}
|
||
const g = (r.groups || []).find((x) => x.id === el.dataset.active);
|
||
renderRouter(r);
|
||
if (g && g.mode === "claude") {
|
||
if (r._claudeSkipped) toast("已选 Claude 分组,但未写入:" + (r._claudeReason || "缺地址或密钥"), "warn");
|
||
else if (r._mode === "direct") toast("已切 Claude Code 分组 ✅ 新开 claude 会话生效", "ok");
|
||
else toast("已秒切 Claude Code 分组 ✅ 无需重启", "ok");
|
||
} else if (r._needsRestart) {
|
||
toast("已切换,重启 Codex 后生效(配置有变)", "ok");
|
||
} else if (r._needsCodexRefresh) {
|
||
toast("已秒切分组 ✅ 下一次 Codex 请求即用新模型;顶部模型选择器将在 Codex 下次启动时刷新", "ok");
|
||
} else if (r._mode === "direct") toast("已切回默认(直连你的账号)✅", "ok");
|
||
else toast("已秒切分组 ✅ 无需重启 Codex", "ok");
|
||
})
|
||
);
|
||
box.querySelectorAll("[data-edit]").forEach((el) =>
|
||
el.addEventListener("click", () => openGrpModal(st.groups.find((g) => g.id === el.dataset.edit)))
|
||
);
|
||
box.querySelectorAll("[data-default-config]").forEach((el) =>
|
||
el.addEventListener("click", openDefaultConfigModal)
|
||
);
|
||
box.querySelectorAll("[data-del]").forEach((el) =>
|
||
el.addEventListener("click", async () => {
|
||
if (confirm("删除这个分组?")) renderRouter(await apic.routerDeleteGroup(el.dataset.del));
|
||
})
|
||
);
|
||
}
|
||
|
||
async function loadRouter() {
|
||
try {
|
||
renderRouter(await apic.routerStatus());
|
||
} catch (e) {
|
||
toast("路由状态读取失败:" + (e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
function openGrpModal(g) {
|
||
grpEditing = g || null;
|
||
$("#grp-modal-title").textContent = g ? "编辑分组" : "新增分组";
|
||
$("#grp-name").value = g ? g.name : "";
|
||
$("#grp-mode").value = g && g.mode ? g.mode : "responses";
|
||
$("#grp-url").value = g ? g.baseUrl : "";
|
||
$("#grp-key").value = ""; // 密钥不回显,留空则不改
|
||
$("#grp-key").placeholder = g && g.hasKey ? "(已保存,留空则不修改)" : "sk-…";
|
||
$("#grp-model").value = g ? g.model || "" : "";
|
||
$("#grp-codex-image").checked = !!(g && g.codexImage);
|
||
$("#grp-codex-video").checked = !!(g && g.codexVideo);
|
||
$("#grp-stt").checked = !!(g && g.stt);
|
||
$("#grp-multimodal").checked = !!(g && g.multimodal);
|
||
$("#grp-stt-model").value = g && g.sttModel ? g.sttModel : "whisper-1";
|
||
grpUpdateSttModelField();
|
||
$("#grp-ctx").value = g && g.contextWindow ? g.contextWindow : "";
|
||
const sel = $("#grp-model-list");
|
||
sel.classList.add("hidden");
|
||
sel.innerHTML = '<option value="">— 从下拉选择模型 —</option>';
|
||
$("#grp-model-hint").textContent = "";
|
||
// 1M 标记:载入该分组已保存的名单;有则先渲染出来(未拉取模型也能看到已勾选的),否则隐藏
|
||
grp1mMarks = new Set((g && Array.isArray(g.oneMillionModels)) ? g.oneMillionModels : []);
|
||
if (grp1mMarks.size) grpRender1mList([...grp1mMarks]);
|
||
else { $("#grp-1m-field").style.display = "none"; $("#grp-1m-list").innerHTML = ""; }
|
||
grpUpdateMultimodalField();
|
||
grpUpdateClaudeDesk();
|
||
$("#grp-modal").classList.remove("hidden");
|
||
}
|
||
function closeGrpModal() {
|
||
$("#grp-modal").classList.add("hidden");
|
||
}
|
||
|
||
function grpUpdateSttModelField() {
|
||
const field = $("#grp-stt-model-field");
|
||
if (!field) return;
|
||
field.classList.toggle("hidden", !$("#grp-stt").checked);
|
||
}
|
||
|
||
function grpUpdateMultimodalField() {
|
||
const toggle = $("#grp-multimodal");
|
||
if (!toggle) return;
|
||
const enabled = $("#grp-mode").value === "chat";
|
||
if (!enabled) toggle.checked = false;
|
||
toggle.disabled = !enabled;
|
||
}
|
||
|
||
function fmtTime(ts) {
|
||
if (!ts) return "无快照时间";
|
||
try { return new Date(ts).toLocaleString(); } catch { return String(ts); }
|
||
}
|
||
function authLine(a) {
|
||
if (!a) return "凭证:未知";
|
||
const parts = [a.loginMode || "凭证:未知"];
|
||
if (a.hasOpenAIKey) parts.push("API Key " + (a.keyPreview || "已保存"));
|
||
if (a.hasTokens) parts.push("OAuth tokens 已保存");
|
||
return parts.join(" · ");
|
||
}
|
||
async function openDefaultConfigModal() {
|
||
const modal = $("#default-config-modal");
|
||
const summary = $("#default-config-summary");
|
||
const current = $("#default-config-current");
|
||
const snapshot = $("#default-config-snapshot");
|
||
modal.classList.remove("hidden");
|
||
summary.innerHTML = '<div><b>读取中</b>正在读取 ~/.codex/config.toml...</div>';
|
||
current.value = "";
|
||
snapshot.value = "";
|
||
try {
|
||
const info = await apic.routerDefaultConfig();
|
||
if (!info || !info.ok) throw new Error((info && info.error) || "读取失败");
|
||
const cur = info.current || {};
|
||
const snap = info.snapshot || {};
|
||
summary.innerHTML = [
|
||
`<div><b>当前配置</b>Provider:${esc(cur.modelProvider || "Codex 原生登录态")}<br>Model:${esc(cur.model || "未设置")}<br>状态:${cur.managed ? "当前被中转/路由接管" : "官方直连"}<br>${esc(authLine(cur.auth))}</div>`,
|
||
`<div><b>默认快照</b>Provider:${esc(snap.modelProvider || "Codex 原生登录态")}<br>Model:${esc(snap.model || "未设置")}<br>状态:${snap.managed ? "异常:仍含接管配置" : "干净官方配置"}<br>修复时间:${esc(fmtTime(snap.repairedAt || snap.savedAt))}</div>`,
|
||
`<div><b>config.toml</b>${esc(info.configPath || "~/.codex/config.toml")}</div>`,
|
||
`<div><b>auth.json</b>${esc(authLine(snap.auth))}<br>${esc(info.authPath || "~/.codex/auth.json")}</div>`,
|
||
].join("");
|
||
current.value = info.currentConfigToml || "(未读取到当前 config.toml)";
|
||
snapshot.value = info.snapshotConfigToml || "(没有默认快照内容:可能是旧版本创建的默认项,或首次记录时 config.toml 为空)";
|
||
} catch (e) {
|
||
summary.innerHTML = `<div><b>读取失败</b>${esc(e.message || e)}</div>`;
|
||
}
|
||
}
|
||
function closeDefaultConfigModal() {
|
||
$("#default-config-modal").classList.add("hidden");
|
||
}
|
||
|
||
// 刷新「接入 Claude 桌面 App」区:按分组协议启用/禁用 + 显示接入/备份状态
|
||
async function grpUpdateClaudeDesk() {
|
||
const block = $("#grp-claudedesk");
|
||
if (!block) return;
|
||
const isClaude = $("#grp-mode").value === "claude";
|
||
block.classList.toggle("hidden", !isClaude);
|
||
const hint = $("#grp-cd-hint");
|
||
const applyBtn = $("#grp-cd-apply");
|
||
const exportBtn = $("#grp-cd-export");
|
||
const copyBtn = $("#grp-cd-copy");
|
||
// 未保存的新分组不能接入(需要 groupId)
|
||
const canApply = isClaude && !!grpEditing;
|
||
[applyBtn, exportBtn, copyBtn].forEach((b) => { if (b) b.disabled = !canApply; });
|
||
if (!isClaude) {
|
||
hint.textContent = "⚠ Claude 桌面 App 只接受 Anthropic 协议分组——请把上面「接口类型」改成 Claude Code 再接入。";
|
||
} else if (!grpEditing) {
|
||
hint.textContent = "先保存这个分组,再回来一键接入 Claude 桌面 App。";
|
||
} else {
|
||
hint.textContent = "把该分组一键写入 Claude 桌面 App,重启 App 即用你的中转。接入前会自动备份官方配置。";
|
||
}
|
||
// 状态行
|
||
const statusEl = $("#grp-cd-status");
|
||
try {
|
||
if (!apic.claudedeskStatus) { statusEl.textContent = ""; return; }
|
||
const s = await apic.claudedeskStatus();
|
||
if (!s || !s.supported) { statusEl.textContent = "当前系统暂不支持(仅 macOS / Windows)。"; return; }
|
||
const parts = [];
|
||
parts.push(s.currentApplied ? "🟢 Claude 桌面当前:已接入第三方中转" : "⚪ Claude 桌面当前:官方登录");
|
||
if (s.hasBackup) parts.push("已备份官方配置,可一键还原");
|
||
statusEl.textContent = parts.join(" · ");
|
||
} catch { statusEl.textContent = ""; }
|
||
}
|
||
|
||
document.querySelectorAll("[data-ctx]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
$("#grp-ctx").value = b.dataset.ctx;
|
||
})
|
||
);
|
||
$("#grp-add").addEventListener("click", () => openGrpModal(null));
|
||
$("#grp-modal-close").addEventListener("click", closeGrpModal);
|
||
$("#default-config-close").addEventListener("click", closeDefaultConfigModal);
|
||
$("#default-config-refresh").addEventListener("click", openDefaultConfigModal);
|
||
$("#default-config-copy").addEventListener("click", () => {
|
||
const text = $("#default-config-current").value;
|
||
if (text) navigator.clipboard.writeText(text).then(() => toast("已复制当前 config.toml", "ok"));
|
||
});
|
||
|
||
// 接口类型改变时,实时刷新「接入 Claude 桌面」区的可用性
|
||
$("#grp-mode").addEventListener("change", () => {
|
||
grpUpdateMultimodalField();
|
||
grpUpdateClaudeDesk();
|
||
});
|
||
$("#grp-stt").addEventListener("change", grpUpdateSttModelField);
|
||
|
||
// —— 接入 Claude 桌面 App 按钮 ——
|
||
$("#grp-cd-apply").addEventListener("click", async (ev) => {
|
||
if (!grpEditing) { toast("请先保存分组", "warn"); return; }
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "接入中…");
|
||
try {
|
||
const r = await apic.claudedeskApply(grpEditing.id);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "接入失败");
|
||
toast("已接入 Claude 桌面 App ✅ 重启 App 生效" + (r.has1m ? "(已带 1M 头)" : "") + "。官方配置已备份,可一键还原。", "ok");
|
||
grpUpdateClaudeDesk();
|
||
} catch (e) {
|
||
toast("接入失败:" + (e.message || e), "err");
|
||
} finally { busy(btn, false); }
|
||
});
|
||
$("#grp-cd-export").addEventListener("click", async () => {
|
||
if (!grpEditing) { toast("请先保存分组", "warn"); return; }
|
||
try {
|
||
const r = await apic.claudedeskExport(grpEditing.id, "json");
|
||
if (r && r.ok) toast("已导出到 " + r.path, "ok");
|
||
else if (r && r.canceled) { /* 取消,静默 */ }
|
||
else throw new Error((r && r.error) || "导出失败");
|
||
} catch (e) { toast("导出失败:" + (e.message || e), "err"); }
|
||
});
|
||
$("#grp-cd-copy").addEventListener("click", async () => {
|
||
if (!grpEditing) { toast("请先保存分组", "warn"); return; }
|
||
try {
|
||
const r = await apic.claudedeskSnippet(grpEditing.id);
|
||
if (r && r.ok) toast("配置已复制到剪贴板", "ok");
|
||
else throw new Error((r && r.error) || "复制失败");
|
||
} catch (e) { toast("复制失败:" + (e.message || e), "err"); }
|
||
});
|
||
$("#grp-cd-restore").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "还原中…");
|
||
try {
|
||
const r = await apic.claudedeskRestore();
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "还原失败");
|
||
toast(r.message || "已还原官方配置,请重启 Claude 桌面 App", "ok");
|
||
grpUpdateClaudeDesk();
|
||
} catch (e) {
|
||
toast("还原失败:" + (e.message || e), "err");
|
||
} finally { busy(btn, false); }
|
||
});
|
||
|
||
$("#grp-modal").addEventListener("click", (e) => {
|
||
if (e.target === $("#grp-modal")) closeGrpModal();
|
||
});
|
||
$("#default-config-modal").addEventListener("click", (e) => {
|
||
if (e.target === $("#default-config-modal")) closeDefaultConfigModal();
|
||
});
|
||
$("#grp-save").addEventListener("click", async () => {
|
||
const name = $("#grp-name").value.trim();
|
||
const baseUrl = $("#grp-url").value.trim();
|
||
if (!name || !baseUrl) {
|
||
toast("名称和 API 地址必填", "err");
|
||
return;
|
||
}
|
||
const group = {
|
||
name,
|
||
baseUrl,
|
||
model: $("#grp-model").value.trim(),
|
||
mode: $("#grp-mode").value,
|
||
contextWindow: $("#grp-ctx").value.trim(),
|
||
codexImage: $("#grp-codex-image").checked,
|
||
codexVideo: $("#grp-codex-video").checked,
|
||
stt: $("#grp-stt").checked,
|
||
sttModel: $("#grp-stt").checked ? ($("#grp-stt-model").value.trim() || "whisper-1") : "",
|
||
multimodal: $("#grp-mode").value === "chat" && $("#grp-multimodal").checked,
|
||
oneMillionModels: [...grp1mMarks],
|
||
};
|
||
if (grpEditing) group.id = grpEditing.id;
|
||
const key = $("#grp-key").value;
|
||
if (key) group.key = key;
|
||
else if (grpEditing) group.key = undefined; // 留空且是编辑 → 保留原密钥(主进程 merge 时不覆盖)
|
||
// 编辑留空时不传 key 字段,避免覆盖
|
||
if (grpEditing && !key) delete group.key;
|
||
try {
|
||
const st = await apic.routerUpsertGroup(group);
|
||
renderRouter(st);
|
||
closeGrpModal();
|
||
// 组合提示:Codex 直连结果 + Claude Code 接入结果
|
||
let codexMsg = "";
|
||
if (st._directApplied) codexMsg = "已写入 Codex 配置(重启 Codex 即用)";
|
||
else if (st._directError) codexMsg = "写入 Codex 失败:" + st._directError;
|
||
else if (st._directSkipped) codexMsg = st._directReason || "未写入 Codex(需启用路由/补全密钥)";
|
||
let claudeMsg = "";
|
||
if (st._claudeApplied) claudeMsg = "已接入 Claude Code";
|
||
else if (st._claudeError) claudeMsg = "接入 Claude Code 失败:" + st._claudeError;
|
||
else if (st._claudeSkipped) claudeMsg = "Claude Code 未接入:" + (st._claudeReason || "缺地址或密钥");
|
||
else if (st._claudeRestored) claudeMsg = "已取消 Claude Code 接入";
|
||
const parts = [codexMsg, claudeMsg].filter(Boolean);
|
||
const hasErr = st._directError || st._claudeError;
|
||
const hasWarn = st._directSkipped || st._claudeSkipped;
|
||
if (parts.length) {
|
||
toast("已保存分组 ✅ " + parts.join(";"), hasErr ? "err" : hasWarn ? "warn" : "ok");
|
||
} else {
|
||
toast("已保存分组 ✅", "ok");
|
||
}
|
||
} catch (e) {
|
||
toast("保存失败:" + (e.message || e), "err");
|
||
}
|
||
});
|
||
|
||
$("#grp-fetch-models").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
const baseUrl = $("#grp-url").value.trim();
|
||
const key = $("#grp-key").value; // 留空且是编辑 → 用已保存的密钥(传 groupId)
|
||
const hint = $("#grp-model-hint");
|
||
if (!baseUrl) {
|
||
hint.textContent = "请先填 API 地址";
|
||
return;
|
||
}
|
||
busy(btn, true, "获取中…");
|
||
hint.textContent = "";
|
||
try {
|
||
const models = await apic.routerFetchModels(baseUrl, key, grpEditing ? grpEditing.id : null);
|
||
const sel = $("#grp-model-list");
|
||
sel.innerHTML =
|
||
'<option value="">— 从下拉选择模型 —</option>' +
|
||
models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("");
|
||
// 若当前已有模型值,选中它
|
||
if ($("#grp-model").value) sel.value = $("#grp-model").value;
|
||
sel.classList.remove("hidden");
|
||
grpRender1mList(models); // 渲染 1M 勾选清单(合并已保存标记)
|
||
hint.textContent = `共 ${models.length} 个模型,选一个或保持手填`;
|
||
} catch (e) {
|
||
hint.textContent = "获取失败:" + (e.message || e) + "(可手动填模型)";
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#grp-model-list").addEventListener("change", (e) => {
|
||
if (e.target.value) $("#grp-model").value = e.target.value;
|
||
});
|
||
|
||
$("#router-toggle").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "处理中…");
|
||
try {
|
||
if (routerState.running) {
|
||
const r = await apic.routerStop();
|
||
renderRouter(r.status || r);
|
||
if (r.releaseError) toast("本地路由已停止,但清理 Codex 接管配置失败:" + r.releaseError, "err");
|
||
else if (r.released && r.keptUserProvider) toast("本地路由已停止,已清理本工具配置并保留你手写的 provider", "ok");
|
||
else if (r.released) toast("本地路由已停止,已清理本工具配置", "ok");
|
||
else if (r.skippedRelease) toast("本地路由已停止,当前 Codex 配置未由本地路由接管", "warn");
|
||
} else {
|
||
renderRouter(await apic.routerStart());
|
||
}
|
||
} catch (e) {
|
||
toast("操作失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#router-refresh-codex").addEventListener("click", async (ev) => {
|
||
if (!confirm("将重启 Codex 以同步顶部模型选择器。\n\n登录态与历史会话保留,未保存的输入内容请先自行确认。是否继续?")) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "刷新中…");
|
||
try {
|
||
const s = await apic.voiceRestartCodex();
|
||
renderVoiceStatus(s);
|
||
if (s && s.ok && s.phase === "connected") toast("Codex 已重启,顶部模型选择器已刷新", "ok");
|
||
else toast((s && (s.error || s.lastError)) || "Codex 刷新失败", "warn");
|
||
} catch (e) {
|
||
toast("Codex 刷新失败:" + (e && e.message ? e.message : e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
renderVoiceStatus(latestVoiceStatus || {});
|
||
}
|
||
});
|
||
|
||
$("#router-save-port").addEventListener("click", async () => {
|
||
try {
|
||
renderRouter(await apic.routerSetPort($("#router-port").value));
|
||
toast("端口已更新", "ok");
|
||
} catch (e) {
|
||
toast("改端口失败:" + (e.message || e), "err");
|
||
}
|
||
});
|
||
$("#router-apply").addEventListener("click", async (ev) => {
|
||
if (!confirm("【高级】启用本地路由:将把 Codex 的 config.toml 改为指向本地路由并启动路由进程(会自动备份)。\n\n普通使用无需此项——保存分组时已把中转配置直接写进 Codex,重启即用。\n仅当你需要「切分组免重启」或使用 Chat 协议转换分组(如 DeepSeek/GLM/Kimi)时才需要启用路由。\n\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认启用?")) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "配置中…");
|
||
try {
|
||
const r = await apic.routerApplyCodex();
|
||
renderRouter(r.status);
|
||
toast("已启用本地路由 ✅ 重启 Codex 后即可通过路由,之后切分组无需重启", "ok");
|
||
} catch (e) {
|
||
toast("配置失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
$("#router-apply-claude").addEventListener("click", async (ev) => {
|
||
const applied = routerState && routerState.claudeApplied;
|
||
if (applied) {
|
||
if (!confirm("取消接入:将从 ~/.claude/settings.json 移除 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN(还原成你原来的值)。\n\n确认?")) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "还原中…");
|
||
try {
|
||
const r = await apic.routerRestoreClaude();
|
||
renderRouter(r.status);
|
||
toast("已取消接入 Claude Code ✅", "ok");
|
||
} catch (e) {
|
||
toast("还原失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
return;
|
||
}
|
||
const hasAnthropicGroup = routerState && routerState.groups.some((g) => !g.isDefault && g.hasKey);
|
||
if (!hasAnthropicGroup && !confirm("还没有可用的中转分组。先去下面新增一个 Anthropic 格式的中转(base_url 填站点根、模式选 Responses 直连、填密钥),再回来接入。\n\n仍要继续?")) return;
|
||
if (!confirm("将修改 ~/.claude/settings.json,把 ANTHROPIC_BASE_URL 指向本地路由(会自动备份)。\n须搭配 Anthropic 格式的中转分组使用。\n\n确认?")) return;
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "接入中…");
|
||
try {
|
||
const r = await apic.routerApplyClaude();
|
||
renderRouter(r.status);
|
||
toast("已接入 Claude Code ✅ 新开 claude 会话即走本地路由,之后切分组无需重启", "ok");
|
||
} catch (e) {
|
||
toast("接入失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
document.querySelector('.tab[data-tab="router"]').addEventListener("click", loadRouter);
|
||
apic.onRouterChanged((st) => renderRouter(st));
|
||
|
||
// ===========================================================================
|
||
// 在线更新
|
||
// ===========================================================================
|
||
async function loadVersion() {
|
||
try {
|
||
$("#app-version").textContent = "v" + (await apic.appVersion());
|
||
} catch {}
|
||
}
|
||
// 显示"发现新版本 + 前往下载"链接(Mac 半自动更新)
|
||
const RELEASE_FALLBACK = "https://update.tokendun.cc/mhgmh/codex-market-app/releases";
|
||
const updateModal = $("#update-modal");
|
||
let updateMandatory = false;
|
||
|
||
// 更新弹窗:显示版本 + 更新内容。kind="hot" 走应用内热更(下载小包→重启);kind="full" 打开下载页。
|
||
function showUpdateModal({ version, notes, mandatory, url, kind }) {
|
||
const link = url || RELEASE_FALLBACK;
|
||
const isHot = kind === "hot";
|
||
updateMandatory = !!mandatory;
|
||
$("#update-title").textContent = mandatory
|
||
? "重要更新(必须升级)"
|
||
: (isHot ? "有可在线更新的新版本" : "发现新版本");
|
||
$("#update-ver").textContent =
|
||
`新版本 v${version} · 当前 v${($("#app-version").textContent || "").replace(/^v/, "") || "?"}` +
|
||
(isHot ? " · 在线更新(几 MB,无需重装)" : "");
|
||
const badge = $("#update-badge");
|
||
badge.textContent = mandatory ? "强制更新" : (isHot ? "在线更新" : "推荐更新");
|
||
badge.className = "update-badge " + (mandatory ? "force" : "normal");
|
||
$("#update-notes").textContent = notes || "";
|
||
|
||
const foot = $("#update-foot");
|
||
function renderButtons(goLabel) {
|
||
foot.innerHTML =
|
||
(mandatory
|
||
? `<button class="btn danger" id="upd-quit">退出软件</button>`
|
||
: `<button class="btn ghost" id="upd-later">稍后再说</button>`) +
|
||
`<button class="btn primary" id="upd-go">${goLabel}</button>`;
|
||
if (mandatory) $("#upd-quit").addEventListener("click", () => apic.quitApp());
|
||
else $("#upd-later").addEventListener("click", closeUpdateModal);
|
||
$("#upd-go").addEventListener("click", onGo);
|
||
}
|
||
async function onGo(ev) {
|
||
if (!isHot) { apic.openExternal(link); return; }
|
||
// 热更:下载 → 校验 → 应用 → 重启
|
||
const go = ev ? ev.currentTarget : $("#upd-go");
|
||
go.disabled = true;
|
||
const later = $("#upd-later"); if (later) later.disabled = true;
|
||
const stop = apic.onHotProgress((p) => { go.textContent = `下载中 ${p}%`; });
|
||
try {
|
||
await apic.hotApply();
|
||
stop && stop();
|
||
updateMandatory = true; // 应用后锁定,引导重启
|
||
$("#update-notes").textContent = "更新已下载并校验完成 ✅ 重启后生效。";
|
||
foot.innerHTML = `<button class="btn primary" id="upd-restart">立即重启</button>`;
|
||
$("#upd-restart").addEventListener("click", () => apic.hotRestart());
|
||
} catch (e) {
|
||
stop && stop();
|
||
go.disabled = false;
|
||
const later2 = $("#upd-later"); if (later2) later2.disabled = false;
|
||
toast(`更新失败:${e.message || e}`, "err");
|
||
go.textContent = "重试";
|
||
}
|
||
}
|
||
renderButtons("立即更新");
|
||
updateModal.classList.remove("hidden");
|
||
}
|
||
function closeUpdateModal() {
|
||
if (updateMandatory) return; // 强制更新不允许关闭
|
||
updateModal.classList.add("hidden");
|
||
}
|
||
// 一般更新:点背景关闭;强制更新:点背景无效
|
||
updateModal.addEventListener("click", (e) => {
|
||
if (e.target === updateModal) closeUpdateModal();
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && !updateModal.classList.contains("hidden")) closeUpdateModal();
|
||
});
|
||
|
||
$("#btn-check-update").addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
const info = $("#update-info");
|
||
busy(btn, true, "检查中…");
|
||
info.textContent = "";
|
||
try {
|
||
// 先查热更(gitea,国内可达)
|
||
const h = await apic.hotCheck();
|
||
if (h && h.canHot) {
|
||
info.textContent = `发现可在线更新 v${h.contentVersion}`;
|
||
showUpdateModal({ version: h.contentVersion, notes: h.notes, mandatory: h.mandatory, kind: "hot" });
|
||
return;
|
||
}
|
||
if (h && (h.needFull || h.needFullAfterHot)) {
|
||
info.textContent = `需要安装完整包 v${h.fullAppVersion || h.contentVersion}`;
|
||
showUpdateModal({
|
||
version: h.fullAppVersion || h.contentVersion,
|
||
notes: `${h.notes || ""}${h.needFullAfterHot ? "\n\n本次更新还需要安装完整包,以注册 TD 工具箱深链协议。" : ""}`.trim(),
|
||
mandatory: h.mandatory,
|
||
url: RELEASE_FALLBACK,
|
||
kind: "full",
|
||
});
|
||
return;
|
||
}
|
||
// 否则查完整安装包
|
||
const r = await apic.checkUpdate();
|
||
if (r.dev) info.textContent = "(开发版不检查更新)";
|
||
else if (r.network) {
|
||
info.innerHTML = `⚠️ ${esc(r.error)} <a class="link" id="dl-manual">前往下载页 ↗</a>`;
|
||
const a = $("#dl-manual");
|
||
if (a) a.addEventListener("click", () => apic.openExternal(r.url || RELEASE_FALLBACK));
|
||
}
|
||
else if (r.error) info.textContent = "检查失败:" + r.error;
|
||
else if (r.hasNew) {
|
||
info.textContent = `发现新版本 v${r.version}`;
|
||
showUpdateModal({ version: r.version, notes: r.notes, mandatory: r.mandatory, url: r.url, kind: "full" });
|
||
}
|
||
else info.textContent = "已是最新版本 ✅";
|
||
} catch (e) {
|
||
info.textContent = "检查失败:" + (e.message || e);
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
apic.onUpdateStatus((s) => {
|
||
const info = $("#update-info");
|
||
if (s.state === "hot-available") {
|
||
info.textContent = `发现可在线更新 v${s.version}`;
|
||
showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, kind: "hot" });
|
||
}
|
||
else if (s.state === "available-manual") {
|
||
info.textContent = `发现新版本 v${s.version}`;
|
||
showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, url: s.url, kind: "full" });
|
||
}
|
||
else if (s.state === "available") info.textContent = `发现新版本 ${s.version},下载中…`;
|
||
else if (s.state === "downloading") info.textContent = `下载新版本 ${s.percent}%…`;
|
||
else if (s.state === "downloaded") info.textContent = `新版本 ${s.version} 已就绪,退出软件即自动更新`;
|
||
else if (s.state === "none") info.textContent = "已是最新版本 ✅";
|
||
else if (s.state === "error") info.textContent = "更新检查失败:" + (s.message || "");
|
||
});
|
||
|
||
// ============================================================
|
||
// 创意画布(Drawflow 节点画布)
|
||
// 懒初始化:首次进入 canvas 标签才 new Drawflow + start(),避免容器无尺寸时初始化。
|
||
// 只经 apic.canvasStatus() / apic.canvasRun(kind, params) 与主进程通信(见 CANVAS_SPEC.md)。
|
||
// ============================================================
|
||
const CANVAS = {
|
||
editor: null, // Drawflow 实例(首次进入才创建)
|
||
inited: false, // 是否已初始化
|
||
status: null, // 最近一次 canvasStatus 结果
|
||
outputs: {}, // 各节点产物缓存:{ [nodeId]: { text?, image? } }
|
||
addCount: 0, // 添加计数,用于错位摆放避免节点重叠
|
||
groups: [], // 中转分组列表 [{id,name,model,host}]
|
||
activeGroupId: "", // 当前激活分组 id(新节点默认选中)
|
||
runSeq: 0, // 运行序号,配合 nodeId 组成唯一 runId
|
||
runMap: {}, // runId → nodeId 映射(流式渐进预览用)
|
||
refImages: {}, // 图片节点参考图缓存:{ [nodeId]: dataURL }(图生图,运行时传图床换外链)
|
||
videoMaterials: {}, // 视频节点素材缓存:{ [nodeId]: { first?, last?, refs:[] } }(dataURL,运行时传图床)
|
||
quickAddPos: null, // 双击空白处弹出菜单时记录的画布坐标
|
||
progressBound: false, // onCanvasProgress 是否已订阅(幂等)
|
||
portResizeObserver: null, // 预览框尺寸变化时同步端口位置
|
||
openConfigEl: null, // 当前展开参数配置的紧凑节点
|
||
openPopEl: null, // 当前展开的 details 参数弹层
|
||
portRefreshRAF: 0, // 全量端口刷新 rAF 合并锁
|
||
portRefreshNodeRAFs: {}, // 单节点端口刷新 rAF 合并锁
|
||
portResizeQueue: new Set(), // ResizeObserver 收集后统一刷新
|
||
portResizeRAF: 0,
|
||
portResizeTimer: 0,
|
||
dragStart: null, // 节点拖动阈值判断,避免拖动结束误触发配置展开
|
||
dragSuppressClick: false,
|
||
dragEndTimer: 0,
|
||
workspaceId: "",
|
||
workspaceName: "未命名画布",
|
||
workspaceReady: false,
|
||
workspaceRestoring: false,
|
||
workspaceSaveTimer: 0,
|
||
workspaceSaving: false,
|
||
workspaceResave: false,
|
||
workspaceSavePromise: null,
|
||
workspaceLoading: false,
|
||
workspaceList: [],
|
||
workspaceRenamingId: "",
|
||
outputRefs: {}, // { [nodeId]: { kind, assetId, sourceUrl?, meta? } },本地媒体引用
|
||
materialRefs: {}, // 上传的参考图 / 首尾帧 / 多参考素材引用
|
||
mediaCacheQueue: Promise.resolve(), // 多参考图批量上传时串行修改同一项目存档
|
||
};
|
||
|
||
function cvQueueMediaCache(payload) {
|
||
const run = () => apic.canvasMediaCache(payload);
|
||
CANVAS.mediaCacheQueue = CANVAS.mediaCacheQueue.then(run, run);
|
||
return CANVAS.mediaCacheQueue;
|
||
}
|
||
|
||
function cvWorkspaceTitle() {
|
||
const el = document.getElementById("cv-projects-btn");
|
||
if (el) el.textContent = CANVAS.workspaceName || "未命名画布";
|
||
}
|
||
|
||
function cvControlKey(el, index) {
|
||
const classes = Array.from(el.classList || []).filter((name) => name.startsWith("cv-")).sort().join(".");
|
||
return `${el.tagName}:${classes}:${el.dataset.slot || ""}:${index}`;
|
||
}
|
||
|
||
function cvCaptureNodeState() {
|
||
const out = {};
|
||
document.querySelectorAll("#drawflow .drawflow-node").forEach((nodeEl) => {
|
||
const id = nodeEl.id.replace("node-", "");
|
||
if (!id) return;
|
||
const fields = {};
|
||
Array.from(nodeEl.querySelectorAll("input, textarea, select")).forEach((el, index) => {
|
||
if (el.type === "file") return;
|
||
fields[cvControlKey(el, index)] = el.value;
|
||
});
|
||
out[id] = { fields };
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function cvApplyNodeState(nodeEl, state) {
|
||
if (!nodeEl || !state || !state.fields) return;
|
||
Array.from(nodeEl.querySelectorAll("input, textarea, select")).forEach((el, index) => {
|
||
if (el.type === "file") return;
|
||
const value = state.fields[cvControlKey(el, index)];
|
||
if (value === undefined) return;
|
||
if (el.tagName === "SELECT" && value && !Array.from(el.options).some((option) => option.value === value)) {
|
||
const option = document.createElement("option");
|
||
option.value = value;
|
||
option.textContent = value + "(上次使用)";
|
||
el.appendChild(option);
|
||
}
|
||
el.value = value;
|
||
});
|
||
cvSyncVideoMode(nodeEl);
|
||
cvUpdateParamSummary(nodeEl);
|
||
}
|
||
|
||
function cvWorkspaceSnapshot() {
|
||
const ed = CANVAS.editor;
|
||
if (!ed || !CANVAS.workspaceId) return null;
|
||
let drawflow;
|
||
try { drawflow = ed.export(); } catch { return null; }
|
||
return {
|
||
id: CANVAS.workspaceId,
|
||
name: CANVAS.workspaceName,
|
||
canvas: {
|
||
drawflow,
|
||
module: ed.module || "Home",
|
||
zoom: ed.zoom || 1,
|
||
canvasX: ed.canvas_x || 0,
|
||
canvasY: ed.canvas_y || 0,
|
||
addCount: CANVAS.addCount || 0,
|
||
},
|
||
nodeState: cvCaptureNodeState(),
|
||
outputs: CANVAS.outputRefs,
|
||
materials: CANVAS.materialRefs,
|
||
};
|
||
}
|
||
|
||
function cvScheduleAutosave() {
|
||
if (!CANVAS.workspaceReady || CANVAS.workspaceRestoring || !CANVAS.workspaceId) return;
|
||
clearTimeout(CANVAS.workspaceSaveTimer);
|
||
CANVAS.workspaceSaveTimer = setTimeout(() => cvSaveWorkspace(), 700);
|
||
}
|
||
|
||
async function cvSaveWorkspace() {
|
||
clearTimeout(CANVAS.workspaceSaveTimer);
|
||
CANVAS.workspaceSaveTimer = 0;
|
||
if (!apic.canvasWorkspaceSave) return;
|
||
if (CANVAS.workspaceSaving) {
|
||
CANVAS.workspaceResave = true;
|
||
return CANVAS.workspaceSavePromise;
|
||
}
|
||
CANVAS.workspaceSaving = true;
|
||
CANVAS.workspaceSavePromise = (async () => {
|
||
try {
|
||
do {
|
||
CANVAS.workspaceResave = false;
|
||
await CANVAS.mediaCacheQueue;
|
||
const snapshot = cvWorkspaceSnapshot();
|
||
if (!snapshot) return;
|
||
const r = await apic.canvasWorkspaceSave(snapshot);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "本地画布保存失败");
|
||
CANVAS.workspaceName = r.workspace.name || CANVAS.workspaceName;
|
||
cvWorkspaceTitle();
|
||
} while (CANVAS.workspaceResave);
|
||
} catch (e) {
|
||
console.warn("canvas autosave failed", e);
|
||
} finally {
|
||
CANVAS.workspaceSaving = false;
|
||
CANVAS.workspaceSavePromise = null;
|
||
}
|
||
})();
|
||
return CANVAS.workspaceSavePromise;
|
||
}
|
||
|
||
function cvWorkspaceNameForNow() {
|
||
const d = new Date();
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return `画布 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
async function cvRestoreOutputs() {
|
||
const entries = Object.entries(CANVAS.outputRefs || {});
|
||
await Promise.all(entries.map(async ([nodeId, ref]) => {
|
||
if (!ref || !ref.assetId || !apic.canvasMediaRead) return;
|
||
try {
|
||
const r = await apic.canvasMediaRead({ workspaceId: CANVAS.workspaceId, assetId: ref.assetId });
|
||
if (!r || !r.ok || !r.dataUrl) return;
|
||
const nodeEl = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeEl) return;
|
||
const host = cvResultHost(nodeEl, ref.kind);
|
||
cvRenderResult(ref.kind, host, { dataUrl: r.dataUrl, url: ref.sourceUrl || "", meta: ref.meta || {} });
|
||
if (ref.kind === "image") CANVAS.outputs[nodeId] = { image: r.dataUrl };
|
||
} catch (e) {
|
||
console.warn("canvas media restore failed", e);
|
||
}
|
||
}));
|
||
}
|
||
|
||
async function cvReadAsset(assetId) {
|
||
if (!assetId || !apic.canvasMediaRead) return "";
|
||
const r = await apic.canvasMediaRead({ workspaceId: CANVAS.workspaceId, assetId });
|
||
return r && r.ok ? r.dataUrl || "" : "";
|
||
}
|
||
|
||
async function cvRestoreMaterials() {
|
||
const entries = Object.entries(CANVAS.materialRefs || {});
|
||
await Promise.all(entries.map(async ([nodeId, refs]) => {
|
||
const nodeEl = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeEl || !refs) return;
|
||
try {
|
||
if (refs.image && refs.image.assetId) {
|
||
const dataUrl = await cvReadAsset(refs.image.assetId);
|
||
if (dataUrl) {
|
||
CANVAS.refImages[nodeId] = dataUrl;
|
||
const thumb = nodeEl.querySelector(".cv-refimg-thumb");
|
||
if (thumb) { thumb.src = dataUrl; thumb.classList.remove("hidden"); }
|
||
nodeEl.querySelector(".cv-refimg-clear")?.classList.remove("hidden");
|
||
const frame = nodeEl.querySelector(".cv-image-frame .cv-video-frame-inner");
|
||
if (frame && !CANVAS.outputRefs[nodeId]) frame.innerHTML = cvPreviewChrome("image", dataUrl, "", '<div class="cv-meta">参考图 · 本地缓存</div>');
|
||
}
|
||
}
|
||
const mats = (CANVAS.videoMaterials[nodeId] = { refs: [] });
|
||
for (const slot of ["first", "last"]) {
|
||
if (!refs[slot] || !refs[slot].assetId) continue;
|
||
const dataUrl = await cvReadAsset(refs[slot].assetId);
|
||
if (!dataUrl) continue;
|
||
mats[slot] = dataUrl;
|
||
const thumb = nodeEl.querySelector(`.cv-vmat-thumb[data-slot="${slot}"]`);
|
||
if (thumb) { thumb.src = dataUrl; thumb.classList.remove("hidden"); }
|
||
nodeEl.querySelector(`.cv-vmat-clear[data-slot="${slot}"]`)?.classList.remove("hidden");
|
||
}
|
||
if (Array.isArray(refs.refs)) {
|
||
const list = await Promise.all(refs.refs.map((item) => cvReadAsset(item.assetId)));
|
||
mats.refs = list.filter(Boolean);
|
||
const box = nodeEl.querySelector(".cv-vmat-refs-thumbs");
|
||
if (box) {
|
||
box.innerHTML = mats.refs.map((src) => `<img src="${src}" alt="参考图" style="max-width:40px;max-height:40px;border-radius:4px;object-fit:cover;margin:2px">`).join("");
|
||
}
|
||
if (mats.refs.length) nodeEl.querySelector('.cv-vmat-clear[data-slot="refs"]')?.classList.remove("hidden");
|
||
}
|
||
} catch (e) {
|
||
console.warn("canvas material restore failed", e);
|
||
}
|
||
}));
|
||
}
|
||
|
||
async function cvApplyWorkspace(workspace) {
|
||
const ed = CANVAS.editor;
|
||
if (!ed || !workspace) return;
|
||
CANVAS.workspaceRestoring = true;
|
||
try {
|
||
CANVAS.workspaceId = workspace.id;
|
||
CANVAS.workspaceName = workspace.name || "未命名画布";
|
||
CANVAS.outputs = {};
|
||
CANVAS.outputRefs = workspace.outputs && typeof workspace.outputs === "object" ? workspace.outputs : {};
|
||
CANVAS.materialRefs = workspace.materials && typeof workspace.materials === "object" ? workspace.materials : {};
|
||
CANVAS.refImages = {};
|
||
CANVAS.videoMaterials = {};
|
||
const canvas = workspace.canvas || {};
|
||
const drawflow = canvas.drawflow && canvas.drawflow.drawflow ? canvas.drawflow : { drawflow: { Home: { data: {} } } };
|
||
// 存档只继承拓扑/位置/数据;节点 HTML 使用当前版本模板,避免热更新后旧画布卡在旧 UI。
|
||
Object.values(drawflow.drawflow || {}).forEach((moduleData) => {
|
||
Object.values((moduleData && moduleData.data) || {}).forEach((node) => {
|
||
if (node && CV_NODES[node.name]) {
|
||
node.html = cvNodeHTML(node.name);
|
||
node.class = `cv-df-${node.name}`;
|
||
}
|
||
});
|
||
});
|
||
ed.import(drawflow);
|
||
ed.module = canvas.module && drawflow.drawflow[canvas.module] ? canvas.module : "Home";
|
||
CANVAS.addCount = Number(canvas.addCount) || 0;
|
||
ed.zoom = Number(canvas.zoom) || 1;
|
||
ed.canvas_x = Number(canvas.canvasX) || 0;
|
||
ed.canvas_y = Number(canvas.canvasY) || 0;
|
||
ed.zoom_last_value = ed.zoom;
|
||
ed.precanvas.style.transform = `translate(${ed.canvas_x}px, ${ed.canvas_y}px) scale(${ed.zoom})`;
|
||
Object.keys(cvAllNodes()).forEach((id) => {
|
||
cvSetupNode(id);
|
||
cvApplyNodeState(document.getElementById(`node-${id}`), (workspace.nodeState || {})[id]);
|
||
});
|
||
cvWorkspaceTitle();
|
||
cvSyncZoomControls(true);
|
||
cvUpdateEmptyHint();
|
||
cvRefreshAllNodePorts();
|
||
await cvRestoreMaterials();
|
||
await cvRestoreOutputs();
|
||
} finally {
|
||
CANVAS.workspaceRestoring = false;
|
||
CANVAS.workspaceReady = true;
|
||
}
|
||
}
|
||
|
||
async function cvCreateWorkspace() {
|
||
if (!apic.canvasWorkspaceCreate) throw new Error("画布本地存储能力未就绪,请先热更新");
|
||
await cvSaveWorkspace();
|
||
const r = await apic.canvasWorkspaceCreate(cvWorkspaceNameForNow());
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "新建画布失败");
|
||
await cvApplyWorkspace(r.workspace);
|
||
cvCloseWorkspacePicker();
|
||
toast("已新建画布,编辑内容会自动保存到本机", "ok");
|
||
}
|
||
|
||
function cvWorkspaceTime(value) {
|
||
const date = new Date(value || 0);
|
||
return Number.isNaN(date.getTime()) ? "刚刚保存" : date.toLocaleString();
|
||
}
|
||
|
||
function cvRenderWorkspaceList() {
|
||
const list = document.getElementById("cv-project-list");
|
||
if (!list) return;
|
||
const items = CANVAS.workspaceList || [];
|
||
if (!items.length) {
|
||
list.innerHTML = '<div class="cv-project-empty">还没有画布记录,新建一个开始创作。</div>';
|
||
return;
|
||
}
|
||
document.getElementById("cv-project-close")?.classList.toggle("hidden", !CANVAS.workspaceReady);
|
||
list.innerHTML = items.map((item) => {
|
||
const renaming = item.id === CANVAS.workspaceRenamingId;
|
||
return `
|
||
<div class="cv-project-item ${item.id === CANVAS.workspaceId ? "active" : ""}">
|
||
<div class="cv-project-info">${renaming
|
||
? `<input class="cv-project-title-input" type="text" maxlength="60" value="${esc(item.name || "未命名画布")}" data-cv-rename-input="${esc(item.id)}" aria-label="画布名称" />`
|
||
: `<div class="cv-project-title">${esc(item.name || "未命名画布")}</div>`}
|
||
<div class="cv-project-meta">${esc(cvWorkspaceTime(item.updatedAt))} · ${Number(item.nodeCount) || 0} 个节点 · ${Number(item.mediaCount) || 0} 个本地素材</div></div>
|
||
<div class="cv-project-item-actions">
|
||
${renaming
|
||
? `<button class="cv-project-icon ok" type="button" data-cv-rename-save="${esc(item.id)}" title="保存名称" aria-label="保存名称">✓</button>
|
||
<button class="cv-project-icon" type="button" data-cv-rename-cancel title="取消改名" aria-label="取消改名">×</button>`
|
||
: `<button class="cv-project-icon" type="button" data-cv-rename-start="${esc(item.id)}" title="修改画布名称" aria-label="修改画布名称">✎</button>
|
||
<button class="btn small ghost cv-project-open" type="button" data-cv-workspace-id="${esc(item.id)}">${item.id === CANVAS.workspaceId ? "当前画布" : "继续"}</button>`}
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
const input = list.querySelector("[data-cv-rename-input]");
|
||
if (input) { input.focus(); input.select(); }
|
||
}
|
||
|
||
async function cvRenameWorkspace(id) {
|
||
const input = document.querySelector(`[data-cv-rename-input="${id}"]`);
|
||
const name = input ? input.value.trim() : "";
|
||
if (!name) {
|
||
toast("画布名称不能为空", "err");
|
||
input?.focus();
|
||
return;
|
||
}
|
||
const r = await apic.canvasWorkspaceRename(id, name);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "画布改名失败");
|
||
const item = CANVAS.workspaceList.find((entry) => entry.id === id);
|
||
if (item) Object.assign(item, r.workspace);
|
||
if (CANVAS.workspaceId === id) {
|
||
CANVAS.workspaceName = r.workspace.name;
|
||
cvWorkspaceTitle();
|
||
}
|
||
CANVAS.workspaceRenamingId = "";
|
||
cvRenderWorkspaceList();
|
||
toast("画布名称已修改", "ok");
|
||
}
|
||
|
||
async function cvOpenWorkspacePicker() {
|
||
const picker = document.getElementById("cv-project-picker");
|
||
if (!picker) return;
|
||
try {
|
||
const r = await apic.canvasWorkspaces();
|
||
CANVAS.workspaceList = r && r.ok && Array.isArray(r.workspaces) ? r.workspaces : [];
|
||
} catch (e) {
|
||
CANVAS.workspaceList = [];
|
||
}
|
||
cvRenderWorkspaceList();
|
||
picker.classList.remove("hidden");
|
||
}
|
||
|
||
function cvCloseWorkspacePicker() {
|
||
if (!CANVAS.workspaceReady) return;
|
||
document.getElementById("cv-project-picker")?.classList.add("hidden");
|
||
}
|
||
|
||
async function cvLoadWorkspace(id) {
|
||
if (!id || id === CANVAS.workspaceId) return cvCloseWorkspacePicker();
|
||
await cvSaveWorkspace();
|
||
const r = await apic.canvasWorkspaceLoad(id);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "读取画布记录失败");
|
||
await cvApplyWorkspace(r.workspace);
|
||
cvCloseWorkspacePicker();
|
||
toast("已恢复画布记录和本地生成素材", "ok");
|
||
}
|
||
|
||
async function cvCacheOutput(nodeId, kind, result, context) {
|
||
const workspaceId = (context && context.workspaceId) || CANVAS.workspaceId;
|
||
if (!workspaceId || !result || !result.dataUrl || !apic.canvasMediaCache) return;
|
||
const r = await cvQueueMediaCache({
|
||
workspaceId, nodeId, kind, dataUrl: result.dataUrl, role: "output",
|
||
sourceUrl: result.url || "", meta: result.meta || {},
|
||
workspaceState: CANVAS.workspaceId === workspaceId ? cvWorkspaceSnapshot() : null,
|
||
});
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "生成结果本地缓存失败");
|
||
if (CANVAS.workspaceId !== workspaceId) return;
|
||
const previous = CANVAS.outputRefs[nodeId];
|
||
const history = previous && Array.isArray(previous.history) ? previous.history.slice() : [];
|
||
if (previous && previous.assetId && previous.assetId !== r.assetId) {
|
||
history.push({ ...previous, history: undefined });
|
||
}
|
||
CANVAS.outputRefs[nodeId] = {
|
||
kind, assetId: r.assetId, sourceUrl: result.url || "", meta: result.meta || {},
|
||
cachedAt: new Date().toISOString(), history,
|
||
};
|
||
await cvSaveWorkspace();
|
||
}
|
||
|
||
async function cvCacheMaterial(nodeId, slot, dataUrl, context) {
|
||
const workspaceId = (context && context.workspaceId) || CANVAS.workspaceId;
|
||
if (!workspaceId || !dataUrl || !apic.canvasMediaCache) return;
|
||
try {
|
||
const r = await cvQueueMediaCache({
|
||
workspaceId, nodeId, kind: "image", dataUrl, role: "material", slot,
|
||
workspaceState: (context && context.workspaceState) || cvWorkspaceSnapshot(),
|
||
});
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "素材缓存失败");
|
||
if (CANVAS.workspaceId !== workspaceId) return;
|
||
const refs = (CANVAS.materialRefs[nodeId] = CANVAS.materialRefs[nodeId] || { refs: [] });
|
||
const value = { assetId: r.assetId };
|
||
if (slot === "refs") refs.refs = (refs.refs || []).concat(value);
|
||
else refs[slot] = value;
|
||
cvScheduleAutosave();
|
||
} catch (e) {
|
||
toast("参考素材本地缓存失败:" + (e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
// 4 类节点的元信息:标题 / 图标 / 输入端口数 / 输出端口数 / 角标
|
||
const CV_NODES = {
|
||
text: { title: "文本 / 提示词", icon: "📝", inputs: 0, outputs: 1, tag: "" },
|
||
image: { title: "文生图", icon: "🖼️", inputs: 1, outputs: 1, tag: "" },
|
||
video: { title: "Seedance 视频", icon: "🎬", inputs: 1, outputs: 0, tag: "V1/V2" },
|
||
grokVideo: { title: "Grok 视频", icon: "🎞️", inputs: 1, outputs: 0, tag: "Grok" },
|
||
tts: { title: "TTS 配音", icon: "🔊", inputs: 1, outputs: 0, tag: "" },
|
||
};
|
||
|
||
function cvCompactVideoBody(kind) {
|
||
const isGrok = kind === "grokVideo";
|
||
const title = isGrok ? "Grok 视频" : "视频";
|
||
const modelHint = isGrok ? "Grok 模型" : "Seedance / V2 模型";
|
||
const resolutionOptions = isGrok
|
||
? `<option value="720p">720p</option><option value="1080p">1080p</option><option value="480p">480p</option>`
|
||
: `<option value="720p">720p</option><option value="1080p">1080p</option><option value="2k">2K(需上游支持)</option><option value="4k">4K(需上游支持)</option><option value="480p">480p</option>`;
|
||
const ratioOptions = isGrok
|
||
? `<option value="16:9">16:9</option><option value="9:16">9:16</option><option value="4:3">4:3</option><option value="3:4">3:4</option><option value="2:3">2:3</option><option value="3:2">3:2</option><option value="1:1">1:1</option>`
|
||
: `<option value="16:9">16:9</option><option value="9:16">9:16</option><option value="1:1">1:1</option><option value="4:3">4:3</option>`;
|
||
return `
|
||
<div class="cv-video-compact">
|
||
<div class="cv-video-label"><span>▦</span>${title}</div>
|
||
<div class="cv-video-frame" data-port-anchor>
|
||
<div class="cv-video-frame-inner">▣</div>
|
||
</div>
|
||
<div class="cv-video-foot">
|
||
<span>参考图 0 张(连线 / @ 同步)</span>
|
||
<b>待定</b>
|
||
</div>
|
||
<div class="cv-video-composer">
|
||
<div class="cv-video-compose-tools">
|
||
<span>↖</span>
|
||
<span>+</span>
|
||
<span class="cv-video-expand">⛶</span>
|
||
</div>
|
||
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="2" placeholder="描述任何你想要生成的内容"></textarea>
|
||
<div class="cv-video-chip-row">
|
||
<select class="cv-field cv-field-group cv-chip-select" title="中转分组"></select>
|
||
<select class="cv-field cv-field-model cv-chip-select" title="${modelHint}"><option value="">${modelHint}</option></select>
|
||
<button class="cv-fetch-models cv-chip-action" title="拉取该分组的模型">拉取</button>
|
||
<details class="cv-video-pop">
|
||
<summary>文生 · 16:9 · 720p · 5s</summary>
|
||
<div class="cv-video-pop-body">
|
||
<label>生成方式<select class="cv-field cv-field-vmode" title="模式">
|
||
<option value="text">文生视频</option>
|
||
<option value="first">首帧图生视频</option>
|
||
<option value="reference">多参考图</option>
|
||
${isGrok ? "" : '<option value="firstlast">双图参考</option>'}
|
||
</select></label>
|
||
<label>比例<select class="cv-field cv-field-ratio" title="比例">${ratioOptions}</select></label>
|
||
<label>清晰度<select class="cv-field cv-field-vsize" title="分辨率">
|
||
${resolutionOptions}
|
||
</select></label>
|
||
<label>时长<select class="cv-field cv-field-duration" title="时长">
|
||
<option value="5">5s</option><option value="6">6s</option><option value="8">8s</option>
|
||
<option value="10">10s</option><option value="15">15s</option>
|
||
</select></label>
|
||
<label>运镜<select class="cv-field cv-field-vquality" title="画质">
|
||
<option value="auto">自动</option><option value="hd">高清</option>
|
||
</select></label>
|
||
<label class="cv-pop-wide">高级<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" /></label>
|
||
<select class="cv-field cv-field-refsource hidden" title="多参考图字段">
|
||
<option value="images">images</option>
|
||
<option value="ref_assets">ref_assets</option>
|
||
</select>
|
||
<button type="button" class="cv-pop-close">收起</button>
|
||
</div>
|
||
</details>
|
||
<button class="cv-run cv-send-run" title="生成">↑</button>
|
||
</div>
|
||
<div class="cv-video-materials">
|
||
<div class="cv-vmat cv-vmat-first hidden">
|
||
<label class="cv-refimg-pick" title="上传首帧图">首帧<input type="file" class="cv-vmat-file" data-slot="first" accept="image/*" hidden /></label>
|
||
<img class="cv-vmat-thumb hidden" data-slot="first" alt="首帧" />
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="first" title="移除">✕</button>
|
||
<input class="cv-field cv-vmat-url" data-slot="first" placeholder="公网首帧 URL;留空用上游图" />
|
||
</div>
|
||
<div class="cv-vmat cv-vmat-last hidden">
|
||
<label class="cv-refimg-pick" title="上传尾帧图">尾帧<input type="file" class="cv-vmat-file" data-slot="last" accept="image/*" hidden /></label>
|
||
<img class="cv-vmat-thumb hidden" data-slot="last" alt="尾帧" />
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="last" title="移除">✕</button>
|
||
<input class="cv-field cv-vmat-url" data-slot="last" placeholder="公网尾帧 URL" />
|
||
</div>
|
||
<div class="cv-vmat cv-vmat-refs hidden">
|
||
<label class="cv-refimg-pick" title="上传多张参考图">参考图<input type="file" class="cv-vmat-file" data-slot="refs" accept="image/*" multiple hidden /></label>
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="refs" title="清空">✕</button>
|
||
<div class="cv-vmat-refs-thumbs"></div>
|
||
<textarea class="cv-field cv-vmat-urls" data-slot="refs" rows="2" placeholder="可选:每行一个公网图片URL"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function cvCompactImageBody() {
|
||
return `
|
||
<div class="cv-video-compact cv-image-compact">
|
||
<div class="cv-video-label"><span>▧</span>图片</div>
|
||
<div class="cv-video-frame cv-image-frame" data-port-anchor>
|
||
<div class="cv-video-frame-inner">▧</div>
|
||
</div>
|
||
<div class="cv-video-foot">
|
||
<span>参考图 0 张(连线 / @ 同步)</span>
|
||
<b>待定</b>
|
||
</div>
|
||
<div class="cv-video-composer">
|
||
<div class="cv-video-compose-tools">
|
||
<span>↖</span>
|
||
<span>+</span>
|
||
<span class="cv-video-expand">⛶</span>
|
||
</div>
|
||
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="2" placeholder="描述任何你想要生成的画面"></textarea>
|
||
<div class="cv-video-chip-row">
|
||
<select class="cv-field cv-field-group cv-chip-select" title="中转分组"></select>
|
||
<input class="cv-field cv-field-model cv-chip-select" placeholder="图片模型" />
|
||
<button class="cv-fetch-models cv-chip-action" title="拉取该分组的模型">拉取</button>
|
||
<datalist class="cv-node-datalist"></datalist>
|
||
<details class="cv-video-pop">
|
||
<summary>1:1 · 1K</summary>
|
||
<div class="cv-video-pop-body">
|
||
<label>比例<select class="cv-field cv-field-ratio" title="比例">
|
||
<option value="1:1">1:1</option><option value="16:9">16:9</option><option value="9:16">9:16</option>
|
||
<option value="4:3">4:3</option><option value="3:4">3:4</option><option value="3:2">3:2</option>
|
||
</select></label>
|
||
<label>清晰度<select class="cv-field cv-field-quality" title="清晰度">
|
||
<option value="1K">1K</option><option value="2K">2K</option><option value="4K">4K</option>
|
||
</select></label>
|
||
<label class="cv-pop-wide">接口<input class="cv-field cv-field-endpoint" placeholder="/v1/images/generations" /></label>
|
||
<label class="cv-pop-wide">参考字段<input class="cv-field cv-field-reffield" placeholder="image" /></label>
|
||
<div class="cv-refimg-row">
|
||
<label class="cv-refimg-pick" title="上传参考图">参考图<input type="file" class="cv-field-refimg" accept="image/*" hidden /></label>
|
||
<img class="cv-refimg-thumb hidden" alt="参考图" />
|
||
<button type="button" class="cv-refimg-clear hidden" title="移除参考图">✕</button>
|
||
</div>
|
||
<button type="button" class="cv-pop-close">收起</button>
|
||
</div>
|
||
</details>
|
||
<button class="cv-run cv-send-run" title="生成">↑</button>
|
||
</div>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function cvCompactTtsBody() {
|
||
return `
|
||
<div class="cv-video-compact cv-tts-compact">
|
||
<div class="cv-video-label"><span>♪</span>音频</div>
|
||
<div class="cv-video-frame cv-audio-frame" data-port-anchor>
|
||
<div class="cv-video-frame-inner">♪</div>
|
||
</div>
|
||
<div class="cv-video-foot">
|
||
<span>连接文本 / 手动输入</span>
|
||
<b>待定</b>
|
||
</div>
|
||
<div class="cv-video-composer">
|
||
<div class="cv-video-compose-tools">
|
||
<span>↖</span>
|
||
<span>+</span>
|
||
<span class="cv-video-expand">⛶</span>
|
||
</div>
|
||
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="2" placeholder="输入要配音的文本,或连接上游文本节点"></textarea>
|
||
<div class="cv-video-chip-row">
|
||
<select class="cv-field cv-field-group cv-chip-select" title="中转分组"></select>
|
||
<input class="cv-field cv-field-model cv-chip-select" placeholder="TTS 模型" />
|
||
<button class="cv-fetch-models cv-chip-action" title="拉取该分组的模型">拉取</button>
|
||
<datalist class="cv-node-datalist"></datalist>
|
||
<details class="cv-video-pop">
|
||
<summary>alloy · mp3</summary>
|
||
<div class="cv-video-pop-body">
|
||
<label>音色<select class="cv-field cv-field-voice" title="音色">
|
||
<option value="alloy">alloy</option><option value="nova">nova</option><option value="shimmer">shimmer</option>
|
||
<option value="echo">echo</option><option value="fable">fable</option><option value="onyx">onyx</option>
|
||
</select></label>
|
||
<label>格式<select class="cv-field cv-field-format" title="格式">
|
||
<option value="mp3">mp3</option><option value="wav">wav</option><option value="opus">opus</option>
|
||
</select></label>
|
||
<button type="button" class="cv-pop-close">收起</button>
|
||
</div>
|
||
</details>
|
||
<button class="cv-run cv-send-run" title="生成">↑</button>
|
||
</div>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function cvCompactTextBody() {
|
||
return `
|
||
<div class="cv-video-compact cv-text-compact">
|
||
<div class="cv-video-label"><span>T</span>文本</div>
|
||
<div class="cv-video-frame cv-text-frame" data-port-anchor>
|
||
<div class="cv-video-frame-inner">T</div>
|
||
</div>
|
||
<div class="cv-video-foot">
|
||
<span>作为输入源输出文本</span>
|
||
<b>文本</b>
|
||
</div>
|
||
<div class="cv-video-composer">
|
||
<div class="cv-video-compose-tools">
|
||
<span>↖</span>
|
||
<span>+</span>
|
||
</div>
|
||
<textarea class="cv-field cv-field-prompt cv-video-prompt" rows="3" placeholder="输入文本 / 提示词,连线到下游节点作为输入"></textarea>
|
||
<div class="cv-node-hint">作为输入源,实时输出上面的文本</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 各类节点的表单主体(Drawflow 以 HTML 字符串注入到 .drawflow_content_node)
|
||
function cvNodeBody(kind) {
|
||
if (kind === "text") {
|
||
return cvCompactTextBody();
|
||
return `
|
||
<textarea class="cv-field cv-field-prompt" rows="4" placeholder="输入文本 / 提示词,连线到下游节点作为输入…"></textarea>
|
||
<div class="cv-node-hint">作为输入源,实时输出上面的文本</div>`;
|
||
}
|
||
if (kind === "image") {
|
||
return cvCompactImageBody();
|
||
return `
|
||
<textarea class="cv-field cv-field-prompt" rows="3" placeholder="提示词(可留空,将取上游文本节点)"></textarea>
|
||
<div class="cv-refimg-row">
|
||
<label class="cv-refimg-pick" title="上传参考图(图生图)">📎 参考图
|
||
<input type="file" class="cv-field-refimg" accept="image/*" hidden />
|
||
</label>
|
||
<img class="cv-refimg-thumb hidden" alt="参考图" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
|
||
<button type="button" class="cv-refimg-clear hidden" title="移除参考图">✕</button>
|
||
<span class="cv-node-hint">选填:运行时自动传图床换外链,作图生图参考</span>
|
||
</div>
|
||
<select class="cv-field cv-field-group" title="中转分组"></select>
|
||
<div class="cv-model-row">
|
||
<input class="cv-field cv-field-model" placeholder="填图像模型,如 flux-pro / dall-e-3" />
|
||
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
|
||
</div>
|
||
<datalist class="cv-node-datalist"></datalist>
|
||
<div class="cv-row2">
|
||
<select class="cv-field cv-field-ratio" title="比例">
|
||
<option value="1:1">1:1 方</option>
|
||
<option value="16:9">16:9 横</option>
|
||
<option value="9:16">9:16 竖</option>
|
||
<option value="4:3">4:3</option>
|
||
<option value="3:4">3:4</option>
|
||
<option value="3:2">3:2</option>
|
||
</select>
|
||
<select class="cv-field cv-field-quality" title="清晰度">
|
||
<option value="1K">1K</option>
|
||
<option value="2K">2K</option>
|
||
<option value="4K">4K</option>
|
||
</select>
|
||
</div>
|
||
<details class="cv-adv"><summary>⚙️ 高级</summary>
|
||
<input class="cv-field cv-field-endpoint" placeholder="/v1/images/generations" />
|
||
<input class="cv-field cv-field-reffield" placeholder="参考图字段名(默认 image)" />
|
||
</details>
|
||
<button class="cv-run">运行</button>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>`;
|
||
}
|
||
if (kind === "video") {
|
||
return cvCompactVideoBody("video");
|
||
return `
|
||
<select class="cv-field cv-field-vmode" title="模式">
|
||
<option value="first">图生视频 · 首帧图</option>
|
||
<option value="firstlast">双图参考</option>
|
||
<option value="reference">多参考图 / 全能参考</option>
|
||
<option value="text">文生视频(仅提示词)</option>
|
||
</select>
|
||
<textarea class="cv-field cv-field-prompt" rows="2" placeholder="视频描述 / 运动 / 镜头提示词(必填)"></textarea>
|
||
<div class="cv-vmat cv-vmat-first">
|
||
<label class="cv-refimg-pick" title="上传首帧图">📎 首帧
|
||
<input type="file" class="cv-vmat-file" data-slot="first" accept="image/*" hidden />
|
||
</label>
|
||
<img class="cv-vmat-thumb hidden" data-slot="first" alt="首帧" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="first" title="移除">✕</button>
|
||
<input class="cv-field cv-vmat-url" data-slot="first" placeholder="或手填公网图片URL;留空用上游图" />
|
||
</div>
|
||
<div class="cv-vmat cv-vmat-last hidden">
|
||
<label class="cv-refimg-pick" title="上传尾帧图">📎 尾帧
|
||
<input type="file" class="cv-vmat-file" data-slot="last" accept="image/*" hidden />
|
||
</label>
|
||
<img class="cv-vmat-thumb hidden" data-slot="last" alt="尾帧" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="last" title="移除">✕</button>
|
||
<input class="cv-field cv-vmat-url" data-slot="last" placeholder="或手填尾帧公网URL" />
|
||
</div>
|
||
<div class="cv-vmat cv-vmat-refs hidden">
|
||
<label class="cv-refimg-pick" title="上传多张参考图">📎 参考图(可多选)
|
||
<input type="file" class="cv-vmat-file" data-slot="refs" accept="image/*" multiple hidden />
|
||
</label>
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="refs" title="清空">✕</button>
|
||
<div class="cv-vmat-refs-thumbs"></div>
|
||
<div class="cv-node-hint">可多选,运行时各自传图床换外链</div>
|
||
</div>
|
||
<select class="cv-field cv-field-group" title="中转分组"></select>
|
||
<div class="cv-model-row">
|
||
<select class="cv-field cv-field-model" title="CLMM 模型"><option value="">先拉取模型</option></select>
|
||
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
|
||
</div>
|
||
<div class="cv-row2">
|
||
<select class="cv-field cv-field-ratio" title="比例">
|
||
<option value="16:9">16:9</option>
|
||
<option value="9:16">9:16</option>
|
||
<option value="1:1">1:1</option>
|
||
<option value="4:3">4:3</option>
|
||
</select>
|
||
<select class="cv-field cv-field-vquality" title="画质">
|
||
<option value="auto">画质 自动</option>
|
||
<option value="hd">HD</option>
|
||
</select>
|
||
</div>
|
||
<div class="cv-row2">
|
||
<select class="cv-field cv-field-duration" title="时长">
|
||
<option value="-1">时长 自动</option>
|
||
<option value="5">5s</option>
|
||
<option value="10">10s</option>
|
||
<option value="15">15s</option>
|
||
</select>
|
||
</div>
|
||
<div class="cv-node-hint">支持 Seedance / Seedance V2:切换分组后自动匹配接口</div>
|
||
<details class="cv-adv"><summary>⚙️ 高级</summary>
|
||
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" />
|
||
<select class="cv-field cv-field-refsource" title="多参考图字段">
|
||
<option value="images">多参考图字段 images</option>
|
||
<option value="ref_assets">全能参考字段 ref_assets</option>
|
||
</select>
|
||
</details>
|
||
<button class="cv-run">运行</button>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>`;
|
||
}
|
||
if (kind === "grokVideo") {
|
||
return cvCompactVideoBody("grokVideo");
|
||
return `
|
||
<select class="cv-field cv-field-vmode" title="模式">
|
||
<option value="text">文生视频</option>
|
||
<option value="first">首帧图生视频</option>
|
||
<option value="reference">多参考图</option>
|
||
</select>
|
||
<textarea class="cv-field cv-field-prompt" rows="3" placeholder="Grok 视频提示词(可连接上游文本节点)"></textarea>
|
||
<div class="cv-vmat cv-vmat-first hidden">
|
||
<label class="cv-refimg-pick" title="上传首帧图">📎 首帧
|
||
<input type="file" class="cv-vmat-file" data-slot="first" accept="image/*" hidden />
|
||
</label>
|
||
<img class="cv-vmat-thumb hidden" data-slot="first" alt="首帧" style="max-width:44px;max-height:44px;border-radius:4px;object-fit:cover;vertical-align:middle" />
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="first" title="移除">✕</button>
|
||
<input class="cv-field cv-vmat-url" data-slot="first" placeholder="或手填公网图片URL;留空用上游图" />
|
||
</div>
|
||
<div class="cv-vmat cv-vmat-refs hidden">
|
||
<label class="cv-refimg-pick" title="批量上传参考图">📎 参考图
|
||
<input type="file" class="cv-vmat-file" data-slot="refs" accept="image/*" multiple hidden />
|
||
</label>
|
||
<button type="button" class="cv-vmat-clear hidden" data-slot="refs" title="清空">✕</button>
|
||
<div class="cv-vmat-refs-thumbs"></div>
|
||
<textarea class="cv-field cv-vmat-urls" data-slot="refs" rows="2" placeholder="可选:每行一个公网图片URL;会和上传图一起作为参考"></textarea>
|
||
</div>
|
||
<select class="cv-field cv-field-group" title="中转分组"></select>
|
||
<div class="cv-model-row">
|
||
<select class="cv-field cv-field-model" title="CLMM 模型"><option value="">先拉取模型</option></select>
|
||
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
|
||
</div>
|
||
<div class="cv-row2">
|
||
<select class="cv-field cv-field-ratio" title="视频格式">
|
||
<option value="16:9">16:9 横屏</option>
|
||
<option value="9:16">9:16 竖屏</option>
|
||
<option value="4:3">4:3 横屏</option>
|
||
<option value="3:4">3:4 竖屏</option>
|
||
<option value="2:3">2:3 竖屏</option>
|
||
<option value="3:2">3:2 横屏</option>
|
||
<option value="1:1">1:1 方形</option>
|
||
</select>
|
||
<select class="cv-field cv-field-vsize" title="分辨率">
|
||
<option value="720p">720p</option>
|
||
<option value="1080p">1080p</option>
|
||
<option value="480p">480p</option>
|
||
</select>
|
||
</div>
|
||
<div class="cv-row2">
|
||
<select class="cv-field cv-field-duration" title="时长">
|
||
<option value="6">6s</option>
|
||
<option value="8">8s</option>
|
||
<option value="5">5s</option>
|
||
<option value="10">10s</option>
|
||
<option value="15">15s</option>
|
||
</select>
|
||
</div>
|
||
<div class="cv-node-hint cv-grok-hint">通过 CLMM 聚合接口提交,模型请使用拉取到的完整名称</div>
|
||
<details class="cv-adv"><summary>⚙️ 高级</summary>
|
||
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" />
|
||
</details>
|
||
<button class="cv-run">运行</button>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>`;
|
||
}
|
||
if (kind === "tts") {
|
||
return cvCompactTtsBody();
|
||
return `
|
||
<textarea class="cv-field cv-field-prompt" rows="3" placeholder="要配音的文本(可留空,取上游文本节点)"></textarea>
|
||
<select class="cv-field cv-field-group" title="中转分组"></select>
|
||
<div class="cv-model-row">
|
||
<input class="cv-field cv-field-model" placeholder="填 TTS 模型,如 tts-1(可留空)" />
|
||
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
|
||
</div>
|
||
<datalist class="cv-node-datalist"></datalist>
|
||
<div class="cv-row2">
|
||
<select class="cv-field cv-field-voice" title="音色">
|
||
<option value="alloy">alloy</option>
|
||
<option value="nova">nova</option>
|
||
<option value="shimmer">shimmer</option>
|
||
<option value="echo">echo</option>
|
||
<option value="fable">fable</option>
|
||
<option value="onyx">onyx</option>
|
||
</select>
|
||
<select class="cv-field cv-field-format" title="格式">
|
||
<option value="mp3">mp3</option>
|
||
<option value="wav">wav</option>
|
||
<option value="opus">opus</option>
|
||
</select>
|
||
</div>
|
||
<button class="cv-run">运行</button>
|
||
<div class="cv-node-err hidden"></div>
|
||
<div class="cv-node-preview hidden"></div>`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
// 整个节点的 HTML(外层卡片 + 头部标题 + 主体)
|
||
function cvNodeHTML(kind) {
|
||
const m = CV_NODES[kind];
|
||
const tag = m.tag ? `<span class="cv-node-tag">${m.tag}</span>` : "";
|
||
return `
|
||
<div class="cv-node cv-node-${kind}">
|
||
<div class="cv-node-head">
|
||
<span class="cv-node-ico">${m.icon}</span>
|
||
<span class="cv-node-name">${m.title}</span>${tag}
|
||
</div>
|
||
<div class="cv-node-body">${cvNodeBody(kind)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function cvCloseConfig(compact) {
|
||
const box = compact || CANVAS.openConfigEl;
|
||
if (!box) return;
|
||
box.classList.remove("cv-config-open");
|
||
box.closest(".drawflow-node")?.classList.remove("cv-node-config-open");
|
||
if (CANVAS.openConfigEl === box) CANVAS.openConfigEl = null;
|
||
}
|
||
|
||
function cvOpenConfig(compact) {
|
||
if (!compact) return;
|
||
if (CANVAS.openConfigEl && CANVAS.openConfigEl !== compact) cvCloseConfig(CANVAS.openConfigEl);
|
||
compact.classList.add("cv-config-open");
|
||
compact.closest(".drawflow-node")?.classList.add("cv-node-config-open");
|
||
CANVAS.openConfigEl = compact;
|
||
}
|
||
|
||
function cvCloseOpenPop(except) {
|
||
const pop = CANVAS.openPopEl;
|
||
if (!pop || pop === except) return;
|
||
pop.open = false;
|
||
if (CANVAS.openPopEl === pop) CANVAS.openPopEl = null;
|
||
}
|
||
|
||
function cvMarkCanvasDragging(on) {
|
||
const el = document.getElementById("drawflow");
|
||
if (!el) return;
|
||
el.classList.toggle("cv-dragging", !!on);
|
||
}
|
||
|
||
// 懒初始化 Drawflow(幂等)
|
||
function cvInit() {
|
||
if (CANVAS.inited) return;
|
||
const el = document.getElementById("drawflow");
|
||
if (!el || typeof Drawflow === "undefined") return;
|
||
const editor = new Drawflow(el);
|
||
editor.reroute = false; // 关闭连线中转点:否则点连线是加点而非选中,导致无法删除连线
|
||
editor.curvature = 0.5;
|
||
editor.draggable_inputs = false; // 输入/文本域/下拉不触发节点拖拽,保证可正常输入
|
||
editor.start();
|
||
CANVAS.editor = editor;
|
||
CANVAS.inited = true;
|
||
|
||
// 连线删除:单击选中 → 双击该连线即删除;或选中后按 Delete/Backspace
|
||
let selConn = null;
|
||
editor.on("connectionSelected", (c) => { selConn = c; });
|
||
editor.on("connectionUnselected", () => { selConn = null; });
|
||
editor.on("connectionStart", () => el.classList.add("cv-linking"));
|
||
editor.on("connectionCancel", () => el.classList.remove("cv-linking"));
|
||
editor.on("connectionCreated", () => { el.classList.remove("cv-linking"); cvScheduleAutosave(); });
|
||
editor.on("connectionRemoved", () => cvScheduleAutosave());
|
||
editor.on("nodeCreated", () => cvScheduleAutosave());
|
||
editor.on("nodeMoved", () => cvScheduleAutosave());
|
||
editor.on("translate", () => cvScheduleAutosave());
|
||
editor.on("mouseUp", () => el.classList.remove("cv-linking"));
|
||
el.addEventListener("dblclick", (e) => {
|
||
if (!e.target.closest(".connection")) return;
|
||
if (selConn && CANVAS.editor) {
|
||
CANVAS.editor.removeSingleConnection(selConn.output_id, selConn.input_id, selConn.output_class, selConn.input_class);
|
||
selConn = null;
|
||
}
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if ((e.key === "Delete" || e.key === "Backspace") && selConn && CANVAS.editor) {
|
||
const t = e.target;
|
||
// 避免在输入框里按退格误删连线
|
||
if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
|
||
CANVAS.editor.removeSingleConnection(selConn.output_id, selConn.input_id, selConn.output_class, selConn.input_class);
|
||
selConn = null;
|
||
}
|
||
});
|
||
|
||
// 节点删除时清理产物缓存 + 流式映射
|
||
editor.on("nodeRemoved", (id) => {
|
||
if (CANVAS.openConfigEl?.closest(".drawflow-node")?.id === `node-${id}`) CANVAS.openConfigEl = null;
|
||
if (CANVAS.openPopEl?.closest(".drawflow-node")?.id === `node-${id}`) CANVAS.openPopEl = null;
|
||
delete CANVAS.outputs[id];
|
||
delete CANVAS.refImages[id];
|
||
delete CANVAS.videoMaterials[id];
|
||
delete CANVAS.outputRefs[id];
|
||
cvClearRunMapForNode(id);
|
||
cvUpdateEmptyHint();
|
||
cvScheduleAutosave();
|
||
});
|
||
|
||
// 点击空白画布直接进入沉浸式全屏;点节点、连线、工具条则不触发
|
||
const panel = document.getElementById("tab-canvas");
|
||
const stage = panel ? panel.querySelector(".cv-stage") : null;
|
||
if (stage && !stage.dataset.fullscreenBound) {
|
||
stage.dataset.fullscreenBound = "1";
|
||
let blankDown = null;
|
||
stage.addEventListener("pointerdown", (e) => {
|
||
blankDown = { x: e.clientX, y: e.clientY };
|
||
});
|
||
stage.addEventListener("click", (e) => {
|
||
if (panel && panel.classList.contains("cv-fullscreen")) return;
|
||
if (!cvIsBlankCanvasClick(e)) return;
|
||
if (blankDown) {
|
||
const dx = e.clientX - blankDown.x;
|
||
const dy = e.clientY - blankDown.y;
|
||
if ((dx * dx + dy * dy) > 36) return;
|
||
}
|
||
cvToggleFull(true);
|
||
});
|
||
stage.addEventListener("dblclick", (e) => {
|
||
if (!cvIsBlankCanvasClick(e)) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
cvOpenQuickMenu(e.clientX, e.clientY);
|
||
});
|
||
const quickMenu = document.getElementById("cv-quick-menu");
|
||
if (quickMenu && !quickMenu.dataset.bound) {
|
||
quickMenu.dataset.bound = "1";
|
||
quickMenu.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-cv-quick-add]");
|
||
if (!btn) return;
|
||
cvAddNode(btn.dataset.cvQuickAdd, CANVAS.quickAddPos || undefined);
|
||
cvCloseQuickMenu();
|
||
});
|
||
}
|
||
el.addEventListener("pointerdown", (e) => {
|
||
const nodeEl = e.target.closest(".drawflow-node");
|
||
if (!nodeEl || e.button !== 0) {
|
||
CANVAS.dragStart = null;
|
||
return;
|
||
}
|
||
clearTimeout(CANVAS.dragEndTimer);
|
||
CANVAS.dragStart = { x: e.clientX, y: e.clientY, moved: false };
|
||
CANVAS.dragSuppressClick = false;
|
||
}, true);
|
||
el.addEventListener("pointermove", (e) => {
|
||
const start = CANVAS.dragStart;
|
||
if (!start) return;
|
||
const dx = e.clientX - start.x;
|
||
const dy = e.clientY - start.y;
|
||
if (!start.moved && (dx * dx + dy * dy) > 16) {
|
||
start.moved = true;
|
||
CANVAS.dragSuppressClick = true;
|
||
cvMarkCanvasDragging(true);
|
||
}
|
||
}, true);
|
||
document.addEventListener("pointerup", () => {
|
||
if (!CANVAS.dragStart) return;
|
||
CANVAS.dragStart = null;
|
||
clearTimeout(CANVAS.dragEndTimer);
|
||
CANVAS.dragEndTimer = setTimeout(() => {
|
||
CANVAS.dragSuppressClick = false;
|
||
cvMarkCanvasDragging(false);
|
||
}, 80);
|
||
}, true);
|
||
panel.addEventListener("click", (e) => {
|
||
if (!CANVAS.editor || !panel.classList.contains("active")) return;
|
||
const menu = document.getElementById("cv-quick-menu");
|
||
if (menu && !menu.classList.contains("hidden") && !e.target.closest("#cv-quick-menu")) cvCloseQuickMenu();
|
||
|
||
const compact = e.target.closest("#drawflow .cv-video-compact");
|
||
if (!e.target.closest(".cv-video-pop")) cvCloseOpenPop();
|
||
if (!compact) {
|
||
if (!e.target.closest("#cv-quick-menu, .cv-toolbar, .cv-zoom, .cv-tool-rail")) cvCloseConfig();
|
||
return;
|
||
}
|
||
if (CANVAS.openConfigEl && CANVAS.openConfigEl !== compact) cvCloseConfig(CANVAS.openConfigEl);
|
||
if (CANVAS.dragSuppressClick) return;
|
||
if (e.target.closest(".cv-video-composer, .cv-video-pop, .cv-preview-actions, button, input, textarea, select, label")) return;
|
||
cvOpenConfig(compact);
|
||
});
|
||
panel.addEventListener("toggle", (e) => {
|
||
const pop = e.target;
|
||
if (!pop || !pop.classList || !pop.classList.contains("cv-video-pop")) return;
|
||
if (pop.open) {
|
||
cvCloseOpenPop(pop);
|
||
CANVAS.openPopEl = pop;
|
||
} else if (CANVAS.openPopEl === pop) {
|
||
CANVAS.openPopEl = null;
|
||
}
|
||
const compact = pop.closest(".cv-video-compact");
|
||
if (compact) compact.classList.toggle("cv-pop-open", !!pop.open);
|
||
}, true);
|
||
panel.addEventListener("input", (e) => {
|
||
if (e.target.closest("#drawflow .cv-field")) cvScheduleAutosave();
|
||
});
|
||
panel.addEventListener("change", (e) => {
|
||
if (e.target.closest("#drawflow .cv-field")) cvScheduleAutosave();
|
||
});
|
||
}
|
||
|
||
// 事件委托:点击节点内「运行」/「拉取模型」按钮
|
||
el.addEventListener("click", (e) => {
|
||
const popClose = e.target.closest(".cv-pop-close");
|
||
if (popClose) {
|
||
const box = popClose.closest("details");
|
||
if (box) box.open = false;
|
||
const compact = popClose.closest(".cv-video-compact");
|
||
if (compact) {
|
||
compact.classList.remove("cv-pop-open");
|
||
cvCloseConfig(compact);
|
||
}
|
||
return;
|
||
}
|
||
const runBtn = e.target.closest(".cv-run");
|
||
if (runBtn) {
|
||
const nodeEl = runBtn.closest(".drawflow-node");
|
||
if (nodeEl) cvRunNode(nodeEl.id.replace("node-", ""), runBtn).catch(() => {});
|
||
return;
|
||
}
|
||
const openPreviewBtn = e.target.closest(".cv-preview-open");
|
||
if (openPreviewBtn) {
|
||
cvOpenPreview(openPreviewBtn.closest(".cv-node-preview, .cv-video-frame-inner"));
|
||
return;
|
||
}
|
||
const downloadPreviewBtn = e.target.closest(".cv-preview-download");
|
||
if (downloadPreviewBtn) {
|
||
cvDownloadPreview(downloadPreviewBtn.closest(".cv-node-preview, .cv-video-frame-inner"));
|
||
return;
|
||
}
|
||
const fetchBtn = e.target.closest(".cv-fetch-models");
|
||
if (fetchBtn) {
|
||
const nodeEl = fetchBtn.closest(".drawflow-node");
|
||
if (nodeEl) cvFetchNodeModels(nodeEl.id.replace("node-", ""), fetchBtn);
|
||
return;
|
||
}
|
||
const clrBtn = e.target.closest(".cv-refimg-clear");
|
||
if (clrBtn) {
|
||
const nodeEl = clrBtn.closest(".drawflow-node");
|
||
if (nodeEl) cvClearRefImage(nodeEl);
|
||
return;
|
||
}
|
||
const vmatClr = e.target.closest(".cv-vmat-clear");
|
||
if (vmatClr) {
|
||
const nodeEl = vmatClr.closest(".drawflow-node");
|
||
if (nodeEl) cvClearVideoMat(nodeEl, vmatClr.dataset.slot);
|
||
}
|
||
});
|
||
|
||
// 事件委托:切换节点分组下拉 → 联动模型框/清空旧候选;切换视频模式 → 显隐图片URL
|
||
el.addEventListener("change", (e) => {
|
||
const grpSel = e.target.closest(".cv-field-group");
|
||
if (grpSel) {
|
||
const nodeEl = grpSel.closest(".drawflow-node");
|
||
if (nodeEl) cvOnGroupChange(nodeEl, grpSel.value);
|
||
return;
|
||
}
|
||
const vmodeSel = e.target.closest(".cv-field-vmode");
|
||
if (vmodeSel) {
|
||
const nodeEl = vmodeSel.closest(".drawflow-node");
|
||
if (nodeEl) cvSyncVideoMode(nodeEl);
|
||
return;
|
||
}
|
||
const refInput = e.target.closest(".cv-field-refimg");
|
||
if (refInput) {
|
||
const nodeEl = refInput.closest(".drawflow-node");
|
||
if (nodeEl) cvOnRefImagePicked(nodeEl, refInput.files && refInput.files[0]);
|
||
return;
|
||
}
|
||
const vmatFile = e.target.closest(".cv-vmat-file");
|
||
if (vmatFile) {
|
||
const nodeEl = vmatFile.closest(".drawflow-node");
|
||
if (nodeEl) cvOnVideoMatPicked(nodeEl, vmatFile.dataset.slot, vmatFile.files);
|
||
}
|
||
});
|
||
el.addEventListener("change", (e) => {
|
||
const nodeEl = e.target.closest(".drawflow-node");
|
||
if (nodeEl && e.target.closest(".cv-video-pop")) cvUpdateParamSummary(nodeEl);
|
||
});
|
||
|
||
// 缩放变化(按钮 / Ctrl+滚轮)→ 同步右下角百分比
|
||
editor.on("zoom", cvUpdateZoomLabel);
|
||
window.addEventListener("resize", cvRefreshAllNodePorts);
|
||
requestAnimationFrame(cvRefreshAllNodePorts);
|
||
|
||
// 普通鼠标滚轮直接缩放(无需 Ctrl);Ctrl+滚轮也统一走这里,避免原生步进越界。
|
||
// 用 rAF 把「一次滚动的多次事件」合并成每帧一次,避免一帧内多写 transform / 多次 dispatch。
|
||
let wheelAccum = 0;
|
||
let wheelRAF = 0;
|
||
el.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
if (!CANVAS.editor) return;
|
||
wheelAccum += e.deltaY;
|
||
if (wheelRAF) return; // 一帧内只处理一次
|
||
wheelRAF = requestAnimationFrame(() => {
|
||
wheelRAF = 0;
|
||
const STEP = 100;
|
||
const steps = Math.trunc(wheelAccum / STEP);
|
||
wheelAccum -= steps * STEP;
|
||
if (steps) cvSetZoomPercent(cvZoomPercent() - steps * 5);
|
||
});
|
||
},
|
||
{ passive: false, capture: true }
|
||
);
|
||
|
||
cvUpdateEmptyHint();
|
||
}
|
||
|
||
// 刷新右下角缩放百分比显示
|
||
function cvUpdateZoomLabel() {
|
||
cvSyncZoomControls();
|
||
}
|
||
|
||
function cvIsBlankCanvasClick(e) {
|
||
const t = e.target;
|
||
if (!t || !t.closest) return false;
|
||
if (
|
||
t.closest(
|
||
".drawflow-node, .parent-node, .input, .output, .cv-zoom, .cv-mask, .cv-toolbar, .cv-tool-rail, .cv-quick-menu, .cv-empty-hint, .modal, .connection, .point, .cv-field, .cv-preview-shell, .cv-node-preview, .cv-adv, .cv-refimg-pick, .cv-vmat, .drawflow-delete"
|
||
)
|
||
) return false;
|
||
return !!t.closest(".cv-stage");
|
||
}
|
||
|
||
// 图片节点选了参考图(图生图):读成 dataURL 存缓存 + 显示缩略图
|
||
function cvOnRefImagePicked(nodeEl, file) {
|
||
if (!file) return;
|
||
const id = nodeEl.id.replace("node-", "");
|
||
const workspaceContext = { workspaceId: CANVAS.workspaceId, workspaceState: cvWorkspaceSnapshot() };
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
CANVAS.refImages[id] = reader.result; // dataURL
|
||
const thumb = nodeEl.querySelector(".cv-refimg-thumb");
|
||
if (thumb) {
|
||
thumb.src = reader.result;
|
||
thumb.classList.remove("hidden");
|
||
}
|
||
const clr = nodeEl.querySelector(".cv-refimg-clear");
|
||
if (clr) clr.classList.remove("hidden");
|
||
const frame = nodeEl.querySelector(".cv-image-frame .cv-video-frame-inner");
|
||
if (frame) {
|
||
const foot = '<div class="cv-meta">参考图 · 上传</div>';
|
||
frame.innerHTML = cvPreviewChrome("image", reader.result, "", foot);
|
||
}
|
||
cvCacheMaterial(id, "image", reader.result, workspaceContext);
|
||
cvScheduleAutosave();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
function cvResetFramePlaceholder(nodeEl, kind, text) {
|
||
if (!nodeEl) return;
|
||
const frame = nodeEl.querySelector(".cv-video-frame-inner");
|
||
if (!frame) return;
|
||
const symbol = text || (kind === "image" ? "▧" : kind === "tts" ? "♪" : "▣");
|
||
frame.innerHTML = symbol;
|
||
}
|
||
|
||
// 清除图片节点参考图
|
||
function cvClearRefImage(nodeEl) {
|
||
const id = nodeEl.id.replace("node-", "");
|
||
delete CANVAS.refImages[id];
|
||
if (CANVAS.materialRefs[id]) delete CANVAS.materialRefs[id].image;
|
||
const thumb = nodeEl.querySelector(".cv-refimg-thumb");
|
||
if (thumb) {
|
||
thumb.src = "";
|
||
thumb.classList.add("hidden");
|
||
}
|
||
const clr = nodeEl.querySelector(".cv-refimg-clear");
|
||
if (clr) clr.classList.add("hidden");
|
||
const inp = nodeEl.querySelector(".cv-field-refimg");
|
||
if (inp) inp.value = "";
|
||
cvResetFramePlaceholder(nodeEl, "image");
|
||
cvScheduleAutosave();
|
||
}
|
||
|
||
// 视频节点选了素材(首帧/尾帧/多参考图):读成 dataURL 存缓存 + 显示缩略图
|
||
function cvOnVideoMatPicked(nodeEl, slot, fileList) {
|
||
const files = Array.from(fileList || []);
|
||
if (!files.length) return;
|
||
const id = nodeEl.id.replace("node-", "");
|
||
const workspaceContext = { workspaceId: CANVAS.workspaceId, workspaceState: cvWorkspaceSnapshot() };
|
||
const store = (CANVAS.videoMaterials[id] = CANVAS.videoMaterials[id] || { refs: [] });
|
||
if (slot === "refs") {
|
||
store.refs = store.refs || [];
|
||
const box = nodeEl.querySelector(".cv-vmat-refs-thumbs");
|
||
files.forEach((f) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
store.refs.push(reader.result);
|
||
if (box) {
|
||
const im = document.createElement("img");
|
||
im.src = reader.result;
|
||
im.alt = "参考图";
|
||
im.style.cssText = "max-width:40px;max-height:40px;border-radius:4px;object-fit:cover;margin:2px";
|
||
box.appendChild(im);
|
||
}
|
||
const clr = nodeEl.querySelector('.cv-vmat-clear[data-slot="refs"]');
|
||
if (clr) clr.classList.remove("hidden");
|
||
cvCacheMaterial(id, "refs", reader.result, workspaceContext);
|
||
cvScheduleAutosave();
|
||
};
|
||
reader.readAsDataURL(f);
|
||
});
|
||
return;
|
||
}
|
||
// first / last:单张
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
store[slot] = reader.result;
|
||
const thumb = nodeEl.querySelector(`.cv-vmat-thumb[data-slot="${slot}"]`);
|
||
if (thumb) {
|
||
thumb.src = reader.result;
|
||
thumb.classList.remove("hidden");
|
||
}
|
||
const clr = nodeEl.querySelector(`.cv-vmat-clear[data-slot="${slot}"]`);
|
||
if (clr) clr.classList.remove("hidden");
|
||
if (slot === "first" && nodeEl.querySelector(".cv-video-frame-inner")) {
|
||
const frame = nodeEl.querySelector(".cv-video-frame-inner");
|
||
const foot = '<div class="cv-meta">首帧 · 上传</div>';
|
||
frame.innerHTML = cvPreviewChrome("image", reader.result, "", foot);
|
||
}
|
||
cvCacheMaterial(id, slot, reader.result, workspaceContext);
|
||
cvScheduleAutosave();
|
||
};
|
||
reader.readAsDataURL(files[0]);
|
||
}
|
||
|
||
// 清除视频节点某槽位素材
|
||
function cvClearVideoMat(nodeEl, slot) {
|
||
const id = nodeEl.id.replace("node-", "");
|
||
const store = CANVAS.videoMaterials[id];
|
||
const refs = CANVAS.materialRefs[id];
|
||
if (store) {
|
||
if (slot === "refs") store.refs = [];
|
||
else delete store[slot];
|
||
}
|
||
if (refs) {
|
||
if (slot === "refs") refs.refs = [];
|
||
else delete refs[slot];
|
||
}
|
||
if (slot === "refs") {
|
||
const box = nodeEl.querySelector(".cv-vmat-refs-thumbs");
|
||
if (box) box.innerHTML = "";
|
||
const urls = nodeEl.querySelector('.cv-vmat-urls[data-slot="refs"]');
|
||
if (urls) urls.value = "";
|
||
} else {
|
||
const thumb = nodeEl.querySelector(`.cv-vmat-thumb[data-slot="${slot}"]`);
|
||
if (thumb) {
|
||
thumb.src = "";
|
||
thumb.classList.add("hidden");
|
||
}
|
||
}
|
||
const clr = nodeEl.querySelector(`.cv-vmat-clear[data-slot="${slot}"]`);
|
||
if (clr) clr.classList.add("hidden");
|
||
const inp = nodeEl.querySelector(`.cv-vmat-file[data-slot="${slot}"]`);
|
||
if (inp) inp.value = "";
|
||
if (slot === "first") cvResetFramePlaceholder(nodeEl, "video");
|
||
cvScheduleAutosave();
|
||
}
|
||
|
||
function cvIsSeedanceV2Group(g) {
|
||
if (!g) return false;
|
||
if (g.seedanceV2) return true;
|
||
const text = `${g.name || ""} ${g.model || ""}`.toLowerCase();
|
||
return /seedance[\s_-]*v\s*2|seedancev2|seedance_v2/.test(text) || cvIsSeedanceV2Model(g.model);
|
||
}
|
||
|
||
function cvIsSeedanceV2Model(model) {
|
||
return /^(?:bh2\.0-|sd2\.0-|sdvip|gz-sd)/i.test(String(model || "").trim());
|
||
}
|
||
|
||
function cvIsSeedanceGroup(g) {
|
||
if (!g) return false;
|
||
if (g.seedance) return true;
|
||
return /seedance/i.test(`${g.name || ""} ${g.model || ""}`);
|
||
}
|
||
|
||
function cvIsGrokGroup(g) {
|
||
if (!g) return false;
|
||
if (g.grok) return true;
|
||
return /grok/i.test(`${g.name || ""} ${g.model || ""}`) || /grok-imagine-video/i.test(g.model || "");
|
||
}
|
||
|
||
function cvGroupChannelTag(g, nodeKind) {
|
||
if (!g) return "";
|
||
if (cvIsSeedanceV2Group(g)) return "Seedance V2";
|
||
if (cvIsGrokGroup(g)) return "Grok";
|
||
if (cvIsSeedanceGroup(g)) return "Seedance";
|
||
if (g.codexVideo && nodeKind === "video") return "视频";
|
||
if (g.codexImage && nodeKind === "image") return "生图";
|
||
return "";
|
||
}
|
||
|
||
function cvSortGroupsForNode(groups, nodeKind) {
|
||
const score = (g) => {
|
||
let s = 0;
|
||
if (nodeKind === "video") {
|
||
if (g.codexVideo) s += 100;
|
||
if (cvIsSeedanceV2Group(g)) s += 90;
|
||
if (cvIsSeedanceGroup(g)) s += 70;
|
||
if (cvIsGrokGroup(g)) s += 40;
|
||
} else if (nodeKind === "grokVideo") {
|
||
if (cvIsGrokGroup(g)) s += 100;
|
||
if (g.codexVideo) s += 50;
|
||
} else if (nodeKind === "image") {
|
||
if (g.codexImage) s += 100;
|
||
}
|
||
return s;
|
||
};
|
||
return groups.slice().sort((a, b) => score(b) - score(a));
|
||
}
|
||
|
||
// 用 CANVAS.groups 填充一个分组下拉,默认选中 activeGroupId(保留已有选择)
|
||
// Seedance 视频节点会优先展示 / 默认选中 Seedance V2 分组,方便直接生成。
|
||
function cvFillGroupSelect(sel) {
|
||
if (!sel) return;
|
||
const prev = sel.value;
|
||
const groups = CANVAS.groups || [];
|
||
if (!groups.length) {
|
||
sel.innerHTML = `<option value="">请先在『路由中转』添加分组</option>`;
|
||
return;
|
||
}
|
||
const nodeEl = sel.closest(".drawflow-node");
|
||
const isImage = !!(nodeEl && nodeEl.querySelector(".cv-node-image"));
|
||
const isGrokVideo = !!(nodeEl && nodeEl.querySelector(".cv-node-grokVideo"));
|
||
const isSeedanceVideo = !!(nodeEl && nodeEl.querySelector(".cv-node-video") && !isGrokVideo);
|
||
const isVideo = isSeedanceVideo || isGrokVideo;
|
||
const nodeKind = isGrokVideo ? "grokVideo" : isSeedanceVideo ? "video" : isImage ? "image" : "";
|
||
const ordered = cvSortGroupsForNode(groups, nodeKind);
|
||
sel.innerHTML = ordered
|
||
.map((g) => {
|
||
const tag = cvGroupChannelTag(g, nodeKind);
|
||
const label = esc(g.name)
|
||
+ (tag ? ` · ${esc(tag)}` : "");
|
||
return `<option value="${esc(g.id)}">${label}</option>`;
|
||
})
|
||
.join("");
|
||
const keep = prev && groups.some((g) => String(g.id) === prev);
|
||
const imageGroup = isImage ? groups.find((g) => g.codexImage) : null;
|
||
const videoDefault = isSeedanceVideo
|
||
? (
|
||
groups.find((g) => g.codexVideo && cvIsSeedanceV2Group(g))
|
||
|| groups.find((g) => cvIsSeedanceV2Group(g))
|
||
|| groups.find((g) => g.codexVideo && cvIsSeedanceGroup(g))
|
||
|| groups.find((g) => g.codexVideo)
|
||
|| groups.find((g) => cvIsSeedanceGroup(g))
|
||
)
|
||
: null;
|
||
const grokGroup = isGrokVideo
|
||
? (groups.find((g) => cvIsGrokGroup(g)) || groups.find((g) => g.codexVideo))
|
||
: null;
|
||
const want = keep
|
||
? prev
|
||
: String((videoDefault && videoDefault.id) || (imageGroup && imageGroup.id) || (grokGroup && grokGroup.id) || CANVAS.activeGroupId || "");
|
||
if (want) sel.value = want;
|
||
cvApplyGroupChannelHints(nodeEl, want);
|
||
}
|
||
|
||
function cvSetNodeModelOptions(nodeEl, models) {
|
||
if (!nodeEl) return;
|
||
const model = nodeEl.querySelector(".cv-field-model");
|
||
const list = Array.isArray(models) ? models.filter(Boolean) : [];
|
||
if (model && model.tagName === "SELECT") {
|
||
const current = model.value.trim();
|
||
const options = ['<option value="">请选择模型</option>']
|
||
.concat(list.map((item) => `<option value="${esc(item)}">${esc(item)}</option>`));
|
||
if (current && !list.includes(current)) {
|
||
options.splice(1, 0, `<option value="${esc(current)}">${esc(current)}(当前)</option>`);
|
||
}
|
||
model.innerHTML = options.join("");
|
||
if (current) model.value = current;
|
||
return;
|
||
}
|
||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||
if (dl) dl.innerHTML = list.map((item) => `<option value="${esc(item)}"></option>`).join("");
|
||
}
|
||
|
||
// 节点创建后统一初始化:视频模型迁移为真正的下拉框,其它节点继续使用 datalist。
|
||
function cvSetupNode(id) {
|
||
const nodeEl = document.getElementById(`node-${id}`);
|
||
if (!nodeEl) return;
|
||
let model = nodeEl.querySelector(".cv-field-model");
|
||
const isVideo = !!nodeEl.querySelector(".cv-node-video, .cv-node-grokVideo");
|
||
if (isVideo && model && model.tagName !== "SELECT") {
|
||
const current = model.value.trim();
|
||
const replacement = document.createElement("select");
|
||
replacement.className = model.className;
|
||
replacement.title = "CLMM 模型";
|
||
replacement.innerHTML = '<option value="">请选择模型</option>' + (current
|
||
? `<option value="${esc(current)}">${esc(current)}(当前)</option>`
|
||
: "");
|
||
model.replaceWith(replacement);
|
||
model = replacement;
|
||
}
|
||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||
if (dl && model && model.tagName !== "SELECT") {
|
||
const listId = `cv-models-node-${id}`;
|
||
dl.id = listId;
|
||
model.setAttribute("list", listId);
|
||
}
|
||
cvFillGroupSelect(nodeEl.querySelector(".cv-field-group"));
|
||
// 视频节点:按当前模式初始化素材控件显隐
|
||
if (nodeEl.querySelector(".cv-field-vmode")) cvSyncVideoMode(nodeEl);
|
||
cvUpdateParamSummary(nodeEl);
|
||
cvObserveNodePortFrame(nodeEl);
|
||
requestAnimationFrame(() => cvAlignNodePorts(nodeEl));
|
||
}
|
||
|
||
function cvSyncImagePreviewRatio(nodeEl) {
|
||
const frame = nodeEl && nodeEl.querySelector(".cv-image-frame");
|
||
if (!frame) return;
|
||
const ratio = (nodeEl.querySelector(".cv-field-ratio")?.value || "1:1").trim();
|
||
const parts = ratio.split(":").map((n) => parseFloat(n));
|
||
const w = Number.isFinite(parts[0]) && parts[0] > 0 ? parts[0] : 1;
|
||
const h = Number.isFinite(parts[1]) && parts[1] > 0 ? parts[1] : 1;
|
||
frame.dataset.ratio = ratio;
|
||
frame.style.setProperty("--cv-image-preview-aspect", `${w} / ${h}`);
|
||
cvRefreshNodePorts(nodeEl);
|
||
}
|
||
|
||
function cvUpdateParamSummary(nodeEl) {
|
||
if (!nodeEl) return;
|
||
const sum = nodeEl.querySelector(".cv-video-pop > summary");
|
||
if (!sum) return;
|
||
const val = (sel) => {
|
||
const el = nodeEl.querySelector(sel);
|
||
return el ? el.value.trim() : "";
|
||
};
|
||
if (nodeEl.querySelector(".cv-image-compact")) {
|
||
cvSyncImagePreviewRatio(nodeEl);
|
||
sum.textContent = [val(".cv-field-ratio") || "1:1", val(".cv-field-quality") || "1K"].join(" · ");
|
||
return;
|
||
}
|
||
if (nodeEl.querySelector(".cv-tts-compact")) {
|
||
sum.textContent = [val(".cv-field-voice") || "alloy", val(".cv-field-format") || "mp3"].join(" · ");
|
||
return;
|
||
}
|
||
const modeMap = { text: "文生", first: "首帧", firstlast: "双图", reference: "参考" };
|
||
const mode = modeMap[val(".cv-field-vmode")] || "文生";
|
||
const resolutionMap = { "2k": "2K", "4k": "4K" };
|
||
const resolution = val(".cv-field-vsize") || "720p";
|
||
sum.textContent = [mode, val(".cv-field-ratio") || "16:9", resolutionMap[resolution.toLowerCase()] || resolution, val(".cv-field-duration") || "5s"].join(" · ");
|
||
}
|
||
|
||
// 按所选分组切换 Seedance / Seedance V2 的默认接口与提示。
|
||
function cvApplyGroupChannelHints(nodeEl, groupId) {
|
||
if (!nodeEl) return;
|
||
const isSeedanceVideo = !!(nodeEl.querySelector(".cv-node-video") && !nodeEl.querySelector(".cv-node-grokVideo"));
|
||
if (!isSeedanceVideo) return;
|
||
const g = (CANVAS.groups || []).find((item) => String(item.id) === String(groupId || ""));
|
||
const endpoint = nodeEl.querySelector(".cv-field-endpoint");
|
||
const hint = nodeEl.querySelector(".cv-node-hint");
|
||
const title = nodeEl.querySelector(".cv-video-label");
|
||
const isV2 = cvIsSeedanceV2Group(g);
|
||
if (endpoint) {
|
||
const cur = String(endpoint.value || "").trim();
|
||
const v1Path = "/v1/videos";
|
||
const v2Path = "/v1/video/generations";
|
||
// 仅在空或旧默认路径时自动切换,避免覆盖用户自定义
|
||
if (!cur || cur === v1Path || cur === v2Path || /\/v1\/videos(?:\/generations)?$/i.test(cur) || /\/v1\/video\/generations$/i.test(cur)) {
|
||
endpoint.value = isV2 ? v2Path : "";
|
||
}
|
||
endpoint.placeholder = isV2 ? v2Path : v1Path;
|
||
endpoint.title = isV2 ? "Seedance V2 原生接口" : "CLMM / 旧 Seedance 接口";
|
||
}
|
||
if (hint) {
|
||
hint.textContent = isV2
|
||
? "Seedance V2(Binghuo):模型请拉取 V2 分组完整名称,默认 /v1/video/generations"
|
||
: "Seedance / CLMM:模型名、时长和参考图按上游能力校验,默认 /v1/videos";
|
||
}
|
||
if (title) {
|
||
title.innerHTML = isV2 ? "<span>▦</span>Seedance V2" : "<span>▦</span>视频";
|
||
}
|
||
}
|
||
|
||
// 分组下拉变化:清空旧模型候选,并按 Seedance V1/V2 切换默认接口。
|
||
function cvOnGroupChange(nodeEl, groupId) {
|
||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||
if (dl) dl.innerHTML = "";
|
||
const model = nodeEl.querySelector(".cv-field-model");
|
||
if (model && model.tagName === "SELECT") model.innerHTML = '<option value="">请点击“拉取”选择模型</option>';
|
||
cvApplyGroupChannelHints(nodeEl, groupId);
|
||
}
|
||
|
||
// 拉取某节点所选分组的模型 → 填充完整模型下拉/候选列表
|
||
async function cvFetchNodeModels(nodeId, btn) {
|
||
const nodeEl = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeEl) return;
|
||
const sel = nodeEl.querySelector(".cv-field-group");
|
||
const groupId = sel ? sel.value : "";
|
||
if (!groupId) {
|
||
toast("请先选择中转分组(或在『路由中转』添加分组)", "");
|
||
return;
|
||
}
|
||
busy(btn, true, "…");
|
||
try {
|
||
if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)");
|
||
const r = await apic.canvasModels(groupId);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
|
||
const models = Array.isArray(r.models) ? r.models : [];
|
||
cvSetNodeModelOptions(nodeEl, models);
|
||
toast(`已拉取 ${models.length} 个模型,请从下拉框手动选择`, "ok");
|
||
} catch (e) {
|
||
toast("拉取模型失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
// 加载中转分组并缓存;完成后回填所有已存在节点的分组下拉
|
||
async function cvLoadGroups() {
|
||
try {
|
||
if (!apic.canvasGroups) throw new Error("no api");
|
||
const r = await apic.canvasGroups();
|
||
if (r && r.ok) {
|
||
CANVAS.groups = Array.isArray(r.groups) ? r.groups : [];
|
||
CANVAS.activeGroupId = r.activeId || (CANVAS.groups[0] && CANVAS.groups[0].id) || "";
|
||
} else {
|
||
CANVAS.groups = [];
|
||
CANVAS.activeGroupId = "";
|
||
}
|
||
} catch (e) {
|
||
CANVAS.groups = [];
|
||
CANVAS.activeGroupId = "";
|
||
}
|
||
cvBackfillGroups();
|
||
}
|
||
|
||
// 回填画布内所有节点的分组下拉(节点可能先于分组数据创建)
|
||
function cvBackfillGroups() {
|
||
document.querySelectorAll("#drawflow .cv-field-group").forEach(cvFillGroupSelect);
|
||
}
|
||
|
||
// 当前画布全部节点(对象:{ [id]: nodeData })
|
||
function cvAllNodes() {
|
||
try {
|
||
const ed = CANVAS.editor;
|
||
return (ed.drawflow.drawflow[ed.module] || {}).data || {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// 给某节点的提示词/文本域预填内容(addNode 后 DOM 已同步存在)
|
||
function cvSetField(id, value) {
|
||
const el = document.querySelector(`#node-${id} .cv-field-prompt`);
|
||
if (el) el.value = value;
|
||
}
|
||
|
||
// 注入示例工作流:文本→文生图→图生视频,另一文本→TTS(仅在画布为空时)
|
||
function cvSeedSample() {
|
||
const ed = CANVAS.editor;
|
||
if (!ed) return;
|
||
if (Object.keys(cvAllNodes()).length > 0) return; // 已有节点则不重复注入
|
||
try {
|
||
// addNode 返回新节点 id;错开摆放避免重叠(节点宽 250px)
|
||
const t1 = ed.addNode("text", 0, 1, 40, 60, "cv-df-text", {}, cvNodeHTML("text"), false);
|
||
const img = ed.addNode("image", 1, 1, 370, 40, "cv-df-image", {}, cvNodeHTML("image"), false);
|
||
const vid = ed.addNode("video", 1, 0, 710, 40, "cv-df-video", {}, cvNodeHTML("video"), false);
|
||
const t2 = ed.addNode("text", 0, 1, 40, 420, "cv-df-text", {}, cvNodeHTML("text"), false);
|
||
const tts = ed.addNode("tts", 1, 0, 370, 400, "cv-df-tts", {}, cvNodeHTML("tts"), false);
|
||
cvSetField(t1, "一只戴宇航头盔的柴犬,赛博朋克霓虹风");
|
||
cvSetField(t2, "大家好,这是TD工具箱创意画布");
|
||
// 能力节点:唯一化 datalist + 回填分组下拉(默认选中 activeId)
|
||
[img, vid, tts].forEach(cvSetupNode);
|
||
cvRefreshAllNodePorts();
|
||
// 连线:output_1 → input_1
|
||
ed.addConnection(t1, img, "output_1", "input_1");
|
||
ed.addConnection(img, vid, "output_1", "input_1");
|
||
ed.addConnection(t2, tts, "output_1", "input_1");
|
||
cvRefreshAllNodePorts();
|
||
cvUpdateEmptyHint();
|
||
} catch (e) {
|
||
// 预置失败不影响画布可用
|
||
}
|
||
}
|
||
|
||
// 全屏尺寸变化后触发一次重绘(节点绝对坐标不变,仅刷新缩放/连线)
|
||
function cvReflow() {
|
||
try {
|
||
if (CANVAS.editor && typeof CANVAS.editor.zoom_refresh === "function") {
|
||
CANVAS.editor.zoom_refresh();
|
||
}
|
||
cvRefreshAllNodePorts();
|
||
} catch (e) {}
|
||
}
|
||
|
||
function cvZoomPercent() {
|
||
return Math.round((CANVAS.editor && CANVAS.editor.zoom ? CANVAS.editor.zoom : 1) * 100);
|
||
}
|
||
|
||
function cvSyncZoomControls(forceRange) {
|
||
const ed = CANVAS.editor;
|
||
const pct = cvZoomPercent();
|
||
const label = document.getElementById("cv-zoom-reset");
|
||
const range = document.getElementById("cv-zoom-range");
|
||
if (label) label.textContent = pct + "%";
|
||
if (range && ed) {
|
||
range.min = String(Math.round((ed.zoom_min || 0.5) * 100));
|
||
range.max = String(Math.round((ed.zoom_max || 1.6) * 100));
|
||
if (forceRange || document.activeElement !== range) range.value = String(pct);
|
||
}
|
||
}
|
||
|
||
function cvSetZoomPercent(pct) {
|
||
const ed = CANVAS.editor;
|
||
if (!ed) return;
|
||
const minPct = Math.round((ed.zoom_min || 0.5) * 100);
|
||
const maxPct = Math.round((ed.zoom_max || 1.6) * 100);
|
||
const next = Math.min(maxPct, Math.max(minPct, Math.round(Number(pct) || 100)));
|
||
ed.zoom = next / 100;
|
||
if (typeof ed.zoom_refresh === "function") ed.zoom_refresh();
|
||
cvSyncZoomControls(true);
|
||
cvRefreshAllNodePorts();
|
||
cvScheduleAutosave();
|
||
}
|
||
|
||
function cvRequestAutoFull() {
|
||
const panel = document.getElementById("tab-canvas");
|
||
if (!panel || panel.classList.contains("cv-fullscreen")) return;
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
if (panel.classList.contains("active") && !panel.classList.contains("cv-fullscreen")) {
|
||
cvToggleFull(true);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 切换沉浸式全屏(fixed 覆盖整个应用窗口,不新开窗口)
|
||
function cvToggleFull(force) {
|
||
const panel = document.getElementById("tab-canvas");
|
||
if (!panel) return;
|
||
const on = typeof force === "boolean"
|
||
? force
|
||
: !panel.classList.contains("cv-fullscreen");
|
||
panel.classList.toggle("cv-fullscreen", on);
|
||
document.body.classList.toggle("cv-noscroll", on);
|
||
const btn = document.getElementById("cv-full-btn");
|
||
if (btn) btn.textContent = on ? "✕ 退出全屏" : "⛶ 全屏";
|
||
requestAnimationFrame(() => requestAnimationFrame(cvReflow));
|
||
}
|
||
|
||
// 计算画布中心坐标(考虑当前缩放/平移),并错位摆放避免重叠
|
||
function cvCenterPos() {
|
||
const c = document.getElementById("drawflow");
|
||
const ed = CANVAS.editor;
|
||
const zoom = ed.zoom || 1;
|
||
const rect = c.getBoundingClientRect();
|
||
const stagger = (CANVAS.addCount++ % 6) * 34;
|
||
const x = (rect.width / 2 - (ed.canvas_x || 0)) / zoom - 120 + stagger;
|
||
const y = (rect.height / 2 - (ed.canvas_y || 0)) / zoom - 90 + stagger;
|
||
return { x: Math.max(20, x), y: Math.max(20, y) };
|
||
}
|
||
|
||
function cvCanvasPosFromPoint(clientX, clientY) {
|
||
const c = document.getElementById("drawflow");
|
||
const ed = CANVAS.editor;
|
||
if (!c || !ed) return cvCenterPos();
|
||
const rect = c.getBoundingClientRect();
|
||
const zoom = ed.zoom || 1;
|
||
const x = (clientX - rect.left - (ed.canvas_x || 0)) / zoom - 125;
|
||
const y = (clientY - rect.top - (ed.canvas_y || 0)) / zoom - 70;
|
||
return { x: Math.max(20, x), y: Math.max(20, y) };
|
||
}
|
||
|
||
function cvUpdateEmptyHint() {
|
||
const hint = document.getElementById("cv-empty-hint");
|
||
if (!hint) return;
|
||
hint.classList.toggle("hidden", Object.keys(cvAllNodes()).length > 0);
|
||
}
|
||
|
||
function cvOpenQuickMenu(clientX, clientY) {
|
||
const menu = document.getElementById("cv-quick-menu");
|
||
const stage = document.querySelector("#tab-canvas .cv-stage");
|
||
if (!menu || !stage) return;
|
||
CANVAS.quickAddPos = cvCanvasPosFromPoint(clientX, clientY);
|
||
const rect = stage.getBoundingClientRect();
|
||
const width = 240;
|
||
const height = 310;
|
||
const left = Math.min(Math.max(14, clientX - rect.left + 12), Math.max(14, rect.width - width - 14));
|
||
const top = Math.min(Math.max(14, clientY - rect.top + 12), Math.max(14, rect.height - height - 14));
|
||
menu.style.left = left + "px";
|
||
menu.style.top = top + "px";
|
||
menu.classList.remove("hidden");
|
||
}
|
||
|
||
function cvCloseQuickMenu() {
|
||
const menu = document.getElementById("cv-quick-menu");
|
||
if (menu) menu.classList.add("hidden");
|
||
}
|
||
|
||
// 在画布中心添加一个指定类型的节点
|
||
function cvAddNode(kind, customPos) {
|
||
const m = CV_NODES[kind];
|
||
if (!CANVAS.editor || !m) return;
|
||
const pos = customPos || cvCenterPos();
|
||
const id = CANVAS.editor.addNode(
|
||
kind, m.inputs, m.outputs, pos.x, pos.y,
|
||
`cv-df-${kind}`, {}, cvNodeHTML(kind), false
|
||
);
|
||
cvSetupNode(id); // 唯一化 datalist + 回填分组下拉
|
||
cvRefreshNodePorts(document.getElementById(`node-${id}`));
|
||
cvUpdateEmptyHint();
|
||
cvScheduleAutosave();
|
||
}
|
||
|
||
function cvPortAnchor(nodeEl) {
|
||
if (!nodeEl) return null;
|
||
return nodeEl.querySelector("[data-port-anchor]") || nodeEl.querySelector(".cv-video-frame") || nodeEl.querySelector(".cv-video-composer");
|
||
}
|
||
|
||
function cvAlignNodePorts(nodeEl) {
|
||
if (!nodeEl) return false;
|
||
const frame = cvPortAnchor(nodeEl);
|
||
if (!frame) return false;
|
||
const nodeRect = nodeEl.getBoundingClientRect();
|
||
const frameRect = frame.getBoundingClientRect();
|
||
if (!nodeRect.width || !frameRect.width) return false;
|
||
const zoom = (CANVAS.editor && CANVAS.editor.zoom) || 1;
|
||
const midY = (frameRect.top - nodeRect.top + frameRect.height / 2) / zoom;
|
||
const leftX = (frameRect.left - nodeRect.left) / zoom;
|
||
const rightX = (frameRect.right - nodeRect.left) / zoom;
|
||
const next = `${Math.round(midY)}:${Math.round(leftX)}:${Math.round(rightX)}`;
|
||
if (nodeEl.dataset.cvPortPos === next) return false;
|
||
nodeEl.dataset.cvPortPos = next;
|
||
nodeEl.style.setProperty("--cv-port-y", `${midY}px`);
|
||
nodeEl.style.setProperty("--cv-port-left", `${leftX}px`);
|
||
nodeEl.style.setProperty("--cv-port-right", `${rightX}px`);
|
||
return true;
|
||
}
|
||
|
||
function cvAlignAllNodePorts() {
|
||
document.querySelectorAll("#drawflow .drawflow-node").forEach(cvAlignNodePorts);
|
||
}
|
||
|
||
function cvRefreshNodePorts(nodeEl) {
|
||
if (!nodeEl || !nodeEl.id) return;
|
||
const id = nodeEl.id;
|
||
if (CANVAS.portRefreshNodeRAFs[id]) return;
|
||
CANVAS.portRefreshNodeRAFs[id] = requestAnimationFrame(() => {
|
||
delete CANVAS.portRefreshNodeRAFs[id];
|
||
if (!nodeEl.isConnected) return;
|
||
const changed = cvAlignNodePorts(nodeEl);
|
||
if (changed && CANVAS.editor && typeof CANVAS.editor.updateConnectionNodes === "function") {
|
||
CANVAS.editor.updateConnectionNodes(nodeEl.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
function cvRefreshAllNodePorts() {
|
||
if (CANVAS.portRefreshRAF) return;
|
||
CANVAS.portRefreshRAF = requestAnimationFrame(() => {
|
||
CANVAS.portRefreshRAF = 0;
|
||
if (document.getElementById("drawflow")?.classList.contains("cv-dragging")) {
|
||
setTimeout(cvRefreshAllNodePorts, 90);
|
||
return;
|
||
}
|
||
document.querySelectorAll("#drawflow .drawflow-node").forEach((nodeEl) => {
|
||
const changed = cvAlignNodePorts(nodeEl);
|
||
if (changed && CANVAS.editor && typeof CANVAS.editor.updateConnectionNodes === "function") {
|
||
CANVAS.editor.updateConnectionNodes(nodeEl.id);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function cvObserveNodePortFrame(nodeEl) {
|
||
if (!nodeEl) return;
|
||
const frame = cvPortAnchor(nodeEl);
|
||
if (!frame || frame.dataset.cvPortObserved) return;
|
||
frame.dataset.cvPortObserved = "1";
|
||
if (!CANVAS.portResizeObserver && typeof ResizeObserver !== "undefined") {
|
||
CANVAS.portResizeObserver = new ResizeObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
const next = `${Math.round(entry.contentRect.width)}:${Math.round(entry.contentRect.height)}`;
|
||
if (entry.target.dataset.cvResizeSize === next) return;
|
||
entry.target.dataset.cvResizeSize = next;
|
||
const node = entry.target.closest(".drawflow-node");
|
||
if (node) CANVAS.portResizeQueue.add(node);
|
||
});
|
||
if (CANVAS.portResizeTimer) return;
|
||
CANVAS.portResizeTimer = setTimeout(() => {
|
||
CANVAS.portResizeTimer = 0;
|
||
if (CANVAS.portResizeRAF) return;
|
||
CANVAS.portResizeRAF = requestAnimationFrame(() => {
|
||
CANVAS.portResizeRAF = 0;
|
||
const nodes = Array.from(CANVAS.portResizeQueue);
|
||
CANVAS.portResizeQueue.clear();
|
||
nodes.forEach(cvRefreshNodePorts);
|
||
});
|
||
}, 120);
|
||
});
|
||
}
|
||
if (CANVAS.portResizeObserver) CANVAS.portResizeObserver.observe(frame);
|
||
}
|
||
|
||
// 切换视频模式:按 mode 显隐首帧、尾帧和多参考图素材控件
|
||
function cvSyncVideoMode(nodeEl) {
|
||
const sel = nodeEl.querySelector(".cv-field-vmode");
|
||
if (!sel) return;
|
||
const mode = sel.value || "first";
|
||
const show = {
|
||
first: mode === "first" || mode === "firstlast",
|
||
last: mode === "firstlast",
|
||
refs: mode === "reference",
|
||
};
|
||
["first", "last", "refs"].forEach((slot) => {
|
||
const box = nodeEl.querySelector(".cv-vmat-" + slot);
|
||
if (box) box.classList.toggle("hidden", !show[slot]);
|
||
});
|
||
}
|
||
|
||
// 读取单个节点的「输出产物」:文本节点实时读 textarea,其它读运行后缓存的 dataUrl
|
||
function cvNodeOutput(id) {
|
||
const stored = CANVAS.outputs[id] || {};
|
||
const res = { text: stored.text || "", image: stored.image || "" };
|
||
try {
|
||
const node = CANVAS.editor.getNodeFromId(id);
|
||
if (node && node.name === "text") {
|
||
const el = document.querySelector(`#node-${id} .cv-field-prompt`);
|
||
if (el) res.text = el.value.trim();
|
||
}
|
||
} catch (e) {}
|
||
return res;
|
||
}
|
||
|
||
// 汇总某节点所有上游连线的产物:文本保留首个,图片保留全部并提供首图兼容字段。
|
||
function cvCollectUpstream(nodeId) {
|
||
try {
|
||
const node = CANVAS.editor.getNodeFromId(nodeId);
|
||
if (window.CanvasInputs) return window.CanvasInputs.collectUpstreamOutputs(node, cvNodeOutput);
|
||
} catch (e) {}
|
||
return { text: "", image: "", images: [] };
|
||
}
|
||
|
||
// 节点内错误红字
|
||
function cvSetErr(nodeEl, msg) {
|
||
const e = nodeEl.querySelector(".cv-node-err");
|
||
if (!e) return;
|
||
if (msg) {
|
||
e.textContent = msg;
|
||
e.classList.remove("hidden");
|
||
} else {
|
||
e.textContent = "";
|
||
e.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
// 运行按钮 loading 态
|
||
function cvBusy(btn, on) {
|
||
if (!btn) return;
|
||
if (on) {
|
||
btn.dataset.lbl = btn.textContent;
|
||
btn.textContent = "运行中…";
|
||
btn.disabled = true;
|
||
btn.classList.add("loading");
|
||
} else {
|
||
btn.textContent = btn.dataset.lbl || "运行";
|
||
btn.disabled = false;
|
||
btn.classList.remove("loading");
|
||
}
|
||
}
|
||
|
||
function cvMediaExt(mime, kind) {
|
||
if (/png/i.test(mime)) return "png";
|
||
if (/jpe?g/i.test(mime)) return "jpg";
|
||
if (/webp/i.test(mime)) return "webp";
|
||
if (/mp4/i.test(mime) || kind === "video" || kind === "grokVideo") return "mp4";
|
||
if (/wav/i.test(mime)) return "wav";
|
||
if (/mpeg/i.test(mime) || /mp3/i.test(mime)) return "mp3";
|
||
if (/ogg/i.test(mime) || /opus/i.test(mime)) return "opus";
|
||
return kind === "image" ? "png" : kind === "audio" ? "mp3" : "bin";
|
||
}
|
||
|
||
function cvPreviewChrome(kind, dataUrl, sourceUrl, foot) {
|
||
const isVideo = kind === "video" || kind === "grokVideo";
|
||
const sourceAttr = sourceUrl ? ` data-source-url="${esc(sourceUrl)}"` : "";
|
||
const media = isVideo
|
||
? `<video class="cv-media" controls src="${dataUrl}"${sourceAttr}></video>`
|
||
: `<img class="cv-media" src="${dataUrl}"${sourceAttr} alt="生成图片" />`;
|
||
return `
|
||
<div class="cv-preview-shell ${isVideo ? "cv-preview-video" : "cv-preview-image"}">
|
||
<div class="cv-preview-actions">
|
||
<button type="button" class="cv-preview-open" title="放大预览">⛶</button>
|
||
<button type="button" class="cv-preview-download" title="下载">⇩</button>
|
||
</div>
|
||
${media}
|
||
</div>${foot}`;
|
||
}
|
||
|
||
function cvAudioPreviewChrome(dataUrl, foot) {
|
||
return `
|
||
<div class="cv-preview-shell cv-preview-audio">
|
||
<div class="cv-preview-actions">
|
||
<button type="button" class="cv-preview-open" title="放大预览">⛶</button>
|
||
<button type="button" class="cv-preview-download" title="下载">⇩</button>
|
||
</div>
|
||
<audio class="cv-media-audio" controls src="${dataUrl}"></audio>
|
||
</div>${foot}`;
|
||
}
|
||
|
||
function cvOpenPreview(previewEl) {
|
||
if (!previewEl) return;
|
||
const media = previewEl.querySelector("img.cv-media, video.cv-media, audio.cv-media-audio");
|
||
if (!media || !media.src) return;
|
||
const isVideo = media.tagName === "VIDEO";
|
||
const isAudio = media.tagName === "AUDIO";
|
||
const src = media.src || media.dataset.sourceUrl;
|
||
const modal = modalOverlay.querySelector(".modal");
|
||
const body = $("#modal-body");
|
||
$("#modal-title").textContent = isVideo ? "视频预览" : isAudio ? "音频预览" : "图片预览";
|
||
body.innerHTML = "";
|
||
const m = document.createElement(isVideo ? "video" : isAudio ? "audio" : "img");
|
||
m.className = "cv-modal-media";
|
||
m.src = src;
|
||
if (isVideo || isAudio) m.controls = true;
|
||
body.appendChild(m);
|
||
if (modal) modal.classList.add("cv-media-modal");
|
||
modalOverlay.classList.remove("hidden");
|
||
}
|
||
|
||
function cvDataUrlToBlob(dataUrl) {
|
||
const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(dataUrl);
|
||
if (!m) throw new Error("不是合法的 data URL");
|
||
const mime = m[1] || "application/octet-stream";
|
||
if (m[2]) {
|
||
// base64 编码 → 直接 atob 解码(+ 是合法 base64 字符,不要 decodeURIComponent)
|
||
const bin = atob(m[3]);
|
||
const u8 = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||
return new Blob([u8], { type: mime });
|
||
}
|
||
// 非 base64 的 data URL(百分号编码)
|
||
const raw = decodeURIComponent(m[3].replace(/\+/g, " "));
|
||
return new Blob([raw], { type: mime });
|
||
}
|
||
|
||
async function cvDownloadPreview(previewEl) {
|
||
if (!previewEl) return;
|
||
const media = previewEl.querySelector("img.cv-media, video.cv-media, audio.cv-media-audio");
|
||
if (!media || !media.src) return;
|
||
const isVideo = media.tagName === "VIDEO";
|
||
const isAudio = media.tagName === "AUDIO";
|
||
const src = media.src || media.dataset.sourceUrl;
|
||
try {
|
||
let blob;
|
||
if (/^data:/i.test(src)) {
|
||
// data URL 直接解析,不走 fetch(避免 CSP 拦截 + 节省内存)
|
||
blob = cvDataUrlToBlob(src);
|
||
} else {
|
||
// 远程 URL 仍需 fetch
|
||
const res = await fetch(src);
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
blob = await res.blob();
|
||
}
|
||
const ext = cvMediaExt(blob.type, isVideo ? "video" : isAudio ? "audio" : "image");
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `td-canvas-${Date.now()}.${ext}`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||
} catch (e) {
|
||
if (/^https?:\/\//i.test(src) && apic.openExternal) {
|
||
apic.openExternal(src);
|
||
toast("已用浏览器打开原始文件链接", "ok");
|
||
return;
|
||
}
|
||
toast("下载失败:" + (e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
// 渲染运行结果到预览区(结果均为主进程返回的 data URL)
|
||
function cvRenderResult(kind, el, r) {
|
||
const meta = r.meta || {};
|
||
const info = [meta.model, meta.ms ? meta.ms + "ms" : "", meta.host]
|
||
.filter(Boolean).join(" · ");
|
||
const foot = info ? `<div class="cv-meta">${esc(info)}</div>` : "";
|
||
if (kind === "image") {
|
||
el.innerHTML = cvPreviewChrome(kind, r.dataUrl, r.url || "", foot);
|
||
} else if (kind === "video" || kind === "grokVideo") {
|
||
el.innerHTML = cvPreviewChrome(kind, r.dataUrl, r.url || "", foot);
|
||
} else if (kind === "tts") {
|
||
el.innerHTML = cvAudioPreviewChrome(r.dataUrl, foot);
|
||
}
|
||
}
|
||
|
||
function cvResultHost(nodeEl, kind) {
|
||
if (!nodeEl) return null;
|
||
if (kind === "image" || kind === "video" || kind === "grokVideo" || kind === "tts") {
|
||
return nodeEl.querySelector(".cv-video-frame-inner") || nodeEl.querySelector(".cv-node-preview");
|
||
}
|
||
return nodeEl.querySelector(".cv-node-preview");
|
||
}
|
||
|
||
function cvResetResultHost(el, kind) {
|
||
if (!el) return;
|
||
if (el.classList.contains("cv-video-frame-inner")) {
|
||
el.innerHTML = kind === "image" ? "▧" : kind === "tts" ? "♪" : "▣";
|
||
return;
|
||
}
|
||
el.classList.add("hidden");
|
||
el.innerHTML = "";
|
||
}
|
||
|
||
// 据 比例×清晰度 算出图像尺寸 "WxH"(长边 1K=1024/2K=2048/4K=4096,短边按比例取整)
|
||
function cvRatioToSize(ratio, quality) {
|
||
const longEdge = quality === "4K" ? 4096 : quality === "2K" ? 2048 : 1024;
|
||
const parts = String(ratio).split(":").map((n) => parseFloat(n));
|
||
let w = parts[0], h = parts[1];
|
||
if (!w || !h) { w = 1; h = 1; }
|
||
const maxPart = Math.max(w, h);
|
||
const scale = longEdge / maxPart;
|
||
return `${Math.round(w * scale)}x${Math.round(h * scale)}`;
|
||
}
|
||
|
||
// —— 流式图片渐进预览 ——
|
||
// 清理某节点的所有 runId 映射(重跑/删除时,避免旧帧串到新预览)
|
||
function cvClearRunMapForNode(nodeId) {
|
||
const nid = String(nodeId);
|
||
for (const rid of Object.keys(CANVAS.runMap)) {
|
||
if (String(CANVAS.runMap[rid]) === nid) delete CANVAS.runMap[rid];
|
||
}
|
||
}
|
||
|
||
// 把预览区里的 <img> 换成新 dataUrl;若还是 skeleton 则替换成 <img>
|
||
function cvSetPreviewImage(prevEl, dataUrl) {
|
||
if (!prevEl || !dataUrl) return;
|
||
prevEl.classList.remove("hidden");
|
||
if (prevEl.classList.contains("cv-video-frame-inner")) {
|
||
const foot = '<div class="cv-meta">生成中…</div>';
|
||
prevEl.innerHTML = cvPreviewChrome("image", dataUrl, "", foot);
|
||
return;
|
||
}
|
||
const img = prevEl.querySelector("img.cv-media");
|
||
if (img) img.src = dataUrl;
|
||
else prevEl.innerHTML = `<img class="cv-media" src="${dataUrl}" alt="生成中…" />`;
|
||
}
|
||
|
||
// 订阅后端流式进度(幂等,页面生命周期内保留一次)
|
||
function cvBindProgress() {
|
||
if (CANVAS.progressBound) return;
|
||
if (!apic.onCanvasProgress) return; // 后端未接入则不订阅
|
||
CANVAS.progressBound = true;
|
||
apic.onCanvasProgress((p) => {
|
||
try {
|
||
if (!p || !p.runId || !p.dataUrl) return;
|
||
const nodeId = CANVAS.runMap[p.runId];
|
||
if (!nodeId) return; // 无映射(已结束/已清理)→ 丢弃,避免串图
|
||
const nodeEl = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeEl) return;
|
||
cvSetPreviewImage(cvResultHost(nodeEl, "image"), p.dataUrl);
|
||
} catch (e) {}
|
||
});
|
||
}
|
||
|
||
// 运行一个节点:组装 params → apic.canvasRun → 预览 / 报错(全程 try/catch)
|
||
async function cvRunNode(nodeId, btn) {
|
||
const nodeEl = document.getElementById(`node-${nodeId}`);
|
||
if (!nodeEl) return;
|
||
const node = CANVAS.editor.getNodeFromId(nodeId);
|
||
const kind = node && node.name;
|
||
const workspaceContext = { workspaceId: CANVAS.workspaceId, workspaceState: cvWorkspaceSnapshot() };
|
||
const prevEl = cvResultHost(nodeEl, kind);
|
||
cvSetErr(nodeEl, "");
|
||
|
||
// 组装每类节点的入参(优先自身填写,其次上游连线)
|
||
const up = cvCollectUpstream(nodeId);
|
||
const promptEl = nodeEl.querySelector(".cv-field-prompt");
|
||
const ownText = promptEl ? promptEl.value.trim() : "";
|
||
const val = (sel) => {
|
||
const el = nodeEl.querySelector(sel);
|
||
return el ? el.value.trim() : "";
|
||
};
|
||
let runKind = "";
|
||
let params = null;
|
||
let imageRunId = ""; // 仅 image 流式用;finally 里清理映射
|
||
if (kind === "image") {
|
||
const prompt = ownText || up.text;
|
||
if (!prompt) return cvSetErr(nodeEl, "请填写提示词,或连接一个文本节点作为输入");
|
||
const model = val(".cv-field-model");
|
||
if (!model) return cvSetErr(nodeEl, "请先填写图像模型(如 flux-pro / dall-e-3)");
|
||
const size = cvRatioToSize(val(".cv-field-ratio") || "1:1", val(".cv-field-quality") || "1K");
|
||
const endpointPath = val(".cv-field-endpoint");
|
||
runKind = "image";
|
||
params = { prompt, model, size };
|
||
if (endpointPath) params.endpointPath = endpointPath;
|
||
// 图生图:本节点上传的参考图优先,其次使用连接上游的首张图片。
|
||
const refImg = CANVAS.refImages[nodeId] || up.image;
|
||
if (refImg) params.image = refImg;
|
||
const refField = val(".cv-field-reffield");
|
||
if (refField) params.refField = refField;
|
||
// 流式渐进预览:先清理该节点旧映射(避免重跑串图),再生成唯一 runId
|
||
cvClearRunMapForNode(nodeId);
|
||
imageRunId = nodeId + "-" + (++CANVAS.runSeq);
|
||
CANVAS.runMap[imageRunId] = nodeId;
|
||
params.runId = imageRunId;
|
||
} else if (kind === "video") {
|
||
let mode = val(".cv-field-vmode") || "first";
|
||
if (mode === "image") mode = "first"; // 兼容旧值
|
||
const model = val(".cv-field-model");
|
||
const endpointPath = val(".cv-field-endpoint");
|
||
const duration = val(".cv-field-duration");
|
||
const mats = CANVAS.videoMaterials[nodeId] || {};
|
||
const matUrl = (slot) => {
|
||
const el = nodeEl.querySelector(`.cv-vmat-url[data-slot="${slot}"]`);
|
||
return el ? el.value.trim() : "";
|
||
};
|
||
const matUrls = (slot) => {
|
||
const el = nodeEl.querySelector(`.cv-vmat-urls[data-slot="${slot}"]`);
|
||
return el ? el.value.split(/\s+/).map((s) => s.trim()).filter(Boolean) : [];
|
||
};
|
||
runKind = "video";
|
||
params = {
|
||
mode,
|
||
prompt: ownText || up.text,
|
||
ratio: val(".cv-field-ratio"),
|
||
quality: val(".cv-field-vquality"),
|
||
resolution: val(".cv-field-vsize") || "720p",
|
||
referenceField: val(".cv-field-refsource") || "images",
|
||
};
|
||
if (model) params.model = model;
|
||
if (!(params.prompt && params.prompt.trim())) {
|
||
return cvSetErr(nodeEl, "Seedance 文生/图生视频都必须填写提示词");
|
||
}
|
||
// 按模式收集素材:优先手填URL,其次上传缓存(dataURL),首帧再兜底上游图
|
||
if (mode === "first" || mode === "firstlast") {
|
||
const first = matUrl("first") || mats.first || up.image || "";
|
||
if (!first) return cvSetErr(nodeEl, "该模式需首帧:连上游『文生图』/ 上传 / 手填公网URL");
|
||
params.image = first;
|
||
}
|
||
if (mode === "firstlast") {
|
||
const last = matUrl("last") || mats.last || "";
|
||
if (!last) return cvSetErr(nodeEl, "首尾帧模式还需尾帧图(上传或手填公网URL)");
|
||
params.lastFrameImage = last;
|
||
}
|
||
if (mode === "reference") {
|
||
const refs = CanvasInputs.uniqueNonEmpty([].concat(up.images, mats.refs || [], matUrls("refs")));
|
||
if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请连接上游图片、上传参考图,或填写公网图片URL");
|
||
params.referenceImages = refs;
|
||
}
|
||
if (duration) params.seconds = duration;
|
||
if (endpointPath) params.endpointPath = endpointPath;
|
||
else {
|
||
// 未填高级接口时:Seedance V2 分组自动走原生 /v1/video/generations
|
||
const gid = val(".cv-field-group");
|
||
const gg = (CANVAS.groups || []).find((item) => String(item.id) === String(gid || ""));
|
||
if (cvIsSeedanceV2Group(gg) || cvIsSeedanceV2Model(model)) params.endpointPath = "/v1/video/generations";
|
||
}
|
||
} else if (kind === "grokVideo") {
|
||
const mode = val(".cv-field-vmode") || "text";
|
||
const model = val(".cv-field-model");
|
||
const endpointPath = val(".cv-field-endpoint");
|
||
const duration = val(".cv-field-duration");
|
||
const mats = CANVAS.videoMaterials[nodeId] || {};
|
||
const matUrl = (slot) => {
|
||
const el = nodeEl.querySelector(`.cv-vmat-url[data-slot="${slot}"]`);
|
||
return el ? el.value.trim() : "";
|
||
};
|
||
const matUrls = (slot) => {
|
||
const el = nodeEl.querySelector(`.cv-vmat-urls[data-slot="${slot}"]`);
|
||
return el ? el.value.split(/\s+/).map((s) => s.trim()).filter(Boolean) : [];
|
||
};
|
||
runKind = "grokVideo";
|
||
params = {
|
||
mode,
|
||
prompt: ownText || up.text,
|
||
model,
|
||
resolution: val(".cv-field-vsize") || "720p",
|
||
aspectRatio: val(".cv-field-ratio") || "16:9",
|
||
};
|
||
if (!params.prompt) return cvSetErr(nodeEl, "请填写 Grok 视频提示词,或连接一个文本节点作为输入");
|
||
if (mode === "first") {
|
||
const first = matUrl("first") || mats.first || up.image || "";
|
||
if (!first) return cvSetErr(nodeEl, "首帧图生视频需要首帧:连上游『文生图』/ 上传 / 手填公网URL");
|
||
params.image = first;
|
||
}
|
||
if (mode === "reference") {
|
||
const refs = CanvasInputs.uniqueNonEmpty([].concat(up.images, mats.refs || [], matUrls("refs")));
|
||
if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请连接上游图片、上传参考图,或填写公网图片URL");
|
||
params.referenceImages = refs;
|
||
params.aspectRatio = params.aspectRatio || "16:9";
|
||
}
|
||
if (duration) params.seconds = duration;
|
||
if (endpointPath) params.endpointPath = endpointPath;
|
||
} else if (kind === "tts") {
|
||
const text = ownText || up.text;
|
||
if (!text) return cvSetErr(nodeEl, "请填写文本,或连接一个文本节点作为输入");
|
||
const model = val(".cv-field-model");
|
||
const voice = val(".cv-field-voice") || "alloy";
|
||
const format = val(".cv-field-format") || "mp3";
|
||
runKind = "tts";
|
||
params = { text, voice, format };
|
||
if (model) params.model = model;
|
||
} else {
|
||
return; // 文本节点无需运行
|
||
}
|
||
|
||
// 每个能力节点都需选定中转分组,params 带上 groupId
|
||
const groupId = val(".cv-field-group");
|
||
if (!groupId)
|
||
return cvSetErr(nodeEl, "请先选择中转分组(或在『路由中转』添加分组)");
|
||
params.groupId = groupId;
|
||
|
||
cvBusy(btn, true);
|
||
if (prevEl) {
|
||
prevEl.classList.remove("hidden");
|
||
prevEl.innerHTML = `<div class="cv-skeleton"><span class="cv-spin"></span>生成中…</div>`;
|
||
}
|
||
try {
|
||
await cvSaveWorkspace();
|
||
if (!apic.canvasRun) throw new Error("画布运行能力尚未就绪(后端未接入)");
|
||
const r = await apic.canvasRun(runKind, params);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "生成失败");
|
||
if (CANVAS.workspaceId === workspaceContext.workspaceId) {
|
||
cvRenderResult(runKind, prevEl, r);
|
||
// 缓存产物供下游取用(图片可连到图生视频)
|
||
if (runKind === "image") CANVAS.outputs[nodeId] = { image: r.dataUrl };
|
||
}
|
||
try {
|
||
await cvCacheOutput(nodeId, runKind, r, workspaceContext);
|
||
} catch (cacheErr) {
|
||
toast("生成成功,但本地缓存失败:" + (cacheErr.message || cacheErr), "err");
|
||
}
|
||
} catch (e) {
|
||
cvResetResultHost(prevEl, runKind || kind);
|
||
cvSetErr(nodeEl, e.message || String(e));
|
||
} finally {
|
||
cvBusy(btn, false);
|
||
if (imageRunId) delete CANVAS.runMap[imageRunId]; // 运行结束清理映射
|
||
}
|
||
}
|
||
|
||
// 刷新顶部状态徽标 + 未就绪遮罩
|
||
async function cvRefreshStatus() {
|
||
const badge = document.getElementById("cv-status");
|
||
const mask = document.getElementById("cv-mask");
|
||
const maskText = document.getElementById("cv-mask-text");
|
||
if (!badge || !mask) return;
|
||
badge.textContent = "检测中…";
|
||
badge.className = "pc-badge cv-status";
|
||
try {
|
||
if (!apic.canvasStatus) throw new Error("中转能力尚未就绪(后端未接入)");
|
||
const st = await apic.canvasStatus();
|
||
CANVAS.status = st;
|
||
if (st && st.ready) {
|
||
const p = st.provider || {};
|
||
const label = [p.name, p.model].filter(Boolean).join(" · ") || "已就绪";
|
||
badge.textContent = "● " + label;
|
||
badge.classList.add("on");
|
||
mask.classList.add("hidden");
|
||
} else {
|
||
badge.textContent = "○ 未就绪";
|
||
badge.classList.add("off");
|
||
if (maskText) maskText.textContent =
|
||
(st && st.reason) || "请先在『路由中转』配置并激活一个中转分组";
|
||
mask.classList.remove("hidden");
|
||
}
|
||
} catch (e) {
|
||
badge.textContent = "○ 状态获取失败";
|
||
badge.classList.add("off");
|
||
if (maskText) maskText.textContent = "无法获取中转状态:" + (e.message || e);
|
||
mask.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
// 进入画布:懒初始化 + 加载分组 + 刷新状态
|
||
async function cvEnter() {
|
||
cvInit();
|
||
cvBindProgress(); // 幂等;若启动时后端未就绪,进入时再尝试订阅
|
||
cvLoadGroups();
|
||
cvRefreshStatus();
|
||
cvRequestAutoFull();
|
||
if (!CANVAS.workspaceReady && !CANVAS.workspaceLoading) {
|
||
CANVAS.workspaceLoading = true;
|
||
try {
|
||
const r = await apic.canvasWorkspaces();
|
||
CANVAS.workspaceList = r && r.ok && Array.isArray(r.workspaces) ? r.workspaces : [];
|
||
if (!CANVAS.workspaceList.length) {
|
||
await cvCreateWorkspace();
|
||
} else {
|
||
await cvOpenWorkspacePicker();
|
||
}
|
||
} catch (e) {
|
||
toast("画布记录加载失败:" + (e.message || e), "err");
|
||
} finally {
|
||
CANVAS.workspaceLoading = false;
|
||
}
|
||
}
|
||
}
|
||
const cvTabBtn = document.querySelector('.tab[data-tab="canvas"]');
|
||
if (cvTabBtn) cvTabBtn.addEventListener("click", cvEnter);
|
||
// 启动时订阅一次流式进度(幂等)
|
||
cvBindProgress();
|
||
|
||
document.getElementById("cv-projects-btn")?.addEventListener("click", cvOpenWorkspacePicker);
|
||
document.getElementById("cv-project-close")?.addEventListener("click", cvCloseWorkspacePicker);
|
||
document.getElementById("cv-project-create")?.addEventListener("click", () => {
|
||
cvCreateWorkspace().catch((e) => toast("新建画布失败:" + (e.message || e), "err"));
|
||
});
|
||
document.getElementById("cv-new-btn")?.addEventListener("click", () => {
|
||
cvCreateWorkspace().catch((e) => toast("新建画布失败:" + (e.message || e), "err"));
|
||
});
|
||
document.getElementById("cv-project-list")?.addEventListener("click", (e) => {
|
||
const renameStart = e.target.closest("[data-cv-rename-start]");
|
||
if (renameStart) {
|
||
CANVAS.workspaceRenamingId = renameStart.dataset.cvRenameStart;
|
||
cvRenderWorkspaceList();
|
||
return;
|
||
}
|
||
if (e.target.closest("[data-cv-rename-cancel]")) {
|
||
CANVAS.workspaceRenamingId = "";
|
||
cvRenderWorkspaceList();
|
||
return;
|
||
}
|
||
const renameSave = e.target.closest("[data-cv-rename-save]");
|
||
if (renameSave) {
|
||
cvRenameWorkspace(renameSave.dataset.cvRenameSave).catch((err) => toast("改名失败:" + (err.message || err), "err"));
|
||
return;
|
||
}
|
||
const btn = e.target.closest("[data-cv-workspace-id]");
|
||
if (!btn) return;
|
||
busy(btn, true, "载入中…");
|
||
cvLoadWorkspace(btn.dataset.cvWorkspaceId)
|
||
.catch((err) => toast("恢复画布失败:" + (err.message || err), "err"))
|
||
.finally(() => busy(btn, false));
|
||
});
|
||
document.getElementById("cv-project-list")?.addEventListener("keydown", (e) => {
|
||
const input = e.target.closest("[data-cv-rename-input]");
|
||
if (!input) return;
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
cvRenameWorkspace(input.dataset.cvRenameInput).catch((err) => toast("改名失败:" + (err.message || err), "err"));
|
||
} else if (e.key === "Escape") {
|
||
CANVAS.workspaceRenamingId = "";
|
||
cvRenderWorkspaceList();
|
||
}
|
||
});
|
||
|
||
// 顶部「添加节点」按钮
|
||
$$("[data-cv-add]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
cvInit();
|
||
cvAddNode(b.dataset.cvAdd);
|
||
})
|
||
);
|
||
// 遮罩内「前往路由中转」
|
||
const cvGoto = document.getElementById("cv-goto-router");
|
||
if (cvGoto) cvGoto.addEventListener("click", () => activatePanel("router"));
|
||
|
||
// 工具条「🔄 拉取模型」:用当前激活分组拉取,一次性灌进所有节点的模型选择器
|
||
const cvModelsBtn = document.getElementById("cv-models-btn");
|
||
if (cvModelsBtn)
|
||
cvModelsBtn.addEventListener("click", async (ev) => {
|
||
const btn = ev.currentTarget;
|
||
busy(btn, true, "拉取中…");
|
||
try {
|
||
if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)");
|
||
const r = await apic.canvasModels(CANVAS.activeGroupId || undefined);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
|
||
const models = Array.isArray(r.models) ? r.models : [];
|
||
const nodes = document.querySelectorAll("#drawflow .drawflow-node");
|
||
nodes.forEach((node) => cvSetNodeModelOptions(node, models));
|
||
toast(`已按当前分组拉取 ${models.length} 个模型,可在各节点手动切换`, "ok");
|
||
} catch (e) {
|
||
toast("拉取模型失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
});
|
||
|
||
// 工具条「载入示例」:画布为空时重新注入示例工作流
|
||
const cvSeedBtn = document.getElementById("cv-seed-btn");
|
||
if (cvSeedBtn)
|
||
cvSeedBtn.addEventListener("click", () => {
|
||
cvInit();
|
||
if (Object.keys(cvAllNodes()).length > 0) {
|
||
toast("画布已有节点,先清空后再载入示例", "");
|
||
return;
|
||
}
|
||
cvSeedSample();
|
||
});
|
||
|
||
// 工具条「⛶ 放大 / ✕ 退出放大」
|
||
const cvFullBtn = document.getElementById("cv-full-btn");
|
||
if (cvFullBtn)
|
||
cvFullBtn.addEventListener("click", () => {
|
||
cvInit();
|
||
cvToggleFull();
|
||
});
|
||
|
||
// 悬浮缩放控件(+ / 100% 重置 / -)统一走 cvSetZoomPercent,避免越过 Drawflow min/max
|
||
const cvZoomStep = (delta) => () => {
|
||
cvInit();
|
||
cvSetZoomPercent(cvZoomPercent() + delta);
|
||
};
|
||
document.getElementById("cv-zoom-in")?.addEventListener("click", cvZoomStep(5));
|
||
document.getElementById("cv-zoom-out")?.addEventListener("click", cvZoomStep(-5));
|
||
document.getElementById("cv-zoom-reset")?.addEventListener("click", () => {
|
||
cvInit();
|
||
cvSetZoomPercent(100);
|
||
});
|
||
const cvZoomRange = document.getElementById("cv-zoom-range");
|
||
if (cvZoomRange) {
|
||
cvZoomRange.addEventListener("input", () => cvSetZoomPercent(cvZoomRange.value));
|
||
}
|
||
|
||
// Esc 退出全屏
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") {
|
||
const panel = document.getElementById("tab-canvas");
|
||
if (panel && panel.classList.contains("cv-fullscreen")) cvToggleFull(false);
|
||
}
|
||
});
|
||
|
||
// ============================================================================
|
||
// 员工团队:需求 → AI 员工团队 → 打包 zip
|
||
// ============================================================================
|
||
const TEAM = {
|
||
tool: "codex",
|
||
team: null,
|
||
groups: [],
|
||
activeGroupId: "",
|
||
orchestratorGroupId: "",
|
||
agentGroupId: "",
|
||
agentModels: [],
|
||
loaded: false,
|
||
};
|
||
|
||
async function tmEnter() {
|
||
if (!TEAM.loaded) {
|
||
TEAM.loaded = true;
|
||
tmSetView(TEAM.team ? "content" : "empty"); // 归一初始层,避免 empty/loading/content 叠在一起
|
||
await tmLoadGroups();
|
||
}
|
||
}
|
||
|
||
// 拉取中转分组,填充分组下拉
|
||
async function tmLoadGroups() {
|
||
try {
|
||
if (!apic.canvasGroups) throw new Error("no api");
|
||
const r = await apic.canvasGroups();
|
||
if (r && r.ok) {
|
||
TEAM.groups = Array.isArray(r.groups) ? r.groups : [];
|
||
TEAM.activeGroupId = r.activeId || (TEAM.groups[0] && TEAM.groups[0].id) || "";
|
||
TEAM.orchestratorGroupId = TEAM.orchestratorGroupId || TEAM.activeGroupId;
|
||
const preferredWorker = TEAM.groups.find((g) => /deepseek/i.test(
|
||
`${g.id || ""} ${g.name || ""} ${g.model || ""} ${(g.models || []).join(" ")}`
|
||
));
|
||
const fallbackWorker = TEAM.groups.find((g) => g.id !== TEAM.activeGroupId && !g.codexImage && !g.codexVideo && !g.stt)
|
||
|| TEAM.groups[0];
|
||
TEAM.agentGroupId = TEAM.agentGroupId || (preferredWorker && preferredWorker.id) || (fallbackWorker && fallbackWorker.id) || "";
|
||
} else {
|
||
TEAM.groups = [];
|
||
TEAM.activeGroupId = "";
|
||
TEAM.orchestratorGroupId = "";
|
||
TEAM.agentGroupId = "";
|
||
}
|
||
} catch (e) {
|
||
TEAM.groups = [];
|
||
TEAM.activeGroupId = "";
|
||
TEAM.orchestratorGroupId = "";
|
||
TEAM.agentGroupId = "";
|
||
}
|
||
tmRenderGroupSelect("tm-group", TEAM.activeGroupId, (id) => {
|
||
TEAM.activeGroupId = id;
|
||
const group = TEAM.groups.find((g) => g.id === id);
|
||
tmSetSelectModels("tm-model", TEAM.activeGroupId, group && (group.model || (group.models || [])[0]));
|
||
tmUpdateGenBtn();
|
||
});
|
||
tmRenderGroupSelect("tm-orch-group", TEAM.orchestratorGroupId, (id) => {
|
||
TEAM.orchestratorGroupId = id;
|
||
const group = TEAM.groups.find((g) => g.id === id);
|
||
tmSetSelectModels("tm-orch-model", TEAM.orchestratorGroupId, group && (group.model || (group.models || [])[0]));
|
||
tmUpdateGenBtn();
|
||
});
|
||
tmRenderGroupSelect("tm-agent-group", TEAM.agentGroupId, (id) => {
|
||
TEAM.agentGroupId = id;
|
||
const group = TEAM.groups.find((g) => g.id === id);
|
||
tmSetWorkerModels(group, group && (group.model || (group.models || [])[0]), true);
|
||
tmUpdateGenBtn();
|
||
});
|
||
const leadGroup = TEAM.groups.find((g) => g.id === TEAM.activeGroupId);
|
||
const orchestratorGroup = TEAM.groups.find((g) => g.id === TEAM.orchestratorGroupId);
|
||
const workerGroup = TEAM.groups.find((g) => g.id === TEAM.agentGroupId);
|
||
tmSetSelectModels("tm-model", TEAM.activeGroupId, leadGroup && (leadGroup.model || (leadGroup.models || [])[0]));
|
||
tmSetSelectModels("tm-orch-model", TEAM.orchestratorGroupId, orchestratorGroup && (orchestratorGroup.model || (orchestratorGroup.models || [])[0]));
|
||
tmSetWorkerModels(workerGroup, workerGroup && (workerGroup.model || (workerGroup.models || [])[0]));
|
||
tmUpdateGenBtn();
|
||
}
|
||
|
||
function tmRenderGroupSelect(id, selectedId, onChange) {
|
||
const sel = document.getElementById(id);
|
||
if (!sel) return;
|
||
sel.innerHTML = TEAM.groups.length
|
||
? TEAM.groups.map((g) => `<option value="${esc(g.id)}">${esc(g.name || g.id)}</option>`).join("")
|
||
: `<option value="">未配置可用分组(去『路由中转』添加)</option>`;
|
||
sel.value = selectedId || "";
|
||
sel.onchange = () => onChange(sel.value);
|
||
}
|
||
|
||
function tmSetSelectModels(id, groupId, preferred) {
|
||
const sel = document.getElementById(id);
|
||
if (!sel) return;
|
||
const group = TEAM.groups.find((g) => g.id === groupId);
|
||
const models = Array.from(new Set([
|
||
preferred,
|
||
group && group.model,
|
||
...((group && group.models) || []),
|
||
].map((m) => String(m || "").trim()).filter(Boolean)));
|
||
sel.innerHTML = models.length
|
||
? models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
|
||
: `<option value="">先拉取模型</option>`;
|
||
if (preferred && models.includes(preferred)) sel.value = preferred;
|
||
sel.onchange = tmUpdateGenBtn;
|
||
}
|
||
|
||
function tmSetWorkerModels(group, preferred, force = false) {
|
||
const input = document.getElementById("tm-agent-model");
|
||
const list = document.getElementById("tm-agent-model-list");
|
||
if (!input || !list) return;
|
||
TEAM.agentModels = Array.from(new Set([
|
||
preferred,
|
||
group && group.model,
|
||
...((group && group.models) || []),
|
||
].map((m) => String(m || "").trim()).filter(Boolean)));
|
||
list.innerHTML = TEAM.agentModels.map((m) => `<option value="${esc(m)}"></option>`).join("");
|
||
if (preferred && (force || !input.value.trim())) input.value = preferred;
|
||
input.oninput = tmUpdateGenBtn;
|
||
}
|
||
|
||
// 拉取所选分组的模型列表,填充总控下拉或执行模型 datalist。
|
||
async function tmFetchModelsFor(kind) {
|
||
const isWorker = kind === "worker";
|
||
const isOrchestrator = kind === "orchestrator";
|
||
const btn = document.getElementById(isWorker ? "tm-agent-fetch" : (isOrchestrator ? "tm-orch-fetch" : "tm-fetch"));
|
||
const groupId = (isWorker ? TEAM.agentGroupId : (isOrchestrator ? TEAM.orchestratorGroupId : TEAM.activeGroupId)) || undefined;
|
||
busy(btn, true, "…");
|
||
try {
|
||
if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)");
|
||
const r = await apic.canvasModels(groupId);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
|
||
const models = Array.isArray(r.models) ? r.models : [];
|
||
if (isWorker) {
|
||
TEAM.agentModels = models;
|
||
const input = document.getElementById("tm-agent-model");
|
||
const list = document.getElementById("tm-agent-model-list");
|
||
if (list) list.innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join("");
|
||
if (input && !input.value.trim()) input.value = models[0] || "";
|
||
} else {
|
||
const sel = document.getElementById(isOrchestrator ? "tm-orch-model" : "tm-model");
|
||
if (sel) {
|
||
sel.innerHTML = models.length
|
||
? models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
|
||
: `<option value="">该分组没有可用模型</option>`;
|
||
sel.onchange = tmUpdateGenBtn;
|
||
}
|
||
}
|
||
const hint = document.getElementById(isWorker ? "tm-agent-hint" : (isOrchestrator ? "tm-orch-hint" : "tm-model-hint"));
|
||
if (hint) hint.textContent = isWorker
|
||
? `已拉取 ${models.length} 个模型,选择一个作为子 Agent 执行模型`
|
||
: isOrchestrator
|
||
? `已拉取 ${models.length} 个模型,选择一个作为总控实际执行模型`
|
||
: `已拉取 ${models.length} 个模型,选择一个用于生成团队配置`;
|
||
tmUpdateGenBtn();
|
||
toast(`已拉取 ${models.length} 个模型`, "ok");
|
||
} catch (e) {
|
||
toast("拉取模型失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
function tmFetchModels() { return tmFetchModelsFor("lead"); }
|
||
function tmFetchOrchestratorModels() { return tmFetchModelsFor("orchestrator"); }
|
||
function tmFetchWorkerModels() { return tmFetchModelsFor("worker"); }
|
||
|
||
function tmUpdateGenBtn() {
|
||
const btn = document.getElementById("tm-gen");
|
||
if (btn.dataset.busy === "1") return; // 生成中不改动禁用态(由 tmGenerate 收尾复位)
|
||
const req = document.getElementById("tm-req").value.trim();
|
||
const model = document.getElementById("tm-model").value;
|
||
const orchestratorModel = document.getElementById("tm-orch-model").value;
|
||
const workerModel = document.getElementById("tm-agent-model").value.trim();
|
||
btn.disabled = !(req && model && (TEAM.tool === "claude" || (orchestratorModel && workerModel)));
|
||
}
|
||
|
||
function tmSetView(w) {
|
||
// 用内联 display 直接控制,避免 [hidden] 被 .tm-empty/.tm-loading 的 display:flex 覆盖
|
||
const views = { empty: "tm-empty", loading: "tm-loading", content: "tm-content", error: "tm-error" };
|
||
Object.entries(views).forEach(([k, id]) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
const on = k === w;
|
||
el.hidden = !on;
|
||
el.style.display = on ? (k === "content" ? "block" : "flex") : "none";
|
||
});
|
||
}
|
||
|
||
async function tmGenerate() {
|
||
const requirement = document.getElementById("tm-req").value.trim();
|
||
const model = document.getElementById("tm-model").value;
|
||
const orchestratorModel = document.getElementById("tm-orch-model").value;
|
||
const workerModel = document.getElementById("tm-agent-model").value.trim();
|
||
if (!requirement || !model || (TEAM.tool === "codex" && (!orchestratorModel || !workerModel))) return;
|
||
const genBtn = document.getElementById("tm-gen");
|
||
document.getElementById("tm-download").hidden = true;
|
||
// 生成期间禁用按钮并改文案,防止误触重复提交
|
||
genBtn.disabled = true;
|
||
genBtn.dataset.busy = "1";
|
||
genBtn.textContent = "正在生成员工团队…";
|
||
tmSetView("loading");
|
||
try {
|
||
const r = await apic.teamGenerate({
|
||
requirement,
|
||
tool: TEAM.tool,
|
||
groupId: TEAM.activeGroupId || undefined,
|
||
model,
|
||
orchestratorGroupId: TEAM.orchestratorGroupId || undefined,
|
||
agentGroupId: TEAM.agentGroupId || undefined,
|
||
agentModel: workerModel || undefined,
|
||
orchestratorModel: orchestratorModel || undefined,
|
||
});
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "生成失败");
|
||
TEAM.team = r.result;
|
||
tmRenderTeam(r.result);
|
||
tmSetView("content");
|
||
document.getElementById("tm-download").hidden = false;
|
||
} catch (err) {
|
||
document.getElementById("tm-error").innerHTML = "⚠ " + esc(err.message || String(err));
|
||
tmSetView("error");
|
||
} finally {
|
||
genBtn.textContent = "生成员工团队";
|
||
genBtn.dataset.busy = "";
|
||
tmUpdateGenBtn();
|
||
}
|
||
}
|
||
|
||
function tmRenderTeam(team) {
|
||
const head = document.getElementById("tm-team-head");
|
||
const stack = (team.stack || []).map((s) => `<span class="tm-stack-chip">${esc(s)}</span>`).join("");
|
||
head.innerHTML =
|
||
`<div class="tm-team-name">${esc(team.project_name || "AI 员工团队")}</div>` +
|
||
(team.summary ? `<div class="tm-team-summary">${esc(team.summary)}</div>` : "") +
|
||
(stack ? `<div class="tm-team-stack">${stack}</div>` : "") +
|
||
(team.model_routing ? `<div class="tm-team-models">生成:<code>${esc(team.model_routing.generation_model || team.model_routing.analysis_model || "—")}</code> 总控执行:<code>${esc(team.model_routing.orchestrator_model || "继承")}</code> 子 Agent 执行:<code>${esc(team.model_routing.worker_model || "继承")}</code></div>` : "");
|
||
const list = document.getElementById("tm-team-list");
|
||
list.innerHTML = "";
|
||
list.appendChild(tmConvCard(team));
|
||
if (team.orchestrator) list.appendChild(tmAgentCard(team.orchestrator, true));
|
||
(team.employees || []).forEach((e) => list.appendChild(tmAgentCard(e, false)));
|
||
}
|
||
|
||
function tmConvCard(team) {
|
||
const owner = (team.employees || []).find((e) => e.id === team.contract_owner);
|
||
const li = (arr) => (arr || []).map((x) => `<li>${esc(x)}</li>`).join("");
|
||
const div = document.createElement("div");
|
||
div.className = "tm-conv-card";
|
||
div.innerHTML =
|
||
`<div class="tm-conv-title">团队协同规范</div>
|
||
<div class="tm-conv-owner">接口契约 owner:<b>${esc(owner ? owner.name : team.contract_owner || "—")}</b> · 全部规范已写进每个 agent 的 prompt,并随包附 CONVENTIONS.md</div>
|
||
<div class="tm-conv-grid">
|
||
<div class="tm-conv-col"><div class="tm-cc-label">统一产出格式</div><ul>${li(team.output_format)}</ul></div>
|
||
<div class="tm-conv-col"><div class="tm-cc-label">完成定义 (DoD)</div><ul>${li(team.definition_of_done)}</ul></div>
|
||
<div class="tm-conv-col"><div class="tm-cc-label">Git / 文件边界</div><ul>${li(team.git_rules)}</ul></div>
|
||
${(team.security_checklist || []).length
|
||
? `<div class="tm-conv-col"><div class="tm-cc-label">安全 / 权限清单</div><ul>${li(team.security_checklist)}</ul></div>`
|
||
: `<div class="tm-conv-col"><div class="tm-cc-label">任务交接协议</div><ul>${li(team.handoff_protocol)}</ul></div>`}
|
||
</div>`;
|
||
return div;
|
||
}
|
||
|
||
function tmAgentCard(a, isLead) {
|
||
const div = document.createElement("div");
|
||
div.className = "tm-emp-card" + (isLead ? " lead" : "");
|
||
const resp = (a.responsibilities || []).map((r) => `<li>${esc(r)}</li>`).join("");
|
||
const cons = (a.constraints || []).map((c) => `<span class="tm-con-chip">${esc(c)}</span>`).join("");
|
||
let extra = "";
|
||
if (isLead && (a.workflow || []).length) {
|
||
extra = `<div class="tm-sec-label">调度流程</div><ol class="tm-flow">${a.workflow.map((w) => `<li>${esc(w)}</li>`).join("")}</ol>`;
|
||
} else if (!isLead) {
|
||
const collab = (a.collaborators || []).map((c) => `<code>${esc(c)}</code>`).join("");
|
||
const rows = [];
|
||
if (collab) rows.push(`<div class="tm-ec-row"><span class="tm-ec-k">协作</span><span>${collab}</span></div>`);
|
||
if (a.handoff_in) rows.push(`<div class="tm-ec-row"><span class="tm-ec-k">接收</span><span>${esc(a.handoff_in)}</span></div>`);
|
||
if (a.handoff_out) rows.push(`<div class="tm-ec-row"><span class="tm-ec-k">交付</span><span>${esc(a.handoff_out)}</span></div>`);
|
||
if (rows.length) extra = `<div class="tm-collab">${rows.join("")}</div>`;
|
||
}
|
||
div.innerHTML =
|
||
`<div class="tm-emp-top">
|
||
<span class="tm-emp-name">${esc(a.name || "")}</span>
|
||
${isLead ? '<span class="tm-emp-badge">总控</span>' : ""}
|
||
<span class="tm-emp-id">${esc(a.id || "")}</span>
|
||
</div>
|
||
${a.model ? `<div class="tm-emp-model">模型:<code>${esc(a.model)}</code></div>` : ""}
|
||
<div class="tm-emp-duty">${esc(a.duty || "")}</div>
|
||
${resp ? `<div class="tm-sec-label">职责</div><ul class="tm-resp">${resp}</ul>` : ""}
|
||
${extra}
|
||
${cons ? `<div class="tm-sec-label">约束</div><div class="tm-cons">${cons}</div>` : ""}
|
||
<div class="tm-file-block">
|
||
<button class="tm-file-toggle"></button>
|
||
<div class="tm-file-view" hidden>
|
||
<div class="tm-file-head"><span class="tm-file-name"></span><button class="tm-file-copy">复制</button></div>
|
||
<pre class="tm-file-pre"></pre>
|
||
</div>
|
||
</div>`;
|
||
tmWirePreview(div, a);
|
||
return div;
|
||
}
|
||
|
||
function tmWirePreview(div, a) {
|
||
const ext = TEAM.tool === "claude" ? ".md" : ".toml";
|
||
const toggle = div.querySelector(".tm-file-toggle");
|
||
const view = div.querySelector(".tm-file-view");
|
||
const pre = div.querySelector(".tm-file-pre");
|
||
const nameEl = div.querySelector(".tm-file-name");
|
||
let loaded = false;
|
||
toggle.textContent = `预览 ${ext} 全文`;
|
||
toggle.addEventListener("click", async () => {
|
||
const show = view.hidden;
|
||
view.hidden = !show;
|
||
toggle.textContent = `${show ? "收起" : "预览"} ${ext} 全文`;
|
||
if (show && !loaded) {
|
||
pre.textContent = "加载中…";
|
||
try {
|
||
const r = await apic.teamPreview({ result: TEAM.team, tool: TEAM.tool, id: a.id });
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "预览失败");
|
||
nameEl.textContent = r.filename;
|
||
pre.textContent = r.content;
|
||
loaded = true;
|
||
} catch (err) {
|
||
pre.textContent = "⚠ " + (err.message || err);
|
||
}
|
||
}
|
||
});
|
||
div.querySelector(".tm-file-copy").addEventListener("click", () => {
|
||
if (pre.textContent && loaded) navigator.clipboard.writeText(pre.textContent).then(() => toast("已复制", "ok"));
|
||
});
|
||
}
|
||
|
||
async function tmDownload() {
|
||
if (!TEAM.team) return;
|
||
const btn = document.getElementById("tm-download");
|
||
busy(btn, true, "打包中…");
|
||
try {
|
||
const r = await apic.teamPack({ result: TEAM.team, tool: TEAM.tool });
|
||
if (r && r.ok) toast("已保存到 " + r.path, "ok");
|
||
else if (r && r.canceled) { /* 用户取消,静默 */ }
|
||
else throw new Error((r && r.error) || "打包失败");
|
||
} catch (err) {
|
||
toast("打包失败:" + (err.message || err), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
// —— 事件绑定 ——
|
||
(function tmWireEvents() {
|
||
// 入口在「插件市场」的工具卡片(data-open-panel="team"),懒加载由该处理器触发 tmEnter
|
||
const req = document.getElementById("tm-req");
|
||
if (req) req.addEventListener("input", tmUpdateGenBtn);
|
||
const toolChips = document.getElementById("tm-tool");
|
||
if (toolChips) {
|
||
toolChips.querySelectorAll(".tm-chip").forEach((chip) => {
|
||
chip.addEventListener("click", () => {
|
||
toolChips.querySelectorAll(".tm-chip").forEach((c) => c.classList.remove("active"));
|
||
chip.classList.add("active");
|
||
TEAM.tool = chip.dataset.tool;
|
||
const isClaude = TEAM.tool === "claude";
|
||
document.getElementById("tm-tool-hint").textContent = isClaude
|
||
? "生成纯 Claude 包:.claude/agents/*.md + CLAUDE.md 总控入口"
|
||
: "生成 .toml 员工 + 总控(调度 / 审核)+ AGENTS.md";
|
||
const hint = document.getElementById("tm-agent-hint");
|
||
const inp = document.getElementById("tm-agent-model");
|
||
inp.placeholder = isClaude ? "留空=继承会话模型(推荐),或填 sonnet / opus / haiku" : "必须选择子 Agent 执行模型,如 deepseek-v4-flash";
|
||
hint.textContent = isClaude
|
||
? "Claude 只认 sonnet / opus / haiku / claude-*;留空则省略 model 字段=自动继承"
|
||
: "生成 Codex 团队时必须明确选择执行模型;总控和员工会分别按模型路由";
|
||
// 已生成的团队:切工具后重新渲染(预览扩展名随之变)
|
||
if (TEAM.team) tmRenderTeam(TEAM.team);
|
||
});
|
||
});
|
||
}
|
||
const fetchBtn = document.getElementById("tm-fetch");
|
||
if (fetchBtn) fetchBtn.addEventListener("click", tmFetchModels);
|
||
const orchFetchBtn = document.getElementById("tm-orch-fetch");
|
||
if (orchFetchBtn) orchFetchBtn.addEventListener("click", tmFetchOrchestratorModels);
|
||
const agentFetchBtn = document.getElementById("tm-agent-fetch");
|
||
if (agentFetchBtn) agentFetchBtn.addEventListener("click", tmFetchWorkerModels);
|
||
const genBtn = document.getElementById("tm-gen");
|
||
if (genBtn) genBtn.addEventListener("click", tmGenerate);
|
||
const dlBtn = document.getElementById("tm-download");
|
||
if (dlBtn) dlBtn.addEventListener("click", tmDownload);
|
||
})();
|
||
|
||
// ===========================================================================
|
||
// ②.⑥ 提示词提取(图片 → 反推生图提示词)
|
||
// ===========================================================================
|
||
let pmLoaded = false;
|
||
const PM = {
|
||
groups: [],
|
||
activeGroupId: "",
|
||
models: [],
|
||
imageDataUrl: null,
|
||
};
|
||
|
||
function pmUpdateAnalyzeBtn() {
|
||
const btn = document.getElementById("pm-analyze");
|
||
if (!btn) return;
|
||
const model = document.getElementById("pm-model").value;
|
||
btn.disabled = !(PM.imageDataUrl && model);
|
||
}
|
||
|
||
async function pmEnter() {
|
||
if (pmLoaded) return;
|
||
pmLoaded = true;
|
||
// 加载分组
|
||
try {
|
||
const r = await apic.promptGroups();
|
||
if (r && r.ok) PM.groups = r.groups || [];
|
||
} catch { PM.groups = []; }
|
||
const sel = document.getElementById("pm-group");
|
||
if (sel) {
|
||
sel.innerHTML = PM.groups.length
|
||
? PM.groups.map((g) => `<option value="${esc(g.id)}">${esc(g.name || g.id)}</option>`).join("")
|
||
: `<option value="">未配置可用分组(去『路由中转』添加)</option>`;
|
||
sel.value = PM.activeGroupId || "";
|
||
sel.onchange = () => { PM.activeGroupId = sel.value; };
|
||
}
|
||
pmUpdateAnalyzeBtn();
|
||
}
|
||
|
||
// 拉取所选分组的模型
|
||
async function pmFetchModels() {
|
||
const btn = document.getElementById("pm-fetch");
|
||
const sel = document.getElementById("pm-model");
|
||
const groupId = PM.activeGroupId || undefined;
|
||
busy(btn, true, "…");
|
||
try {
|
||
const r = await apic.promptModels(groupId);
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
|
||
PM.models = Array.isArray(r.models) ? r.models : [];
|
||
if (sel) {
|
||
sel.innerHTML = PM.models.length
|
||
? PM.models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("")
|
||
: `<option value="">该分组没有可用模型</option>`;
|
||
sel.onchange = pmUpdateAnalyzeBtn;
|
||
}
|
||
document.getElementById("pm-model-hint").textContent = `已拉取 ${PM.models.length} 个模型(需要视觉/多模态)`;
|
||
pmUpdateAnalyzeBtn();
|
||
toast(`已拉取 ${PM.models.length} 个模型`, "ok");
|
||
} catch (e) {
|
||
toast("拉取模型失败:" + (e.message || e), "err");
|
||
} finally {
|
||
busy(btn, false);
|
||
}
|
||
}
|
||
|
||
// 拖拽 / 选择图片
|
||
function pmHandleFile(file) {
|
||
if (!file) return;
|
||
if (file.type.startsWith("video/")) {
|
||
pmGrabVideoFrame(file);
|
||
return;
|
||
}
|
||
if (!file.type.startsWith("image/")) { alert("请上传图片或视频文件"); return; }
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
PM.imageDataUrl = reader.result;
|
||
document.getElementById("pm-previewImg").src = PM.imageDataUrl;
|
||
document.getElementById("pm-dzEmpty").hidden = true;
|
||
document.getElementById("pm-dzPreview").hidden = false;
|
||
pmUpdateAnalyzeBtn();
|
||
};
|
||
reader.onerror = () => toast("读取文件失败", "err");
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// 视频取帧
|
||
function pmGrabVideoFrame(file) {
|
||
const video = document.createElement("video");
|
||
video.preload = "metadata"; video.muted = true; video.src = URL.createObjectURL(file);
|
||
video.onloadedmetadata = () => { video.currentTime = Math.min(1, video.duration / 2 || 0.5); };
|
||
video.onseeked = () => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
|
||
canvas.getContext("2d").drawImage(video, 0, 0);
|
||
URL.revokeObjectURL(video.src);
|
||
PM.imageDataUrl = canvas.toDataURL("image/jpeg", 0.92);
|
||
document.getElementById("pm-previewImg").src = PM.imageDataUrl;
|
||
document.getElementById("pm-dzEmpty").hidden = true;
|
||
document.getElementById("pm-dzPreview").hidden = false;
|
||
pmUpdateAnalyzeBtn();
|
||
};
|
||
video.onerror = () => { URL.revokeObjectURL(video.src); toast("无法读取视频文件", "err"); };
|
||
}
|
||
|
||
async function pmAnalyze() {
|
||
if (!PM.imageDataUrl) return;
|
||
const model = document.getElementById("pm-model").value;
|
||
if (!model) return;
|
||
const btn = document.getElementById("pm-analyze");
|
||
btn.disabled = true;
|
||
btn.textContent = "分析中…";
|
||
pmRenderLoading();
|
||
|
||
try {
|
||
const r = await apic.promptAnalyze({
|
||
image: PM.imageDataUrl,
|
||
model,
|
||
groupId: PM.activeGroupId || undefined,
|
||
});
|
||
if (!r || !r.ok) throw new Error((r && r.error) || "分析失败");
|
||
pmRenderResult(r.result);
|
||
} catch (err) {
|
||
pmRenderError(err.message || String(err));
|
||
} finally {
|
||
btn.textContent = "开始分析";
|
||
pmUpdateAnalyzeBtn();
|
||
}
|
||
}
|
||
|
||
// 清空右侧 body 渲染空状态
|
||
function pmRenderEmpty() {
|
||
const body = document.getElementById("pm-result-body");
|
||
if (!body) return;
|
||
body.innerHTML = `
|
||
<div class="pm-empty">
|
||
<div class="pm-empty-ico">🖼️→📝</div>
|
||
<p class="pm-empty-title">上传素材,反推它的生图提示词</p>
|
||
<p class="pm-empty-sub">精准分析九个视觉维度,输出可直接复现的双语提示词</p>
|
||
<div class="pm-empty-feats"><span>七维拆解</span><span>中英双语</span><span>负面提示词</span><span>关键词标签</span></div>
|
||
</div>`;
|
||
}
|
||
function pmRenderLoading() {
|
||
const body = document.getElementById("pm-result-body");
|
||
if (!body) return;
|
||
body.innerHTML = `
|
||
<div class="pm-loading">
|
||
<div class="pm-spinner"></div>
|
||
<div class="pm-loading-text">正在分析图片…</div>
|
||
</div>`;
|
||
}
|
||
function pmRenderError(msg) {
|
||
const body = document.getElementById("pm-result-body");
|
||
if (!body) return;
|
||
body.innerHTML = `<div class="pm-error">⚠ ${esc(msg)}</div>`;
|
||
}
|
||
|
||
function pmRenderResult(data) {
|
||
const body = document.getElementById("pm-result-body");
|
||
if (!body) return;
|
||
|
||
// 置信度徽标
|
||
const conf = data.confidence || "low";
|
||
const confLabel = { high: "高", medium: "中", low: "低" }[conf] || conf;
|
||
const confHTML = `<span class="pm-meta-conf pm-conf-${conf}">${confLabel}</span>`;
|
||
|
||
// 关键词标签
|
||
const tagsHTML = (data.tags_en || []).map((t) => `<span class="pm-tag">${esc(t)}</span>`).join("");
|
||
|
||
// 维度拆解
|
||
const breakdownHTML = (data.breakdown || []).map((d) => `
|
||
<div class="pm-bd-row">
|
||
<div class="pm-bd-label">
|
||
<span class="pm-bd-label-zh">${esc(d.label_zh || "")}</span>
|
||
<span class="pm-bd-label-en">${esc(d.label_en || "")}</span>
|
||
</div>
|
||
<div class="pm-bd-values">
|
||
<span class="pm-bd-zh">${esc(d.value_zh || "")}</span>
|
||
<span class="pm-bd-en">${esc(d.value_en || "")}</span>
|
||
</div>
|
||
</div>`).join("");
|
||
|
||
// 备注
|
||
const notesHTML = data.notes_zh ? `<p class="pm-notes">${esc(data.notes_zh)}</p>` : "";
|
||
|
||
body.innerHTML = `
|
||
<div class="pm-result-head">
|
||
<span class="pm-result-title">分析结果</span>
|
||
${confHTML}
|
||
</div>
|
||
<div class="pm-content">
|
||
<div class="pm-card primary">
|
||
<div class="pm-card-head">
|
||
<span class="pm-card-title">英文提示词 <em>推荐使用</em></span>
|
||
<button class="pm-copy-btn">复制</button>
|
||
</div>
|
||
<p class="pm-prompt-text">${esc(data.prompt_en || "")}</p>
|
||
</div>
|
||
<div class="pm-card">
|
||
<div class="pm-card-head">
|
||
<span class="pm-card-title">中文对照</span>
|
||
<button class="pm-copy-btn">复制</button>
|
||
</div>
|
||
<p class="pm-prompt-text">${esc(data.prompt_zh || "")}</p>
|
||
</div>
|
||
<div class="pm-card subtle">
|
||
<div class="pm-card-head">
|
||
<span class="pm-card-title">负面提示词 <em>Negative</em></span>
|
||
<button class="pm-copy-btn">复制</button>
|
||
</div>
|
||
<p class="pm-prompt-text mono">${esc(data.negative_prompt_en || "")}</p>
|
||
<p class="pm-prompt-text zh-sub">${esc(data.negative_prompt_zh || "")}</p>
|
||
</div>
|
||
${tagsHTML ? `<div class="pm-tags-wrap">${tagsHTML}</div>` : ""}
|
||
${breakdownHTML ? `<div class="pm-breakdown">${breakdownHTML}</div>` : ""}
|
||
${notesHTML}
|
||
</div>`;
|
||
}
|
||
|
||
// ---- 事件绑定 ----
|
||
(function pmWireEvents() {
|
||
const dropzone = document.getElementById("pm-dropzone");
|
||
const fileInput = document.getElementById("pm-fileInput");
|
||
if (dropzone) {
|
||
dropzone.addEventListener("click", () => fileInput && fileInput.click());
|
||
["dragover", "dragenter"].forEach((ev) =>
|
||
dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.add("drag"); })
|
||
);
|
||
["dragleave", "drop"].forEach((ev) =>
|
||
dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.remove("drag"); })
|
||
);
|
||
dropzone.addEventListener("drop", (e) => {
|
||
const f = e.dataTransfer.files[0];
|
||
if (f) pmHandleFile(f);
|
||
});
|
||
}
|
||
if (fileInput) fileInput.addEventListener("change", (e) => pmHandleFile(e.target.files[0]));
|
||
const clearBtn = document.getElementById("pm-clearBtn");
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
PM.imageDataUrl = null;
|
||
if (fileInput) fileInput.value = "";
|
||
document.getElementById("pm-dzPreview").hidden = true;
|
||
document.getElementById("pm-dzEmpty").hidden = false;
|
||
pmRenderEmpty(); // 重置右侧为空状态
|
||
pmUpdateAnalyzeBtn();
|
||
});
|
||
}
|
||
const fetchBtn = document.getElementById("pm-fetch");
|
||
if (fetchBtn) fetchBtn.addEventListener("click", pmFetchModels);
|
||
const analyzeBtn = document.getElementById("pm-analyze");
|
||
if (analyzeBtn) analyzeBtn.addEventListener("click", pmAnalyze);
|
||
|
||
// 复制按钮 —— 事件委托(CSP 禁止内联 onclick)
|
||
const resultBody = document.getElementById("pm-result-body");
|
||
if (resultBody) {
|
||
resultBody.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".pm-copy-btn");
|
||
if (!btn) return;
|
||
const card = btn.closest(".pm-card");
|
||
if (!card) return;
|
||
const textEl = card.querySelector(".pm-prompt-text");
|
||
if (!textEl?.textContent) return;
|
||
navigator.clipboard.writeText(textEl.textContent).then(() => toast("已复制", "ok"));
|
||
});
|
||
}
|
||
})();
|
||
|
||
// ===========================================================================
|
||
// ②.⑦ Token 用量监控面板
|
||
// ===========================================================================
|
||
let tkLoaded = false;
|
||
let tkPollTimer = null;
|
||
|
||
function fmtTokens(n) {
|
||
if (!n || n < 0) return "0";
|
||
if (n < 1000) return String(n);
|
||
if (n < 1000000) return (n / 1000).toFixed(1) + "K";
|
||
if (n < 1000000000) return (n / 1000000).toFixed(2) + "M";
|
||
return (n / 1000000000).toFixed(2) + "B";
|
||
}
|
||
|
||
function fmtBadgeTokens(n) {
|
||
n = Math.max(0, Math.round(Number(n) || 0));
|
||
if (n < 1000) return n + " token";
|
||
if (n < 1000000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k token";
|
||
if (n < 1000000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M token";
|
||
return (n / 1000000000).toFixed(1).replace(/\.0$/, "") + "B token";
|
||
}
|
||
|
||
const TOKEN_PROGRESS_CAP = 5000000000;
|
||
|
||
function tokenVisualProgressPct(total) {
|
||
total = Math.max(0, Math.round(Number(total) || 0));
|
||
if (!total) return 0;
|
||
const scale = 100000;
|
||
const pct = Math.log1p(total / scale) / Math.log1p(TOKEN_PROGRESS_CAP / scale) * 100;
|
||
return Math.max(2.4, Math.min(100, pct));
|
||
}
|
||
|
||
function paintTokenPreviewSegments(total) {
|
||
const pct = tokenVisualProgressPct(total);
|
||
const segs = Array.from(document.querySelectorAll(".tk-preview-seg"));
|
||
if (!segs.length) return;
|
||
const span = 100 / segs.length;
|
||
segs.forEach((seg, i) => {
|
||
const start = i * span;
|
||
const fill = Math.max(0, Math.min(100, ((pct - start) / span) * 100));
|
||
seg.style.setProperty("--tk-preview-seg-fill", fill + "%");
|
||
});
|
||
const badge = document.getElementById("tk-preview-badge-text");
|
||
if (badge) badge.textContent = fmtBadgeTokens(total);
|
||
}
|
||
|
||
function tkRenderStatus(s) {
|
||
const dot = document.getElementById("tk-dot");
|
||
const phaseText = document.getElementById("tk-phase-text");
|
||
if (dot) dot.dataset.state = (s && s.phase) || "unknown";
|
||
if (phaseText) {
|
||
const map = { connected: "已连接", disconnected: "未连接", waiting_debug_port: "等待调试端口…", unsupported_packaged: "需官网版", transcribing: "已连接", recording: "已连接" };
|
||
phaseText.textContent = map[(s && s.phase)] || (s && s.phase) || "—";
|
||
}
|
||
}
|
||
|
||
async function tkRefresh() {
|
||
const totalEl = document.getElementById("tk-total");
|
||
const inputEl = document.getElementById("tk-input");
|
||
const outputEl = document.getElementById("tk-output");
|
||
const cachedEl = document.getElementById("tk-cached");
|
||
const estimateEl = document.getElementById("tk-estimate");
|
||
const unknownEl = document.getElementById("tk-unknown");
|
||
const dateEl = document.getElementById("tk-date");
|
||
const diagEl = document.getElementById("tk-diag");
|
||
try {
|
||
const r = await apic.tokenStatus();
|
||
tkRenderStatus({ phase: r && r.phase });
|
||
if (r && r.ok) {
|
||
const total = Number(r.total) || 0;
|
||
const input = Number(r.input) || 0;
|
||
const output = Number(r.output) || 0;
|
||
const cached = Number(r.cached) || 0;
|
||
const estimate = Number(r.estimate || r.estimated) || 0;
|
||
const unknown = Number(r.unknown) || 0;
|
||
if (totalEl) totalEl.textContent = fmtTokens(total);
|
||
if (inputEl) inputEl.textContent = fmtTokens(input);
|
||
if (outputEl) outputEl.textContent = fmtTokens(output);
|
||
if (cachedEl) cachedEl.textContent = fmtTokens(cached);
|
||
if (estimateEl) estimateEl.textContent = fmtTokens(estimate);
|
||
if (unknownEl) unknownEl.textContent = fmtTokens(unknown);
|
||
if (dateEl) dateEl.textContent = [r.date || "", r.lastSource ? "来源:" + r.lastSource : ""].filter(Boolean).join(" · ");
|
||
if (diagEl) {
|
||
const net = r.network || {};
|
||
const parts = [
|
||
r.lastSource === "session" ? "本地 session" : "页面 usage",
|
||
"worker " + (Number(net.attachedWorkers) || 0),
|
||
"Network 命中 " + (Number(net.matchedCount) || 0),
|
||
"usage " + (Number(net.usageCount) || 0),
|
||
"无 usage " + (Number(net.noUsageCount) || 0),
|
||
];
|
||
if (net.lastUsageTotal) parts.push("最近 +" + fmtTokens(Number(net.lastUsageTotal) || 0));
|
||
if (net.lastError) parts.push("错误:" + net.lastError);
|
||
diagEl.textContent = parts.join(" · ");
|
||
diagEl.title = [net.lastUsageUrl || "", net.lastNoUsageUrl || "", net.lastMatchedUrl || ""].filter(Boolean).join("\n");
|
||
}
|
||
paintTokenPreviewSegments(total);
|
||
} else {
|
||
if (totalEl) totalEl.textContent = "—";
|
||
if (inputEl) inputEl.textContent = "0";
|
||
if (outputEl) outputEl.textContent = "0";
|
||
if (cachedEl) cachedEl.textContent = "0";
|
||
if (estimateEl) estimateEl.textContent = "0";
|
||
if (unknownEl) unknownEl.textContent = "0";
|
||
if (dateEl) dateEl.textContent = r && r.error ? r.error : "";
|
||
if (diagEl) diagEl.textContent = "";
|
||
paintTokenPreviewSegments(0);
|
||
}
|
||
} catch (e) {
|
||
if (totalEl) totalEl.textContent = "—";
|
||
if (inputEl) inputEl.textContent = "0";
|
||
if (outputEl) outputEl.textContent = "0";
|
||
if (cachedEl) cachedEl.textContent = "0";
|
||
if (estimateEl) estimateEl.textContent = "0";
|
||
if (unknownEl) unknownEl.textContent = "0";
|
||
if (dateEl) dateEl.textContent = "读取失败";
|
||
if (diagEl) diagEl.textContent = "";
|
||
paintTokenPreviewSegments(0);
|
||
}
|
||
}
|
||
|
||
async function tkReset() {
|
||
if (!confirm("确定手动清零今日 Token 用量吗?此操作不可恢复。")) return;
|
||
try {
|
||
const r = await apic.tokenReset();
|
||
if (r && r.ok) { toast("已清零", "ok"); tkRefresh(); }
|
||
else throw new Error((r && r.error) || "重置失败");
|
||
} catch (e) {
|
||
toast("重置失败:" + (e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
async function tkEnter() {
|
||
tkLoaded = true;
|
||
await tkRefresh();
|
||
// 每 5 秒轮询一次(Codex 已连接且用量在变化时)
|
||
if (!tkPollTimer) tkPollTimer = setInterval(tkRefresh, 5000);
|
||
}
|
||
|
||
(function tkWireEvents() {
|
||
const refreshBtn = document.getElementById("tk-refresh");
|
||
if (refreshBtn) refreshBtn.addEventListener("click", tkRefresh);
|
||
const resetBtn = document.getElementById("tk-reset");
|
||
if (resetBtn) resetBtn.addEventListener("click", tkReset);
|
||
})();
|
||
|
||
|
||
// —— 启动 ——
|
||
wireVoiceControls();
|
||
loadMarket();
|
||
deferUI(loadSecrets, 800);
|
||
deferUI(loadVersion, 1400);
|
||
window.addEventListener("beforeunload", () => {
|
||
const snapshot = cvWorkspaceSnapshot();
|
||
if (snapshot && apic.canvasWorkspaceSaveNow) apic.canvasWorkspaceSaveNow(snapshot);
|
||
});
|