Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
286 lines
13 KiB
JavaScript
286 lines
13 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
||
import { PROMPT_LIBRARY } from "./promptLibrary.data.js";
|
||
|
||
// ── 适用节点 ──────────────────────────────────────────────────────────────
|
||
const TARGET_NODES = ["K3Video", "K3MotionControl"];
|
||
|
||
const STYLE_ID = "o1key-plib-styles";
|
||
const DIALOG_ID = "o1key-plib-dialog";
|
||
|
||
// 记录每个节点最近聚焦的提示词框 widget 名
|
||
const lastFocused = new WeakMap();
|
||
|
||
let dialogRoot = null;
|
||
let activeNode = null;
|
||
// 单选分类的当前选择:{ [分类名]: 词条label };多选分类:Set
|
||
const selected = {}; // 单选
|
||
const multiSelected = {}; // 多选 → Set
|
||
|
||
const CSS = `
|
||
#o1key-plib-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-plib-modal{width:min(460px,calc(100vw - 32px));height:min(760px,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-plib-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-plib-title{font-size:15px;font-weight:700;color:#eee}
|
||
.o1pl-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}
|
||
.o1pl-icon-btn:hover{color:#eee;background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.2)}
|
||
#o1pl-body{flex:1;min-height:0;overflow:auto;padding:14px 16px}
|
||
#o1pl-body::-webkit-scrollbar{width:6px}
|
||
#o1pl-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
|
||
.o1pl-cat{margin-bottom:18px}
|
||
.o1pl-cat-title{font-size:13px;font-weight:700;color:#cfcfcf;margin-bottom:10px;display:flex;align-items:center;gap:6px}
|
||
.o1pl-cat-multi{font-size:11px;font-weight:400;color:#777}
|
||
.o1pl-tags{display:flex;flex-wrap:wrap;gap:8px}
|
||
.o1pl-tag{padding:7px 14px;border:1px solid rgba(255,255,255,.16);border-radius:18px;background:rgba(255,255,255,.03);color:#ccc;font-size:13px;cursor:pointer;transition:all .12s;font-family:inherit}
|
||
.o1pl-tag:hover{background:rgba(255,255,255,.08);color:#fff}
|
||
.o1pl-tag.active{border-color:#4ade80;color:#4ade80;background:rgba(74,222,128,.08)}
|
||
#o1pl-footer{flex-shrink:0;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:flex;flex-direction:column;gap:8px}
|
||
#o1pl-preview{font-size:12px;color:#9a9a9a;line-height:1.5;max-height:54px;overflow:auto;min-height:18px}
|
||
#o1pl-preview::-webkit-scrollbar{width:5px}
|
||
#o1pl-preview::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
|
||
#o1pl-footer-row{display:flex;gap:8px;align-items:center}
|
||
#o1pl-target{flex:1;font-size:11px;color:#777;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.o1pl-btn{padding:8px 18px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent;font-family:inherit;font-weight:600}
|
||
.o1pl-btn-ghost{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14);color:#bbb}
|
||
.o1pl-btn-ghost:hover{background:rgba(255,255,255,.1);color:#eee}
|
||
.o1pl-btn-primary{background:#fff;color:#111}
|
||
.o1pl-btn-primary:hover{background:#e6e6e6}
|
||
`;
|
||
|
||
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, """).replace(/'/g, "'");
|
||
}
|
||
|
||
function iconClose() {
|
||
return `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
|
||
}
|
||
|
||
// ── 提示词框定位 ──────────────────────────────────────────────────────────
|
||
// 匹配任意「…提示词…」框,但排除「负向提示词 / negative」。
|
||
// 用包含匹配(而非前缀),以兼容 DynamicCombo 可能给子输入加的前缀名。
|
||
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?.o1plFocusBound) continue;
|
||
el.dataset.o1plFocusBound = "1";
|
||
el.addEventListener("focus", () => lastFocused.set(node, w.name));
|
||
}
|
||
}
|
||
|
||
function setWidgetValue(node, widget, value) {
|
||
widget.value = value;
|
||
const el = widget.element || widget.inputEl;
|
||
if (el && "value" in el) el.value = value;
|
||
widget.callback?.(value);
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
|
||
// 智能逗号拼接:自动在已有文本后补「,」
|
||
function appendToPrompt(node, widget, addition) {
|
||
const cur = String(widget.value || "").trim();
|
||
let next;
|
||
if (!cur) {
|
||
next = addition;
|
||
} else {
|
||
const sep = /[,,。.\s]$/.test(cur) ? "" : ",";
|
||
next = cur + sep + addition;
|
||
}
|
||
setWidgetValue(node, widget, next);
|
||
}
|
||
|
||
// ── 选择状态 → 组装短句 ───────────────────────────────────────────────────
|
||
function buildAssembled() {
|
||
const parts = [];
|
||
for (const cat of PROMPT_LIBRARY) {
|
||
if (cat.multi) {
|
||
const set = multiSelected[cat.name];
|
||
if (!set || !set.size) continue;
|
||
for (const tag of cat.tags) if (set.has(tag.label)) parts.push(tag.prompt);
|
||
} else {
|
||
const lbl = selected[cat.name];
|
||
if (!lbl) continue;
|
||
const tag = cat.tags.find(t => t.label === lbl);
|
||
if (tag) parts.push(tag.prompt);
|
||
}
|
||
}
|
||
return parts.join(",");
|
||
}
|
||
|
||
function refreshPreview() {
|
||
const el = dialogRoot?.querySelector("#o1pl-preview");
|
||
if (!el) return;
|
||
const text = buildAssembled();
|
||
el.textContent = text || "点击词条组合提示词,预览将显示在这里…";
|
||
el.style.color = text ? "#cfcfcf" : "#666";
|
||
|
||
const tgt = dialogRoot?.querySelector("#o1pl-target");
|
||
if (tgt && activeNode) {
|
||
const w = resolveTargetWidget(activeNode);
|
||
tgt.textContent = w ? `→ 插入到「${w.name}」` : "(未找到提示词框)";
|
||
}
|
||
}
|
||
|
||
// ── 渲染:灵感词库 tab ────────────────────────────────────────────────────
|
||
function renderLibraryTab() {
|
||
const body = dialogRoot.querySelector("#o1pl-body");
|
||
body.innerHTML = PROMPT_LIBRARY.map(cat => {
|
||
const tags = cat.tags.map(tag => {
|
||
let active;
|
||
if (cat.multi) active = multiSelected[cat.name]?.has(tag.label);
|
||
else active = selected[cat.name] === tag.label;
|
||
return `<button class="o1pl-tag${active ? " active" : ""}" data-cat="${escapeHtml(cat.name)}" data-label="${escapeHtml(tag.label)}" title="${escapeHtml(tag.prompt)}">${escapeHtml(tag.label)}</button>`;
|
||
}).join("");
|
||
const multiHint = cat.multi ? `<span class="o1pl-cat-multi">(多选)</span>` : "";
|
||
return `<div class="o1pl-cat"><div class="o1pl-cat-title">${escapeHtml(cat.name)}${multiHint}</div><div class="o1pl-tags">${tags}</div></div>`;
|
||
}).join("");
|
||
|
||
body.querySelectorAll(".o1pl-tag").forEach(btn => {
|
||
btn.addEventListener("click", () => toggleTag(btn.dataset.cat, btn.dataset.label));
|
||
});
|
||
}
|
||
|
||
function toggleTag(catName, label) {
|
||
const cat = PROMPT_LIBRARY.find(c => c.name === catName);
|
||
if (!cat) return;
|
||
if (cat.multi) {
|
||
if (!multiSelected[catName]) multiSelected[catName] = new Set();
|
||
const set = multiSelected[catName];
|
||
set.has(label) ? set.delete(label) : set.add(label);
|
||
} else {
|
||
// 单选:再次点击同一词条则取消,否则替换
|
||
selected[catName] = selected[catName] === label ? null : label;
|
||
}
|
||
renderLibraryTab();
|
||
refreshPreview();
|
||
}
|
||
|
||
// ── 插入到节点 ────────────────────────────────────────────────────────────
|
||
function insertText(text) {
|
||
if (!text || !activeNode) return closePanel();
|
||
const widget = resolveTargetWidget(activeNode);
|
||
if (!widget) {
|
||
alert("未找到可注入的提示词框。");
|
||
return;
|
||
}
|
||
appendToPrompt(activeNode, widget, text);
|
||
closePanel();
|
||
}
|
||
|
||
function insertAssembled() {
|
||
const text = buildAssembled();
|
||
if (!text) { closePanel(); return; }
|
||
insertText(text);
|
||
}
|
||
|
||
function resetSelection() {
|
||
for (const k of Object.keys(selected)) delete selected[k];
|
||
for (const k of Object.keys(multiSelected)) delete multiSelected[k];
|
||
}
|
||
|
||
// ── 面板开关 ──────────────────────────────────────────────────────────────
|
||
function closePanel() {
|
||
document.removeEventListener("keydown", handleKeydown);
|
||
dialogRoot?.remove();
|
||
dialogRoot = null;
|
||
activeNode = null;
|
||
}
|
||
|
||
function handleKeydown(e) {
|
||
if (e.key === "Escape") closePanel();
|
||
}
|
||
|
||
function openPanel(node) {
|
||
injectStyles();
|
||
if (dialogRoot) closePanel();
|
||
activeNode = node;
|
||
resetSelection();
|
||
trackFocus(node);
|
||
|
||
dialogRoot = document.createElement("div");
|
||
dialogRoot.id = DIALOG_ID;
|
||
dialogRoot.innerHTML = `
|
||
<div id="o1key-plib-modal" role="dialog" aria-modal="true">
|
||
<div id="o1key-plib-header">
|
||
<div id="o1key-plib-title">提示词词库</div>
|
||
<button class="o1pl-icon-btn" data-close title="关闭">${iconClose()}</button>
|
||
</div>
|
||
<div id="o1pl-body"></div>
|
||
<div id="o1pl-footer">
|
||
<div id="o1pl-preview"></div>
|
||
<div id="o1pl-footer-row">
|
||
<span id="o1pl-target"></span>
|
||
<button class="o1pl-btn o1pl-btn-ghost" data-reset>清空</button>
|
||
<button class="o1pl-btn o1pl-btn-primary" data-insert>插入组合</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(dialogRoot);
|
||
|
||
dialogRoot.addEventListener("click", (e) => { if (e.target === dialogRoot) closePanel(); });
|
||
dialogRoot.querySelector("[data-close]").addEventListener("click", closePanel);
|
||
dialogRoot.querySelector("[data-reset]").addEventListener("click", () => { resetSelection(); renderLibraryTab(); refreshPreview(); });
|
||
dialogRoot.querySelector("[data-insert]").addEventListener("click", insertAssembled);
|
||
document.addEventListener("keydown", handleKeydown);
|
||
|
||
renderLibraryTab();
|
||
refreshPreview();
|
||
}
|
||
|
||
// ── 扩展注册:在目标节点上添加常驻「词库」按钮 widget ──────────────────────
|
||
app.registerExtension({
|
||
name: "o1key.promptLibrary",
|
||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (!TARGET_NODES.includes(nodeData.name)) return;
|
||
|
||
const origCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
const r = origCreated?.apply(this, arguments);
|
||
// 常驻按钮控件:点击打开词库面板
|
||
this.addWidget("button", "📖 提示词词库", null, () => openPanel(this), { serialize: false });
|
||
// 绑定提示词框聚焦追踪(widget 此时可能尚未建好,延迟一拍)
|
||
setTimeout(() => trackFocus(this), 0);
|
||
return r;
|
||
};
|
||
|
||
// 右键菜单兜底入口
|
||
const origMenu = nodeType.prototype.getExtraMenuOptions;
|
||
nodeType.prototype.getExtraMenuOptions = function (canvasRef, options) {
|
||
origMenu?.call(this, canvasRef, options);
|
||
options.unshift({ content: "📖 提示词词库", callback: () => openPanel(this) });
|
||
};
|
||
},
|
||
});
|