import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; // ── 适用节点 ────────────────────────────────────────────────────────────── const TARGET_NODES = ["K3Video"]; const STYLE_ID = "o1key-elem-styles"; const DIALOG_ID = "o1key-elem-dialog"; let dialogRoot = null; let activeNode = null; let currentTab = "mine"; let myElements = []; // /mine 列表缓存 let createRefType = "image"; // 参考方式:image=多图主体, video=视频主体 let createRefs = []; // 创建表单:参考图 { file, preview } 列表(点创建时才上传) let createFrontal = null; // 创建表单:正面图 { file, preview } let createVideo = null; // 创建表单:参考视频 { file, preview } let createTags = []; // 创建表单:已选标签 id 列表(如 o_101) let pollTimer = null; // 轮询定时器 // 主体标签(对应腾讯云 TagList,o_101~o_108) const ELEMENT_TAGS = [ { id: "o_101", name: "热梗" }, { id: "o_102", name: "人物" }, { id: "o_103", name: "动物" }, { id: "o_104", name: "道具" }, { id: "o_105", name: "服饰" }, { id: "o_106", name: "场景" }, { id: "o_107", name: "特效" }, { id: "o_108", name: "其他" }, ]; // 记录每个节点最近聚焦的提示词框 widget 名(与 promptLibrary 同思路,但本文件独立) const lastFocused = new WeakMap(); const CSS = ` #o1key-elem-dialog{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.52);color:#ddd;font-family:inherit} #o1key-elem-modal{width:min(560px,calc(100vw - 32px));height:min(720px,calc(100vh - 48px));display:flex;flex-direction:column;overflow:hidden;border:1px solid rgba(255,255,255,.12);border-radius:10px;background:var(--comfy-menu-bg,#1a1a1a);box-shadow:0 24px 80px rgba(0,0,0,.5)} #o1key-elem-header{height:50px;padding:0 16px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;flex-shrink:0} #o1key-elem-title{font-size:15px;font-weight:700;color:#eee} .o1e-icon-btn{width:30px;height:30px;border:1px solid rgba(255,255,255,.12);border-radius:6px;background:rgba(255,255,255,.04);color:#aaa;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s} .o1e-icon-btn:hover{color:#eee;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)} #o1e-tabs{display:flex;gap:18px;padding:10px 16px 0;flex-shrink:0;border-bottom:1px solid rgba(255,255,255,.06)} .o1e-tab{background:none;border:none;color:#888;font-size:14px;font-weight:600;padding:6px 2px 10px;cursor:pointer;border-bottom:2px solid transparent;font-family:inherit} .o1e-tab.active{color:#fff;border-bottom-color:#fff} #o1e-body{flex:1;min-height:0;overflow:auto;padding:14px 16px} #o1e-body::-webkit-scrollbar{width:6px} #o1e-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px} .o1e-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px} .o1e-card{border:1px solid rgba(255,255,255,.1);border-radius:8px;overflow:hidden;background:rgba(255,255,255,.03);transition:all .12s;display:flex;flex-direction:column} .o1e-card:hover{border-color:rgba(255,255,255,.22)} .o1e-thumb{width:100%;aspect-ratio:1;object-fit:cover;background:#111;cursor:pointer;display:block} .o1e-card-body{padding:8px 10px;display:flex;flex-direction:column;gap:4px} .o1e-card-name{font-size:13px;font-weight:700;color:#eee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .o1e-card-status{font-size:11px} .o1e-st-succeed{color:#4ade80} .o1e-st-pending{color:#e0b341} .o1e-st-failed{color:#df7777} .o1e-card-actions{display:flex;gap:6px;margin-top:4px} .o1e-mini-btn{flex:1;padding:5px 0;border-radius:5px;font-size:12px;cursor:pointer;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#ccc;font-family:inherit} .o1e-mini-btn:hover{background:rgba(255,255,255,.1);color:#fff} .o1e-mini-btn.danger:hover{background:rgba(223,119,119,.15);color:#f3a0a0;border-color:rgba(223,119,119,.4)} .o1e-mini-btn.referenced{background:rgba(74,222,128,.16);color:#4ade80;border-color:rgba(74,222,128,.45)} .o1e-mini-btn.referenced:hover{background:rgba(74,222,128,.24);color:#6ee79a} .o1e-card.is-ref{border-color:rgba(74,222,128,.5);box-shadow:0 0 0 1px rgba(74,222,128,.25) inset} .o1e-ref-badge{position:absolute;top:6px;left:6px;width:20px;height:20px;border-radius:50%;background:#22a559;color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;box-shadow:0 1px 4px rgba(0,0,0,.4)} .o1e-thumb-wrap{position:relative} .o1e-empty{padding:40px 0;text-align:center;color:#777;font-size:13px;line-height:1.8} .o1e-field{margin-bottom:14px} .o1e-label{display:block;font-size:12px;color:#bbb;margin-bottom:6px} .o1e-label .req{color:#df7777;margin-left:2px} .o1e-input,.o1e-textarea{width:100%;box-sizing:border-box;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.14);border-radius:6px;color:#eee;font-size:13px;padding:8px 10px;font-family:inherit} .o1e-textarea{resize:vertical;min-height:54px} .o1e-input:focus,.o1e-textarea:focus{outline:none;border-color:rgba(255,255,255,.32)} .o1e-imgrow{display:flex;flex-wrap:wrap;gap:8px} .o1e-imgbox{position:relative;width:72px;height:72px;border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,.14);background:#111} .o1e-imgbox img{width:100%;height:100%;object-fit:cover} .o1e-imgbox .del{position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:4px;background:rgba(0,0,0,.6);color:#fff;border:none;cursor:pointer;font-size:12px;line-height:1;display:flex;align-items:center;justify-content:center} .o1e-addbox{width:72px;height:72px;border-radius:6px;border:1px dashed rgba(255,255,255,.25);background:rgba(255,255,255,.03);color:#888;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:24px} .o1e-addbox:hover{color:#fff;border-color:rgba(255,255,255,.4)} .o1e-addbox.busy{opacity:.5;pointer-events:none} #o1e-footer{flex-shrink:0;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:flex;flex-direction:column;gap:8px} #o1e-status{font-size:12px;color:#9a9a9a;min-height:16px} #o1e-status[data-kind="error"]{color:#df7777} #o1e-status[data-kind="success"]{color:#63bf8a} #o1e-footer-row{display:flex;gap:8px;align-items:center;justify-content:flex-end} .o1e-btn{padding:8px 18px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent;font-family:inherit;font-weight:600} .o1e-btn-ghost{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14);color:#bbb} .o1e-btn-ghost:hover{background:rgba(255,255,255,.1);color:#eee} .o1e-btn-primary{background:#fff;color:#111} .o1e-btn-primary:hover{background:#e6e6e6} .o1e-btn-primary:disabled{opacity:.5;cursor:not-allowed} .o1e-seg{display:flex;gap:0;border:1px solid rgba(255,255,255,.14);border-radius:6px;overflow:hidden;width:fit-content} .o1e-seg button{background:rgba(255,255,255,.03);border:none;color:#aaa;font-size:12px;padding:7px 16px;cursor:pointer;font-family:inherit} .o1e-seg button.active{background:#fff;color:#111;font-weight:600} .o1e-hint{font-size:11px;color:#777;margin-top:6px;line-height:1.6} .o1e-tags{display:flex;flex-wrap:wrap;gap:7px} .o1e-tag-chip{padding:5px 12px;border-radius:14px;font-size:12px;cursor:pointer;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.04);color:#bbb;font-family:inherit} .o1e-tag-chip.on{background:rgba(255,255,255,.92);color:#111;border-color:#fff;font-weight:600} .o1e-videocard{position:relative;display:flex;align-items:center;gap:10px;width:100%;max-width:320px;box-sizing:border-box;padding:10px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.04)} .o1e-videocard-icon{font-size:24px;line-height:1;flex-shrink:0} .o1e-videocard-info{min-width:0;flex:1} .o1e-videocard-name{font-size:12px;color:#eee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .o1e-videocard-size{font-size:11px;color:#888;margin-top:2px} .o1e-videocard .del{position:absolute;top:6px;right:6px;width:18px;height:18px;border-radius:4px;background:rgba(0,0,0,.6);color:#fff;border:none;cursor:pointer;font-size:12px;line-height:1;display:flex;align-items:center;justify-content:center} .o1e-addbox.video{width:140px;height:90px} `; function injectStyles() { if (document.getElementById(STYLE_ID)) return; const el = document.createElement("style"); el.id = STYLE_ID; el.textContent = CSS; document.head.appendChild(el); } function escapeHtml(v) { return String(v ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } function iconClose() { return ``; } function setStatus(msg, kind = "muted") { const el = dialogRoot?.querySelector("#o1e-status"); if (!el) return; el.textContent = msg || ""; el.dataset.kind = kind; } // ── 后端代理调用 ────────────────────────────────────────────────────────── async function apiGetMine(includeAll = false) { const q = includeAll ? "?include_all=true" : ""; const resp = await api.fetchApi(`/o1key/element/mine${q}`); const data = await resp.json(); if (!data.success) throw new Error(data.message || "获取主体列表失败"); return Array.isArray(data.data) ? data.data : []; } async function apiUpload(file) { const form = new FormData(); form.append("file", file); const resp = await api.fetchApi("/o1key/element/upload", { method: "POST", body: form }); const data = await resp.json(); if (!data.success) throw new Error(data.message || "图片上传失败"); return data.data; // { url, ... } } async function apiCreate(payload) { const resp = await api.fetchApi("/o1key/element/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await resp.json(); if (!data.success) throw new Error(data.message || "创建主体失败"); return data.data; // { id, element_id, status, ... } } async function apiRefresh(taskId) { const resp = await api.fetchApi("/o1key/element/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task_id: taskId }), }); const data = await resp.json(); if (!data.success) throw new Error(data.message || "刷新状态失败"); return data.data; // { element, detail } } async function apiDelete(elementId) { const resp = await api.fetchApi("/o1key/element/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ element_id: elementId }), }); const data = await resp.json(); if (!data.success) throw new Error(data.message || "删除主体失败"); return true; } // ── 提示词框定位(注入【@名称】)────────────────────────────────────────── function isPromptWidget(w) { if (!w || typeof w.name !== "string") return false; const n = w.name; if (!n.includes("提示词") && !/prompt/i.test(n)) return false; if (n.includes("负向") || /negative/i.test(n)) return false; return true; } function listPromptWidgets(node) { return (node?.widgets || []).filter(isPromptWidget); } function resolveTargetWidget(node) { const widgets = listPromptWidgets(node); if (!widgets.length) return null; const remembered = lastFocused.get(node); if (remembered) { const hit = widgets.find(w => w.name === remembered); if (hit) return hit; } return widgets.find(w => w.name.includes("正向") || w.name === "提示词") || widgets[0]; } function trackFocus(node) { for (const w of listPromptWidgets(node)) { const el = w.element || w.inputEl; if (!el || el.dataset?.o1eFocusBound) continue; el.dataset.o1eFocusBound = "1"; el.addEventListener("focus", () => lastFocused.set(node, w.name)); } } // 收集当前节点所有提示词框中已引用的主体名称集合(用于显示"已引用"状态) function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function getReferencedNames(node) { const names = new Set(); if (!node) return names; const re = /【@([^】]+)】/g; for (const w of listPromptWidgets(node)) { const txt = String(w.value || ""); let m; while ((m = re.exec(txt)) !== null) names.add(m[1].trim()); } return names; } // 把【@名称】插入到目标提示词框 function insertReference(name) { if (!activeNode) return; const widget = resolveTargetWidget(activeNode); if (!widget) { setStatus("未找到可注入的提示词框", "error"); return; } const token = `【@${name}】`; const cur = String(widget.value || ""); const next = cur ? (/[\s,,。.]$/.test(cur) ? cur + token : cur + token) : token; widget.value = next; const el = widget.element || widget.inputEl; if (el && "value" in el) el.value = next; widget.callback?.(next); activeNode.setDirtyCanvas?.(true, true); setStatus(`已插入【@${name}】到「${widget.name}」`, "success"); refreshRefBadges(); } // 从所有提示词框移除某主体的全部【@名称】引用 function removeReference(name) { if (!activeNode) return; const re = new RegExp(`【@${escapeRegExp(name)}】`, "g"); let removed = 0; for (const w of listPromptWidgets(activeNode)) { const cur = String(w.value || ""); if (!re.test(cur)) continue; re.lastIndex = 0; // 连带清掉引用前后可能多出的一个分隔符,避免留下空逗号 const next = cur.replace(new RegExp(`\\s*[,,]?\\s*【@${escapeRegExp(name)}】`, "g"), "") .replace(new RegExp(`【@${escapeRegExp(name)}】\\s*[,,]?\\s*`, "g"), ""); if (next === cur) continue; w.value = next; const el = w.element || w.inputEl; if (el && "value" in el) el.value = next; w.callback?.(next); removed += 1; } activeNode.setDirtyCanvas?.(true, true); setStatus(removed ? `已取消引用【@${name}】` : `未找到【@${name}】`, removed ? "muted" : "error"); refreshRefBadges(); } // 点"引用"按钮:已引用则取消,未引用则插入 function toggleReference(name) { if (getReferencedNames(activeNode).has(name)) removeReference(name); else insertReference(name); } // 根据当前提示词内容,刷新卡片上的"已引用"勾选状态(不重新拉列表) function refreshRefBadges() { if (!dialogRoot) return; const refs = getReferencedNames(activeNode); dialogRoot.querySelectorAll(".o1e-card[data-cardname]").forEach(card => { const on = refs.has(card.dataset.cardname); card.classList.toggle("is-ref", on); const badge = card.querySelector(".o1e-ref-badge"); if (badge) badge.style.display = on ? "flex" : "none"; const btn = card.querySelector("[data-use]"); if (btn) { btn.classList.toggle("referenced", on); btn.textContent = on ? "✓ 已引用" : "引用"; } }); } // ── 渲染:我的主体 ──────────────────────────────────────────────────────── const STATUS_LABEL = { succeed: "可用", pending: "生成中…", failed: "失败" }; async function loadMine() { setStatus("正在加载主体列表…"); try { myElements = await apiGetMine(true); // 含 pending/failed,便于看进度 renderMineTab(); setStatus(myElements.length ? `共 ${myElements.length} 个主体` : ""); } catch (e) { myElements = []; renderMineTab(); setStatus(e.message || "加载失败", "error"); } } // 外链缩略图经后端同源代理,绕过 ComfyUI 的 CSP(img-src 'self')限制 function proxyImage(url) { if (!url) return ""; if (/^(data:|blob:)/.test(url)) return url; // 本地预览无需代理 return `/o1key/element/image?url=${encodeURIComponent(url)}`; } function renderMineTab() { const body = dialogRoot.querySelector("#o1e-body"); if (!myElements.length) { body.innerHTML = `
还没有主体
切到「创建主体」上传参考图来创建
`; return; } const cards = myElements.map(e => { const st = e.status || "succeed"; const stCls = st === "succeed" ? "o1e-st-succeed" : st === "failed" ? "o1e-st-failed" : "o1e-st-pending"; const stTxt = STATUS_LABEL[st] || st; const inner = e.frontal_image ? `` : `
`; const thumb = `
${inner}
`; const useBtn = st === "succeed" ? `` : ""; const delBtn = e.element_id ? `` : ""; return `
${thumb}
${escapeHtml(e.name)}
${escapeHtml(stTxt)}
${useBtn}${delBtn}
`; }).join(""); body.innerHTML = `
${cards}
`; body.querySelectorAll("[data-use]").forEach(b => b.addEventListener("click", () => toggleReference(b.dataset.use))); body.querySelectorAll(".o1e-thumb[data-status='succeed']").forEach(t => t.addEventListener("click", () => toggleReference(t.dataset.name))); body.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteElement(b.dataset.del, b.dataset.delname))); refreshRefBadges(); } async function deleteElement(elementId, name) { if (!confirm(`确定删除主体「${name}」?此操作会同时删除云端主体,不可恢复。`)) return; setStatus(`正在删除「${name}」…`); try { await apiDelete(elementId); setStatus(`已删除「${name}」`, "success"); await loadMine(); } catch (e) { setStatus(e.message || "删除失败", "error"); } } // ── 渲染:创建主体 ──────────────────────────────────────────────────────── const pendingForm = { name: "", desc: "", voiceId: "" }; // 创建表单文本暂存(切标签不丢) function renderCreateTab() { const body = dialogRoot.querySelector("#o1e-body"); body.innerHTML = `
`; renderReferArea(); renderTags(); const nameEl = dialogRoot.querySelector("#o1e-name"); const descEl = dialogRoot.querySelector("#o1e-desc"); const voiceEl = dialogRoot.querySelector("#o1e-voice"); nameEl.value = pendingForm.name || ""; descEl.value = pendingForm.desc || ""; voiceEl.value = pendingForm.voiceId || ""; nameEl.addEventListener("input", e => pendingForm.name = e.target.value); descEl.addEventListener("input", e => pendingForm.desc = e.target.value); voiceEl.addEventListener("input", e => pendingForm.voiceId = e.target.value); dialogRoot.querySelectorAll("#o1e-reftype button").forEach(b => b.addEventListener("click", () => { if (createRefType === b.dataset.rt) return; createRefType = b.dataset.rt; dialogRoot.querySelectorAll("#o1e-reftype button").forEach(x => x.classList.toggle("active", x.dataset.rt === createRefType)); renderReferArea(); })); } // 参考区:按 image / video 切换 function renderReferArea() { const area = dialogRoot.querySelector("#o1e-refer-area"); const hint = dialogRoot.querySelector("#o1e-rt-hint"); if (!area) return; if (createRefType === "video") { if (hint) hint.textContent = "MP4/MOV,3~8秒,16:9 或 9:16,1080P,≤200MB;有声视频含人声会触发音色定制。"; area.innerHTML = `
`; renderVideoRow(); } else { if (hint) hint.textContent = "用多张图片设定主体外观:1张正面图 + 1~3张其他角度/特写图。jpg/png,≤10MB,≥300px,宽高比 1:2.5~2.5:1。"; area.innerHTML = `
`; renderFrontalRow(); renderReferRow(); } } function renderTags() { const box = dialogRoot.querySelector("#o1e-tags"); if (!box) return; box.innerHTML = ELEMENT_TAGS.map(t => ``).join(""); box.querySelectorAll("[data-tag]").forEach(b => b.addEventListener("click", () => { const id = b.dataset.tag; const i = createTags.indexOf(id); if (i >= 0) createTags.splice(i, 1); else createTags.push(id); b.classList.toggle("on"); })); } function renderFrontalRow() { const row = dialogRoot.querySelector("#o1e-frontal-row"); if (!row) return; if (createFrontal) { row.innerHTML = `
`; row.querySelector("[data-delfrontal]").addEventListener("click", () => { revokePreview(createFrontal); createFrontal = null; renderFrontalRow(); }); } else { row.innerHTML = `
+
`; row.querySelector("[data-addfrontal]").addEventListener("click", () => pickImage(item => { createFrontal = item; renderFrontalRow(); })); } } function renderReferRow() { const row = dialogRoot.querySelector("#o1e-refer-row"); if (!row) return; let html = createRefs.map((it, i) => `
`).join(""); if (createRefs.length < 3) html += `
+
`; row.innerHTML = html; row.querySelectorAll("[data-delrefer]").forEach(b => b.addEventListener("click", () => { const i = Number(b.dataset.delrefer); revokePreview(createRefs[i]); createRefs.splice(i, 1); renderReferRow(); })); const add = row.querySelector("[data-addrefer]"); if (add) add.addEventListener("click", () => pickImage(item => { createRefs.push(item); renderReferRow(); })); } function renderVideoRow() { const row = dialogRoot.querySelector("#o1e-video-row"); if (!row) return; if (createVideo) { // CSP 禁止加载 blob: 媒体,改用文件卡片(图标+文件名+大小),不嵌入