Files

375 lines
13 KiB
JavaScript

import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { ComfyWidgets } from "/scripts/widgets.js";
const CHAT_CLASSES = ["ChatNode", "Chat"];
const VIEWER_CLASS = "ChatHistoryViewer";
const ROUTE_CHAT = "/chat_optimize/chat";
let historyModal = null;
function ensureHistoryModal() {
if (historyModal) return historyModal;
const overlay = document.createElement("div");
overlay.id = "chat-optimize-history-modal";
overlay.style.position = "fixed";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.display = "none";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(0, 0, 0, 0.6)";
overlay.style.zIndex = "9999";
const panel = document.createElement("div");
panel.style.width = "min(900px, 92%)";
panel.style.height = "80%";
panel.style.background = "#111";
panel.style.color = "#ddd";
panel.style.border = "1px solid #333";
panel.style.borderRadius = "8px";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.boxShadow = "0 10px 30px rgba(0, 0, 0, 0.5)";
const header = document.createElement("div");
header.style.display = "flex";
header.style.alignItems = "center";
header.style.justifyContent = "space-between";
header.style.padding = "10px 12px";
header.style.borderBottom = "1px solid #333";
header.style.background = "#151515";
const title = document.createElement("div");
title.textContent = "Chat History";
title.style.fontSize = "14px";
title.style.fontWeight = "600";
const closeBtn = document.createElement("button");
closeBtn.textContent = "Close";
closeBtn.style.background = "#222";
closeBtn.style.color = "#ddd";
closeBtn.style.border = "1px solid #444";
closeBtn.style.borderRadius = "6px";
closeBtn.style.padding = "6px 10px";
closeBtn.style.cursor = "pointer";
closeBtn.addEventListener("click", () => closeHistoryModal());
header.appendChild(title);
header.appendChild(closeBtn);
const content = document.createElement("pre");
content.style.flex = "1";
content.style.margin = "0";
content.style.padding = "12px";
content.style.overflow = "auto";
content.style.whiteSpace = "pre-wrap";
content.style.fontFamily = "monospace";
content.style.fontSize = "13px";
panel.appendChild(header);
panel.appendChild(content);
overlay.appendChild(panel);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeHistoryModal();
});
document.body.appendChild(overlay);
const onKey = (e) => {
if (e.key === "Escape") closeHistoryModal();
};
document.addEventListener("keydown", onKey);
historyModal = { overlay, content, onKey };
return historyModal;
}
function openHistoryModal(text) {
const modal = ensureHistoryModal();
modal.content.textContent = text || "";
modal.overlay.style.display = "flex";
setTimeout(() => {
modal.content.scrollTop = modal.content.scrollHeight;
}, 0);
}
function closeHistoryModal() {
if (!historyModal) return;
historyModal.overlay.style.display = "none";
}
function isHistoryModalOpen() {
return historyModal && historyModal.overlay.style.display !== "none";
}
function setWidgetValue(node, name, value) {
if (!node.widgets) return;
const w = node.widgets.find((w) => w.name === name);
if (w) {
w.value = value ?? "";
if (w.onChange) w.onChange(value);
node.setDirtyCanvas(true, true);
}
}
function addOrGetWidget(node, type, name, opts = {}) {
if (!node.widgets) node.widgets = [];
let w = node.widgets.find((w) => w.name === name);
if (w) return w;
w = node.addWidget(type, name, opts.default ?? "", () => { }, opts);
return w;
}
async function postJSON(path, body) {
const res = await api.fetchApi(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
}
function propagateOutput(node, slotIndex, value) {
if (!node.outputs || !node.outputs[slotIndex] || !node.outputs[slotIndex].links) return;
node.outputs[slotIndex].links.forEach((linkId) => {
const link = app.graph.links[linkId];
if (!link) return;
const targetNode = app.graph.getNodeById(link.target_id);
if (!targetNode) return;
if (targetNode.inputs && targetNode.inputs[link.target_slot] && targetNode.inputs[link.target_slot].widget) {
const widgetName = targetNode.inputs[link.target_slot].widget.name;
const w = targetNode.widgets?.find(w => w.name === widgetName);
if (w) {
w.value = value;
if (w.onChange) w.onChange(value);
targetNode.setDirtyCanvas(true, true);
return;
}
}
});
}
function getLLMConfig(node) {
const inputIndex = node.inputs?.findIndex(i => i.name === "llm_config");
if (inputIndex === undefined || inputIndex === -1) return null;
const input = node.inputs[inputIndex];
if (!input.link) return null;
const linkId = input.link;
const link = app.graph.links[linkId];
if (!link) return null;
const sourceNode = app.graph.getNodeById(link.origin_id);
if (!sourceNode) return null;
const config = {};
const providerW = sourceNode.widgets?.find(w => w.name === "provider");
if (providerW) config.provider = providerW.value;
const baseUrlW = sourceNode.widgets?.find(w => w.name === "base_url");
if (baseUrlW) config.base_url = baseUrlW.value;
const modelW = sourceNode.widgets?.find(w => w.name === "model_name");
if (modelW) config.model_name = modelW.value;
const tempW = sourceNode.widgets?.find(w => w.name === "temperature");
if (tempW) config.temperature = tempW.value;
const topPW = sourceNode.widgets?.find(w => w.name === "top_p");
if (topPW) config.top_p = topPW.value;
const maxTokensW = sourceNode.widgets?.find(w => w.name === "max_new_tokens");
if (maxTokensW) config.max_new_tokens = maxTokensW.value;
const tokenW = sourceNode.widgets?.find(w => w.name === "hf_token");
if (tokenW) config.hf_token = tokenW.value;
const hfApiUrlW = sourceNode.widgets?.find(w => w.name === "hf_api_url");
if (hfApiUrlW) config.hf_api_url = hfApiUrlW.value;
const apiKeyW = sourceNode.widgets?.find(w => w.name === "api_key");
if (apiKeyW) config.api_key = apiKeyW.value;
return config;
}
function updateWidgetLock(node) {
const input = node.inputs?.find(i => i.name === "llm_config");
const isConnected = input && input.link !== null;
["base_url", "model_name"].forEach(name => {
const w = node.widgets?.find(w => w.name === name);
if (w) {
w.disabled = isConnected;
}
});
}
function setupChatNode(node) {
console.log("[ChatNode] Setting up");
let statusWidget = addOrGetWidget(node, "text", "status", { default: "idle" });
if (statusWidget.inputEl) {
statusWidget.inputEl.readOnly = true;
statusWidget.inputEl.style.fontSize = "11px";
statusWidget.inputEl.style.color = "#888";
statusWidget.inputEl.style.backgroundColor = "#1a1a1a";
statusWidget.inputEl.style.border = "1px solid #333";
}
let historyStore = addOrGetWidget(node, "hidden", "history_preview", { default: "" });
let responseStore = addOrGetWidget(node, "hidden", "assistant_response_preview", { default: "" });
const sendAction = async () => {
const actionWidget = node.widgets?.find((w) => w.name === "action");
const action = actionWidget ? actionWidget.value : "send";
try {
statusWidget.value = `working: ${action}...`;
node.setDirtyCanvas(true, true);
const payload = {};
const excludeWidgets = ["status", "history_preview", "assistant_response_preview", "auto_clear_input"];
(node.widgets || []).forEach((w) => {
if (excludeWidgets.includes(w.name)) return;
payload[w.name] = w.value;
});
payload["action"] = action;
const llmConfig = getLLMConfig(node);
if (llmConfig) payload["llm_config"] = llmConfig;
const data = await postJSON(ROUTE_CHAT, payload);
responseStore.value = data.assistant_response || "";
historyStore.value = data.readable_history || "";
propagateOutput(node, 0, data.assistant_response || "");
propagateOutput(node, 1, data.readable_history || "");
const autoClear = node.widgets?.find((w) => w.name === "auto_clear_input")?.value;
if (action === "send" && autoClear) {
setWidgetValue(node, "user_message", "");
}
statusWidget.value = "done";
node.setDirtyCanvas(true, true);
console.log("[ChatNode] Response received");
} catch (err) {
console.error("[ChatNode] Error:", err);
statusWidget.value = `error: ${err.message}`;
node.setDirtyCanvas(true, true);
}
};
node.onConnectionsChange = () => setTimeout(() => updateWidgetLock(node), 100);
setTimeout(() => updateWidgetLock(node), 100);
const existingButton = node.widgets?.find(w => w.name === "Execute");
if (!existingButton) {
node.addWidget("button", "Execute", null, () => sendAction());
}
}
function setupHistoryViewer(node) {
console.log("[ChatHistoryViewer] Setting up stable scroll version");
// 1. Find or create the history widget
let historyWidget = node.widgets?.find(w => w.name === "history");
if (!historyWidget) {
historyWidget = node.addWidget("text", "history", "", () => {}, { multiline: true });
}
const getHistoryText = () => historyWidget.value ?? historyWidget.inputEl?.value ?? "";
const existingButton = node.widgets?.find(w => w.name === "Open History");
if (!existingButton) {
node.addWidget("button", "Open History", null, () => openHistoryModal(getHistoryText()));
}
// 2. Clear default drawing of text on canvas
node.onDrawForeground = function(ctx) {
// Stop default text preview if any
};
// 3. Configure the textarea
if (historyWidget.inputEl) {
const el = historyWidget.inputEl;
el.readOnly = true;
el.style.display = "block"; // Force visible
el.style.visibility = "visible";
el.style.pointerEvents = "auto"; // Ensure scrollable
el.style.height = "100%";
el.style.width = "100%";
el.style.backgroundColor = "#121212";
el.style.color = "#ddd";
el.style.fontFamily = "monospace";
el.style.fontSize = "13px";
el.style.padding = "10px";
el.style.border = "none";
el.style.boxSizing = "border-box";
el.style.resize = "none";
el.style.overflowY = "scroll";
el.style.whiteSpace = "pre-wrap";
// Custom scrollbar
el.parentElement.style.overflow = "hidden"; // Container shouldn't scroll
}
// 4. Override widget visibility when connected
// ComfyUI usually hides widgets linked to inputs. We want this one visible.
const originalUpdate = node.onNodeCreated;
historyWidget.type = "text"; // Keep it as text so it renders the textarea
// Prevent ComfyUI from hiding it
const originalOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function() {
if (originalOnConnectionsChange) originalOnConnectionsChange.apply(this, arguments);
if (historyWidget.inputEl) {
historyWidget.inputEl.style.display = "block";
historyWidget.inputEl.style.visibility = "visible";
}
};
// 5. High-frequency sync and auto-scroll
const originalOnChange = historyWidget.onChange;
historyWidget.onChange = function(v) {
if (originalOnChange) originalOnChange.call(this, v);
// Sync DOM explicitly if needed
if (historyWidget.inputEl && historyWidget.inputEl.value !== v) {
historyWidget.inputEl.value = v || "";
}
if (isHistoryModalOpen()) {
openHistoryModal(v || "");
}
// Auto scroll to bottom
setTimeout(() => {
if (historyWidget.inputEl) {
historyWidget.inputEl.scrollTop = historyWidget.inputEl.scrollHeight;
}
}, 10);
};
// 6. Set initial size
node.setSize([600, 500]);
}
app.registerExtension({
name: "chat_optimize_ui",
priority: 10,
async nodeCreated(node) {
if (CHAT_CLASSES.includes(node.comfyClass)) {
setupChatNode(node);
} else if (node.comfyClass === VIEWER_CLASS) {
setupHistoryViewer(node);
}
},
});