feat: 新增启动欢迎通知、流式预览节点及多项功能更新
- 新增启动弹窗通知(绿色主题,支持关闭) - 新增 StreamPreview 流式文本预览节点 - 新增 fileUpload、updateNotifier 前端 JS 模块 - 重构多个 client,统一错误处理 - 删除废弃节点 batch_nano_banana_v2、quan_neng_sheng_tu 等 - 将 .config 纳入版本控制(已清空密钥)
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
// 上传单个文件到 ComfyUI input 目录,返回服务端绝对路径
|
||||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file, file.name);
|
||||
const resp = await api.fetchApi("/upload/image", { method: "POST", body: formData });
|
||||
if (!resp.ok) throw new Error(`上传失败: ${file.name}`);
|
||||
const data = await resp.json();
|
||||
const inputDir = await getInputDir();
|
||||
// 拼成绝对路径(Windows 用反斜杠也可以,用正斜杠 Python 也认)
|
||||
return inputDir ? inputDir.replace(/\\/g, "/") + "/" + data.name : data.name;
|
||||
}
|
||||
|
||||
// 获取 ComfyUI input 目录绝对路径(缓存)
|
||||
let _inputDir = null;
|
||||
async function getInputDir() {
|
||||
if (_inputDir !== null) return _inputDir;
|
||||
try {
|
||||
const resp = await api.fetchApi("/o1key/input_dir");
|
||||
if (resp.ok) _inputDir = (await resp.json()).path;
|
||||
else _inputDir = "";
|
||||
} catch { _inputDir = ""; }
|
||||
return _inputDir;
|
||||
}
|
||||
|
||||
// 创建一个"选择文件"按钮,点击后弹出文件选择框
|
||||
// onPaths(paths: string[]) 回调拿到上传后的路径列表
|
||||
function makeUploadButton(label, accept, multiple, onPaths) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = label;
|
||||
btn.style.cssText =
|
||||
"width:100%;padding:4px 8px;cursor:pointer;margin-top:2px;" +
|
||||
"background:#3a5a3a;color:#ddd;border:1px solid #666;" +
|
||||
"border-radius:4px;font-size:12px;";
|
||||
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.multiple = multiple;
|
||||
fileInput.accept = accept;
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
btn.addEventListener("click", () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const files = Array.from(fileInput.files);
|
||||
if (!files.length) return;
|
||||
btn.textContent = "⏳ 上传中...";
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const paths = [];
|
||||
for (const f of files) paths.push(await uploadFile(f));
|
||||
onPaths(paths);
|
||||
btn.textContent = `✅ 已上传 ${files.length} 个`;
|
||||
setTimeout(() => { btn.textContent = label; }, 2000);
|
||||
} catch (e) {
|
||||
console.error("[o1key fileUpload]", e);
|
||||
btn.textContent = "❌ 上传失败";
|
||||
setTimeout(() => { btn.textContent = label; }, 2000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
fileInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
const ACCEPT = ".pdf,.txt,.md,.csv,.json,.py,.js,.ts,.html,.xml,.docx,.xlsx,.pptx,.zip,.wav,.mp3,.png,.jpg,.jpeg,.webp";
|
||||
|
||||
app.registerExtension({
|
||||
name: "o1key.fileUpload",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "LoadFile") return;
|
||||
|
||||
const origCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
origCreated?.call(this);
|
||||
|
||||
const singleWidget = this.widgets?.find(w => w.name === "单文件路径");
|
||||
const folderWidget = this.widgets?.find(w => w.name === "文件夹路径");
|
||||
|
||||
// "单文件路径"下方加按钮(支持多选,追加路径)
|
||||
if (singleWidget) {
|
||||
const btn = makeUploadButton("📂 选择文件(可多选)", ACCEPT, true, (paths) => {
|
||||
const existing = singleWidget.value?.trim();
|
||||
singleWidget.value = existing
|
||||
? existing + ", " + paths.join(", ")
|
||||
: paths.join(", ");
|
||||
singleWidget.callback?.(singleWidget.value);
|
||||
app.graph.setDirtyCanvas(true);
|
||||
});
|
||||
this.addDOMWidget("upload_single_btn", "btn", btn, {
|
||||
getValue() { return null; },
|
||||
setValue() {},
|
||||
});
|
||||
}
|
||||
|
||||
// 清空按钮:同时清空单文件路径和文件夹路径
|
||||
if (singleWidget || folderWidget) {
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.textContent = "🗑 清空文件路径";
|
||||
clearBtn.style.cssText =
|
||||
"width:100%;padding:4px 8px;cursor:pointer;margin-top:2px;" +
|
||||
"background:#5a3a3a;color:#ddd;border:1px solid #666;" +
|
||||
"border-radius:4px;font-size:12px;";
|
||||
clearBtn.addEventListener("click", () => {
|
||||
if (singleWidget) { singleWidget.value = ""; singleWidget.callback?.(""); }
|
||||
if (folderWidget) { folderWidget.value = ""; folderWidget.callback?.(""); }
|
||||
app.graph.setDirtyCanvas(true);
|
||||
});
|
||||
this.addDOMWidget("clear_paths_btn", "btn", clearBtn, {
|
||||
getValue() { return null; },
|
||||
setValue() {},
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
// ── marked.js 懒加载 ──────────────────────────────────────────────────────────
|
||||
let markedReady = null;
|
||||
function loadMarked() {
|
||||
if (markedReady) return markedReady;
|
||||
markedReady = new Promise((resolve) => {
|
||||
if (window.marked) { resolve(window.marked); return; }
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://cdn.jsdelivr.net/npm/marked/marked.min.js";
|
||||
s.onload = () => resolve(window.marked);
|
||||
s.onerror = () => resolve(null);
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return markedReady;
|
||||
}
|
||||
|
||||
// ── 节点 UI 构建 ──────────────────────────────────────────────────────────────
|
||||
function buildUI(node) {
|
||||
if (node._spContainer) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText =
|
||||
"width:100%;height:100%;box-sizing:border-box;padding:6px;" +
|
||||
"display:flex;flex-direction:column;gap:4px;";
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.style.cssText =
|
||||
"display:flex;justify-content:flex-end;gap:6px;align-items:center;";
|
||||
|
||||
const mdToggle = document.createElement("button");
|
||||
mdToggle.textContent = "MD";
|
||||
mdToggle.title = "切换 Markdown / 纯文本";
|
||||
mdToggle.style.cssText =
|
||||
"font-size:10px;padding:2px 6px;border-radius:3px;cursor:pointer;" +
|
||||
"background:#2a5a2a;color:#ccc;border:1px solid #666;";
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.textContent = "复制";
|
||||
copyBtn.style.cssText =
|
||||
"font-size:10px;padding:2px 6px;border-radius:3px;cursor:pointer;" +
|
||||
"background:#444;color:#ccc;border:1px solid #666;";
|
||||
|
||||
toolbar.appendChild(mdToggle);
|
||||
toolbar.appendChild(copyBtn);
|
||||
|
||||
const isDedicatedPreview = node.comfyClass === "StreamPreview" || node.type === "StreamPreview";
|
||||
|
||||
const content = document.createElement("div");
|
||||
if (isDedicatedPreview) {
|
||||
content.style.cssText =
|
||||
"flex:1;min-height:0;overflow:hidden;" +
|
||||
"background:#1a1a1a;border:1px solid #444;border-radius:4px;" +
|
||||
"padding:8px;box-sizing:border-box;font-size:13px;line-height:1.6;" +
|
||||
"color:#ddd;white-space:pre-wrap;word-break:break-word;";
|
||||
} else {
|
||||
content.style.cssText =
|
||||
"width:100%;min-height:60px;max-height:480px;overflow-y:auto;" +
|
||||
"background:#1a1a1a;border:1px solid #444;border-radius:4px;" +
|
||||
"padding:8px;box-sizing:border-box;font-size:13px;line-height:1.6;" +
|
||||
"color:#ddd;white-space:pre-wrap;word-break:break-word;";
|
||||
}
|
||||
|
||||
const status = document.createElement("div");
|
||||
status.style.cssText =
|
||||
"font-size:10px;color:#888;text-align:right;min-height:14px;";
|
||||
|
||||
container.appendChild(toolbar);
|
||||
container.appendChild(content);
|
||||
container.appendChild(status);
|
||||
|
||||
node._spContainer = container;
|
||||
node._spContent = content;
|
||||
node._spStatus = status;
|
||||
node._spMdToggle = mdToggle;
|
||||
node._spRawText = "";
|
||||
node._spMarkdown = true;
|
||||
|
||||
mdToggle.addEventListener("click", () => {
|
||||
node._spMarkdown = !node._spMarkdown;
|
||||
mdToggle.style.background = node._spMarkdown ? "#2a5a2a" : "#444";
|
||||
renderContent(node);
|
||||
});
|
||||
|
||||
copyBtn.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(node._spRawText).then(() => {
|
||||
copyBtn.textContent = "已复制";
|
||||
setTimeout(() => { copyBtn.textContent = "复制"; }, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
const widget = node.addDOMWidget("stream_preview_widget", "preview", container, {
|
||||
getValue() { return node._spRawText; },
|
||||
setValue(v) { },
|
||||
});
|
||||
widget.computeSize = (width) => {
|
||||
const isDedicatedPreview = node.comfyClass === "StreamPreview" || node.type === "StreamPreview";
|
||||
if (isDedicatedPreview) {
|
||||
const nodeHeight = node.size?.[1] ?? 320;
|
||||
const overhead = 60;
|
||||
return [width, Math.max(120, nodeHeight - overhead)];
|
||||
}
|
||||
return [width, 320];
|
||||
};
|
||||
|
||||
loadMarked();
|
||||
}
|
||||
|
||||
async function renderContent(node) {
|
||||
const text = node._spRawText;
|
||||
const el = node._spContent;
|
||||
if (!text) { el.innerHTML = ""; return; }
|
||||
|
||||
if (node._spMarkdown) {
|
||||
const marked = await loadMarked();
|
||||
if (marked) {
|
||||
el.style.whiteSpace = "normal";
|
||||
el.innerHTML = marked.parse(text);
|
||||
} else {
|
||||
el.style.whiteSpace = "pre-wrap";
|
||||
el.textContent = text;
|
||||
}
|
||||
} else {
|
||||
el.style.whiteSpace = "pre-wrap";
|
||||
el.textContent = text;
|
||||
}
|
||||
const isPreview = node.comfyClass === "StreamPreview" || node.type === "StreamPreview";
|
||||
if (!isPreview) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// ── 流式事件监听 ──────────────────────────────────────────────────────────────
|
||||
api.addEventListener("o1key.stream_token", (event) => {
|
||||
const { node_id, token, done } = event.detail;
|
||||
const node = app.graph.getNodeById(parseInt(node_id));
|
||||
if (!node) return;
|
||||
|
||||
buildUI(node);
|
||||
|
||||
if (done) {
|
||||
node._spStreaming = false;
|
||||
node._spStatus.textContent = "生成完成";
|
||||
node._spStatus.style.color = "#4a4";
|
||||
return;
|
||||
}
|
||||
|
||||
// 第一个 token 到来时清空上一次内容
|
||||
if (!node._spStreaming) {
|
||||
node._spStreaming = true;
|
||||
node._spRawText = "";
|
||||
}
|
||||
|
||||
node._spRawText += token;
|
||||
node._spStatus.textContent = "生成中…";
|
||||
node._spStatus.style.color = "#a84";
|
||||
renderContent(node);
|
||||
});
|
||||
|
||||
// ── 节点注册 ──────────────────────────────────────────────────────────────────
|
||||
app.registerExtension({
|
||||
name: "comfyui_o1key.streamPreview",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "StreamPreview") return;
|
||||
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (origOnNodeCreated) origOnNodeCreated.apply(this, arguments);
|
||||
buildUI(this);
|
||||
};
|
||||
|
||||
nodeType.prototype.onResize = function () {
|
||||
this.setDirtyCanvas(true, false);
|
||||
};
|
||||
|
||||
const origOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
if (origOnExecuted) origOnExecuted.apply(this, arguments);
|
||||
buildUI(this);
|
||||
|
||||
const texts = message?.text;
|
||||
if (!texts || texts.length === 0) return;
|
||||
|
||||
this._spRawText = texts[0];
|
||||
this._spStatus.textContent = "完成";
|
||||
this._spStatus.style.color = "#4a4";
|
||||
renderContent(this);
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function (o) {
|
||||
if (origOnSerialize) origOnSerialize.apply(this, arguments);
|
||||
o.sp_text = this._spRawText || "";
|
||||
o.sp_markdown = this._spMarkdown !== false;
|
||||
};
|
||||
|
||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (o) {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
buildUI(this);
|
||||
if (o.sp_text) {
|
||||
this._spRawText = o.sp_text;
|
||||
this._spMarkdown = o.sp_markdown !== false;
|
||||
this._spMdToggle.style.background = this._spMarkdown ? "#2a5a2a" : "#444";
|
||||
renderContent(this);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
api.addEventListener("o1key.update_available", (event) => {
|
||||
const message = event.detail?.message || "欢迎使用o1key工作流";
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
@keyframes o1key-fadein {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.o1key-toast-close:hover { color: #fff !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 28px;
|
||||
background: linear-gradient(135deg, #0d3320 0%, #145a32 60%, #1e8449 100%);
|
||||
color: #d5f5e3;
|
||||
border: 1px solid #27ae60;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px 16px 20px;
|
||||
font-size: 15px;
|
||||
z-index: 99999;
|
||||
box-shadow: 0 6px 24px rgba(39,174,96,0.35), 0 2px 8px rgba(0,0,0,0.5);
|
||||
max-width: 340px;
|
||||
animation: o1key-fadein 0.4s cubic-bezier(.22,.68,0,1.2);
|
||||
`;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.textContent = "🐴 o1key 工作流";
|
||||
title.style.cssText = `font-weight: bold; font-size: 13px; color: #82e0aa; letter-spacing: 0.5px;`;
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.className = "o1key-toast-close";
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: #82e0aa;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
`;
|
||||
closeBtn.onclick = () => toast.remove();
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const divider = document.createElement("div");
|
||||
divider.style.cssText = `height: 1px; background: rgba(39,174,96,0.3); margin-bottom: 10px;`;
|
||||
|
||||
const text = document.createElement("div");
|
||||
text.textContent = message;
|
||||
text.style.cssText = `line-height: 1.7; color: #d5f5e3;`;
|
||||
|
||||
toast.appendChild(header);
|
||||
toast.appendChild(divider);
|
||||
toast.appendChild(text);
|
||||
document.body.appendChild(toast);
|
||||
});
|
||||
@@ -124,7 +124,7 @@ app.registerExtension({
|
||||
// 节点头部 + 其他 widget 的高度
|
||||
// LiteGraph 节点头部约 30px,每个普通 widget 约 24px
|
||||
const NON_VIDEO_WIDGETS = (this.widgets?.filter(
|
||||
(w) => w.name !== "video_preview"
|
||||
(w) => w.name !== "video_preview_widget"
|
||||
).length ?? 0);
|
||||
const headerH = 58 + NON_VIDEO_WIDGETS * 24;
|
||||
|
||||
@@ -138,11 +138,8 @@ app.registerExtension({
|
||||
const origOnResize = nodeType.prototype.onResize;
|
||||
nodeType.prototype.onResize = function (size) {
|
||||
if (origOnResize) origOnResize.apply(this, arguments);
|
||||
if (this._videoAspectRatio && this._videoEl) {
|
||||
// 用新宽度重新计算正确高度,避免拉伸/压缩
|
||||
const innerW = Math.max(size[0] - 16, 10);
|
||||
const videoH = Math.round(innerW * this._videoAspectRatio);
|
||||
this._videoEl.style.height = videoH + "px";
|
||||
if (this._videoAspectRatio) {
|
||||
this._resizeToVideo();
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user