import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import "./marked.umd.js"; const NODE_CLASS = "YCYY_Preview_API_Result"; const OPENAI_NODE_CLASS = "YCYY_OpenAI_Text_API"; const STREAM_EVENT = "ycyy_openai_text_stream"; const LOCALE_SETTING = "Comfy.Locale"; const MIN_WIDTH = 360; const MIN_HEIGHT = 260; const MAX_TYPING_UNITS_PER_FRAME = 18; const RENDER_INTERVAL_MS = 40; const MESSAGES = { en: { empty: "Connect an API result and run the workflow", ready: "Ready", waiting: "Waiting for response…", loading_skill: "Loading Skill…", reading_skill: "Reading Skill files…", reasoning: "Thinking…", generating: "Generating…", drafting: "Model note…", toolRunning: "Tool running…", tool_running: "Tool running…", promoting: "Preparing answer…", activityExpand: "Show activity", activityCollapse: "Hide activity", processTitle: "Process & status", intermediate: "Model note", displaying: "Displaying result…", complete: "Complete", copy: "Copy", copied: "Copied", copyFailed: "Copy failed", success: "Success", copiedDetail: "Text copied to clipboard", error: "Error", }, zh: { empty: "连接 API 结果并运行工作流", ready: "就绪", waiting: "等待响应…", loading_skill: "正在加载 Skill…", reading_skill: "正在读取 Skill 文件…", reasoning: "正在思考…", generating: "正在生成…", drafting: "模型中间说明…", toolRunning: "工具执行中…", tool_running: "工具执行中…", promoting: "正在整理答案…", activityExpand: "展开过程", activityCollapse: "收起过程", processTitle: "过程与状态", intermediate: "模型中间说明", displaying: "正在显示结果…", complete: "完成", copy: "复制", copied: "已复制", copyFailed: "复制失败", success: "成功", copiedDetail: "文本已复制到剪贴板", error: "错误", }, }; const ALLOWED_TAGS = new Set([ "P", "BR", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI", "BLOCKQUOTE", "PRE", "CODE", "HR", "STRONG", "EM", "DEL", "A", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD", ]); const DROP_CONTENT_TAGS = new Set([ "SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "FORM", "SVG", "MATH", "META", "LINK", "BASE", "IMG", "VIDEO", "AUDIO", ]); let currentLocale = "en"; const graphemeSegmenter = globalThis.Intl?.Segmenter ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null; const reducedMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)"); function normalizeLocale(locale) { return String(locale || "en").toLowerCase().startsWith("zh") ? "zh" : "en"; } function message(key) { return MESSAGES[normalizeLocale(currentLocale)]?.[key] ?? MESSAGES.en[key] ?? key; } function nodeClass(node) { return node?.constructor?.comfyClass || node?.comfyClass || node?.type; } function getLink(graph, linkId) { return graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? null; } function safeHref(value) { try { const url = new URL(value, window.location.href); return ["http:", "https:", "mailto:"].includes(url.protocol) ? value : null; } catch { return null; } } function sanitizeMarkedHtml(html) { const template = document.createElement("template"); template.innerHTML = html; for (const element of [...template.content.querySelectorAll("*")]) { if (DROP_CONTENT_TAGS.has(element.tagName)) { element.remove(); continue; } if (!ALLOWED_TAGS.has(element.tagName)) { element.replaceWith(...element.childNodes); continue; } const href = element.tagName === "A" ? safeHref(element.getAttribute("href") || "") : null; const title = element.getAttribute("title"); for (const attribute of [...element.attributes]) { element.removeAttribute(attribute.name); } if (href) { element.setAttribute("href", href); element.setAttribute("target", "_blank"); element.setAttribute("rel", "noopener noreferrer"); } if (title) element.setAttribute("title", title); } return template.innerHTML; } function markedApi() { return globalThis.marked; } function isNearBottom(element) { return element.scrollHeight - element.scrollTop - element.clientHeight < 48; } function splitTextUnits(value) { const text = String(value ?? ""); if (!text) return []; if (graphemeSegmenter) { return [...graphemeSegmenter.segment(text)].map(part => part.segment); } return Array.from(text); } function typingRate(state) { const queueLength = state.pendingUnits.length; if (state.sourceEnded) { if (queueLength > 500) return 720; if (queueLength > 200) return 480; return 260; } if (queueLength > 500) return 260; if (queueLength > 200) return 180; if (queueLength > 80) return 110; return 48; } function renderNow(state) { const followTail = isNearBottom(state.content); const raw = state.displayedText || ""; if (!raw) { state.content.replaceChildren(); if (!["waiting", "loading_skill", "reading_skill", "reasoning", "drafting", "tool_running", "promoting", "generating", "displaying", "error"].includes(state.statusKey)) { const placeholder = document.createElement("div"); placeholder.className = "empty"; placeholder.textContent = message("empty"); state.content.append(placeholder); } } else { const parser = markedApi(); if (!parser?.parse) { state.content.textContent = raw; setStatus(state, "error", "marked.umd.js unavailable"); renderActivity(state); } else { const html = parser.parse(raw, { gfm: true, breaks: true }); state.content.innerHTML = sanitizeMarkedHtml(html); } } if (followTail) state.content.scrollTop = state.content.scrollHeight; } function scheduleRender(state, immediate = false) { if (immediate) { clearTimeout(state.renderTimer); state.renderTimer = null; state.lastRenderTime = performance.now(); renderNow(state); return; } if (state.renderTimer !== null) return; const elapsed = performance.now() - state.lastRenderTime; const delay = Math.max(0, RENDER_INTERVAL_MS - elapsed); state.renderTimer = setTimeout(() => { state.renderTimer = null; state.lastRenderTime = performance.now(); renderNow(state); }, delay); } function setStatus(state, key, detail = "") { state.statusKey = key; state.statusDetail = detail; const visible = [ "waiting", "loading_skill", "reading_skill", "reasoning", "drafting", "tool_running", "promoting", "generating", "displaying", "complete", "error", ].includes(key); state.host.dataset.state = key; state.status.textContent = key === "error" && detail ? `${message("error")}: ${detail}` : message(key); state.status.hidden = !visible; } function appendActivity(state, entry) { state.activityLog.push(entry); if (state.activityLog.length > 20) state.activityLog.splice(0, state.activityLog.length - 20); } function renderActivity(state) { if (!state.activityPanel) return; const followTail = isNearBottom(state.activityWrap); state.activityPanel.replaceChildren(); const latest = state.currentActivity; if (latest) { const current = document.createElement("div"); current.className = "activity-current"; current.textContent = latest.detail ? `${latest.label} · ${latest.detail}` : latest.label; state.activityPanel.append(current); } const log = document.createElement("div"); log.className = "activity-log"; const entries = state.activityExpanded ? state.activityLog : state.activityLog.slice(-2); for (const entry of entries) { const row = document.createElement("div"); row.className = `activity-entry activity-${entry.type || "info"}`; row.textContent = entry.text || ""; log.append(row); } if (state.candidateText) { const candidate = document.createElement("div"); candidate.className = "activity-candidate"; candidate.textContent = `${message("intermediate")}: ${state.candidateText}`; log.append(candidate); } state.activityPanel.append(log); // Keep the process box visible for the complete state as well, so the // final status and activity history remain available after rendering. const statusVisible = !state.status.hidden && state.statusKey !== "ready"; state.activityWrap.hidden = !latest && !state.activityLog.length && !state.candidateText && !statusVisible; // Keep the toggle available after collapsing a short log; otherwise a // two-entry process history could be hidden permanently. state.activityToggle.hidden = !state.activityLog.length && !state.candidateText; state.activityToggle.textContent = state.activityExpanded ? message("activityCollapse") : message("activityExpand"); if (followTail) state.activityWrap.scrollTop = state.activityWrap.scrollHeight; } function updateCopyAvailability(state) { state.copyButton.disabled = !state.finalText; } function setCopyState(state, copyState) { state.copyButton.dataset.copyState = copyState; const label = copyState === "copied" ? message("copied") : (copyState === "failed" ? message("copyFailed") : message("copy")); state.copyButton.title = label; state.copyButton.setAttribute("aria-label", label); } function showCopyToast(success) { const toast = app.extensionManager?.toast; if (!toast?.add) return; toast.add(success ? { severity: "success", summary: message("success"), detail: message("copiedDetail"), life: 3000, } : { severity: "error", summary: message("error"), detail: message("copyFailed"), }); } async function copyRawText(state) { const text = state.finalText; if (!text) return; try { if (navigator.clipboard?.writeText && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.append(textarea); try { textarea.select(); if (!document.execCommand("copy")) throw new Error("copy command failed"); } finally { textarea.remove(); } } setCopyState(state, "copied"); showCopyToast(true); } catch (error) { console.error("[YCYY] Failed to copy preview text:", error); setCopyState(state, "failed"); showCopyToast(false); } finally { clearTimeout(state.copyTimer); state.copyTimer = setTimeout(() => { setCopyState(state, "idle"); }, 1200); } } function stopTyping(state) { if (state.typingFrame !== null) cancelAnimationFrame(state.typingFrame); state.typingFrame = null; state.lastTypingTime = 0; state.typingBudget = 0; } function finishTypingIfReady(state) { if (!state.sourceEnded || state.pendingUnits.length > 0) return false; // Normally the queue already produced finalText. This assignment also // reconciles unusual providers whose final response differs from deltas. if (state.displayedText !== state.finalText) { state.displayedText = state.finalText; } state.receivedText = state.finalText; state.streaming = false; state.activeRunId = null; if (state.terminalError) { setStatus(state, "error", state.terminalError); } else { state.currentActivity = null; setStatus(state, "complete"); } renderActivity(state); updateCopyAvailability(state); scheduleRender(state, true); return true; } function typingTick(state, timestamp) { state.typingFrame = null; if (!state.streaming) return; if (!state.lastTypingTime) state.lastTypingTime = timestamp; const elapsed = Math.min(250, Math.max(0, timestamp - state.lastTypingTime)); state.lastTypingTime = timestamp; state.typingBudget += elapsed * typingRate(state) / 1000; const available = Math.floor(state.typingBudget); const count = Math.min( available, MAX_TYPING_UNITS_PER_FRAME, state.pendingUnits.length, ); if (count > 0) { state.displayedText += state.pendingUnits.splice(0, count).join(""); state.typingBudget -= count; scheduleRender(state); } if (state.pendingUnits.length > 0) { state.typingFrame = requestAnimationFrame(time => typingTick(state, time)); } else { state.lastTypingTime = 0; state.typingBudget = 0; finishTypingIfReady(state); } } function startTyping(state) { if (state.typingFrame !== null) return; if (!state.pendingUnits.length) { finishTypingIfReady(state); return; } state.typingFrame = requestAnimationFrame(time => typingTick(state, time)); } function enqueueDelta(state, value) { const delta = String(value ?? ""); if (!delta) return; state.receivedText += delta; updateCopyAvailability(state); if (reducedMotion?.matches) { state.displayedText = state.receivedText; state.pendingUnits = []; scheduleRender(state); return; } state.pendingUnits.push(...splitTextUnits(delta)); startTyping(state); } function enqueueFinalText(state, value) { const finalText = String(value ?? state.receivedText); state.sourceEnded = true; state.finalText = finalText; state.receivedText = finalText; updateCopyAvailability(state); if (reducedMotion?.matches) { state.displayedText = finalText; state.pendingUnits = []; finishTypingIfReady(state); return; } // Rebuild the unplayed tail from the authoritative final response. This // also prevents duplicate characters when both `end` and `onExecuted` // deliver the same full text before the animation has caught up. if (finalText.startsWith(state.displayedText)) { state.pendingUnits = splitTextUnits(finalText.slice(state.displayedText.length)); } else { const shown = splitTextUnits(state.displayedText); const final = splitTextUnits(finalText); let common = 0; while (common < shown.length && common < final.length && shown[common] === final[common]) { common += 1; } state.displayedText = shown.slice(0, common).join(""); state.pendingUnits = final.slice(common); } startTyping(state); } function createPreview(node) { if (node._ycyyApiResultPreview) return node._ycyyApiResultPreview; const root = document.createElement("div"); root.style.width = "100%"; root.style.height = "100%"; root.style.minHeight = "210px"; root.style.boxSizing = "border-box"; const shadow = root.attachShadow({ mode: "open" }); shadow.innerHTML = `