feat: 新增启动欢迎通知、流式预览节点及多项功能更新

- 新增启动弹窗通知(绿色主题,支持关闭)
- 新增 StreamPreview 流式文本预览节点
- 新增 fileUpload、updateNotifier 前端 JS 模块
- 重构多个 client,统一错误处理
- 删除废弃节点 batch_nano_banana_v2、quan_neng_sheng_tu 等
- 将 .config 纳入版本控制(已清空密钥)
This commit is contained in:
Jony
2026-04-13 00:49:51 +08:00
parent bbc5f4a2c4
commit 92bcf65d14
28 changed files with 1381 additions and 3518 deletions
+122
View File
@@ -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() {},
});
}
};
},
});
+210
View File
@@ -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);
}
};
},
});
+73
View File
@@ -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);
});
+3 -6
View File
@@ -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();
}
};
},