271 lines
8.6 KiB
JavaScript
271 lines
8.6 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
|
|
const OUTPUT_NAME = "output_text";
|
|
const VIEW_TEXT_NODE = "ViewText";
|
|
const STREAMING_SOURCE_NODES = new Set([
|
|
"ModernVLM",
|
|
"Moondream31Query",
|
|
"Moondream31Caption",
|
|
"PromptGenerateAPI",
|
|
"HostedVLMAPI",
|
|
"VLMVideoTemporalReasoner",
|
|
]);
|
|
|
|
function textMetrics(value) {
|
|
const text = String(value ?? "");
|
|
const words = text.trim() ? text.trim().split(/\s+/u).length : 0;
|
|
const lines = text ? text.split("\n").length : 0;
|
|
return `${text.length.toLocaleString()} chars · ${words.toLocaleString()} words · ${lines.toLocaleString()} lines`;
|
|
}
|
|
|
|
function makeButton(label, title, handler) {
|
|
const button = document.createElement("button");
|
|
button.textContent = label;
|
|
button.type = "button";
|
|
button.title = title;
|
|
button.addEventListener("click", handler);
|
|
Object.assign(button.style, {
|
|
color: "var(--input-text, #ddd)",
|
|
background: "var(--comfy-input-bg, #202020)",
|
|
border: "1px solid var(--border-color, #555)",
|
|
borderRadius: "5px",
|
|
padding: "3px 8px",
|
|
cursor: "pointer",
|
|
whiteSpace: "nowrap",
|
|
});
|
|
return button;
|
|
}
|
|
|
|
function ensureOutputWidget(node) {
|
|
let widget = node.widgets?.find((item) => item.name === OUTPUT_NAME);
|
|
if (widget) {
|
|
return widget;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
const header = document.createElement("div");
|
|
const status = document.createElement("span");
|
|
const meta = document.createElement("span");
|
|
const actions = document.createElement("div");
|
|
const output = document.createElement("textarea");
|
|
|
|
status.textContent = "Ready";
|
|
meta.textContent = textMetrics("");
|
|
output.readOnly = true;
|
|
output.wrap = "soft";
|
|
output.spellcheck = false;
|
|
output.setAttribute("aria-label", "VLM text output");
|
|
|
|
const copy = makeButton("Copy", "Copy complete text", async () => {
|
|
const previous = copy.textContent;
|
|
try {
|
|
await navigator.clipboard.writeText(output.value);
|
|
copy.textContent = "Copied";
|
|
} catch {
|
|
copy.textContent = "Copy failed";
|
|
}
|
|
window.setTimeout(() => {
|
|
copy.textContent = previous;
|
|
}, 1200);
|
|
});
|
|
const download = makeButton("Save", "Download output as a UTF-8 text file", () => {
|
|
const blob = new Blob([output.value], {
|
|
type: "text/plain;charset=utf-8",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = `vlm-output-${new Date().toISOString().replaceAll(":", "-")}.txt`;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
const wrap = makeButton("Wrap: on", "Toggle long-line wrapping", () => {
|
|
const enabled = output.wrap !== "off";
|
|
output.wrap = enabled ? "off" : "soft";
|
|
output.style.whiteSpace = enabled ? "pre" : "pre-wrap";
|
|
output.style.overflowX = enabled ? "auto" : "hidden";
|
|
wrap.textContent = enabled ? "Wrap: off" : "Wrap: on";
|
|
});
|
|
const follow = makeButton("Follow: on", "Follow streaming output", () => {
|
|
widget.followOutput = !widget.followOutput;
|
|
follow.textContent = widget.followOutput ? "Follow: on" : "Follow: off";
|
|
});
|
|
|
|
actions.append(wrap, follow, copy, download);
|
|
header.append(status, meta, actions);
|
|
container.append(header, output);
|
|
|
|
Object.assign(container.style, {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: "190px",
|
|
gap: "6px",
|
|
});
|
|
Object.assign(header.style, {
|
|
display: "grid",
|
|
gridTemplateColumns: "auto minmax(0, 1fr) auto",
|
|
alignItems: "center",
|
|
gap: "9px",
|
|
color: "var(--descrip-text, #aaa)",
|
|
fontSize: "11px",
|
|
});
|
|
Object.assign(meta.style, {
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
});
|
|
Object.assign(actions.style, {
|
|
display: "flex",
|
|
gap: "4px",
|
|
justifyContent: "flex-end",
|
|
});
|
|
Object.assign(output.style, {
|
|
width: "100%",
|
|
flex: "1",
|
|
minHeight: "160px",
|
|
resize: "vertical",
|
|
boxSizing: "border-box",
|
|
color: "var(--input-text, #ddd)",
|
|
background: "var(--comfy-input-bg, #202020)",
|
|
border: "1px solid var(--border-color, #555)",
|
|
borderRadius: "6px",
|
|
padding: "9px",
|
|
lineHeight: "1.45",
|
|
whiteSpace: "pre-wrap",
|
|
overflowWrap: "anywhere",
|
|
tabSize: "4",
|
|
});
|
|
|
|
widget = node.addDOMWidget(OUTPUT_NAME, "STRING", container, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
});
|
|
widget.serialize = false;
|
|
widget.inputEl = output;
|
|
widget.statusEl = status;
|
|
widget.metaEl = meta;
|
|
widget.followOutput = true;
|
|
return widget;
|
|
}
|
|
|
|
function setOutput(node, text, state = "Complete") {
|
|
const widget = ensureOutputWidget(node);
|
|
const value = Array.isArray(text) ? text.join("\n\n") : String(text ?? "");
|
|
widget.value = value;
|
|
widget.inputEl.value = value;
|
|
widget.statusEl.textContent = state;
|
|
widget.metaEl.textContent = textMetrics(value);
|
|
if (widget.followOutput && state === "Streaming…") {
|
|
widget.inputEl.scrollTop = widget.inputEl.scrollHeight;
|
|
}
|
|
node.setDirtyCanvas?.(true, true);
|
|
}
|
|
|
|
function findNode(graph, id) {
|
|
if (!graph || id == null) {
|
|
return null;
|
|
}
|
|
return graph.getNodeById?.(id)
|
|
?? graph.getNodeById?.(String(id))
|
|
?? graph.getNodeById?.(Number(id))
|
|
?? null;
|
|
}
|
|
|
|
function linkFor(graph, linkId) {
|
|
return graph?.links?.get?.(linkId)
|
|
?? graph?._links?.get?.(linkId)
|
|
?? null;
|
|
}
|
|
|
|
function isReroute(node) {
|
|
return String(node?.type ?? "").toLowerCase().includes("reroute");
|
|
}
|
|
|
|
function connectedViewTextNodes(source) {
|
|
if (!source?.graph) {
|
|
return [];
|
|
}
|
|
const found = new Set();
|
|
const visited = new Set([source.id]);
|
|
const queue = [source];
|
|
while (queue.length) {
|
|
const current = queue.shift();
|
|
for (const output of current.outputs ?? []) {
|
|
for (const linkId of output.links ?? []) {
|
|
const link = linkFor(source.graph, linkId);
|
|
const target = findNode(source.graph, link?.target_id);
|
|
if (!target || visited.has(target.id)) {
|
|
continue;
|
|
}
|
|
visited.add(target.id);
|
|
if (target.type === VIEW_TEXT_NODE) {
|
|
found.add(target);
|
|
} else if (isReroute(target)) {
|
|
queue.push(target);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [...found];
|
|
}
|
|
|
|
function updateFromProgress({ nodeId, text }) {
|
|
const source = findNode(app.rootGraph ?? app.graph, nodeId);
|
|
if (!source) {
|
|
return;
|
|
}
|
|
if (source.type === VIEW_TEXT_NODE) {
|
|
setOutput(source, text, "Streaming…");
|
|
return;
|
|
}
|
|
if (!STREAMING_SOURCE_NODES.has(source.type)) {
|
|
return;
|
|
}
|
|
for (const target of connectedViewTextNodes(source)) {
|
|
setOutput(target, text, "Streaming…");
|
|
}
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "gokayfem.vlm.view-text",
|
|
async setup() {
|
|
api.addEventListener("progress_text", ({ detail }) => {
|
|
updateFromProgress(detail ?? {});
|
|
});
|
|
},
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== VIEW_TEXT_NODE) {
|
|
return;
|
|
}
|
|
const onCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function (...args) {
|
|
const result = onCreated?.apply(this, args);
|
|
ensureOutputWidget(this);
|
|
if (Array.isArray(this.size)) {
|
|
this.setSize?.([
|
|
Math.max(this.size[0], 430),
|
|
Math.max(this.size[1], 290),
|
|
]);
|
|
}
|
|
return result;
|
|
};
|
|
const onExecuted = nodeType.prototype.onExecuted;
|
|
nodeType.prototype.onExecuted = function (message) {
|
|
const result = onExecuted?.apply(this, arguments);
|
|
setOutput(this, message?.text, "Complete");
|
|
return result;
|
|
};
|
|
},
|
|
onNodeOutputsUpdated(nodeOutputs) {
|
|
for (const [nodeId, output] of Object.entries(nodeOutputs ?? {})) {
|
|
const node = findNode(app.rootGraph ?? app.graph, nodeId);
|
|
if (node?.type === VIEW_TEXT_NODE && output?.text != null) {
|
|
setOutput(node, output.text, "Complete");
|
|
}
|
|
}
|
|
},
|
|
});
|