853 lines
34 KiB
JavaScript
853 lines
34 KiB
JavaScript
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 = `
|
|
<style>
|
|
:host { display: block; width: 100%; height: 100%; }
|
|
.preview {
|
|
position: relative; display: flex; flex-direction: column; gap: 7px; height: 100%; min-height: 200px; overflow: hidden;
|
|
box-sizing: border-box; border: 0; border-radius: 8px;
|
|
color: var(--input-text, var(--fg-color, #ddd));
|
|
font: 13px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
}
|
|
.result-wrap {
|
|
order: 1; position: relative; flex: 1 1 auto; min-height: 0; width: 100%; overflow: hidden;
|
|
box-sizing: border-box; border: 1px solid var(--border-color, #484848); border-radius: 8px;
|
|
background: var(--comfy-input-bg, #181818);
|
|
}
|
|
.content {
|
|
width: 100%; height: 100%; overflow: auto; box-sizing: border-box;
|
|
padding: 12px 14px 22px; overflow-wrap: anywhere;
|
|
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent;
|
|
}
|
|
.activity-wrap { order: 2; position: relative; z-index: 1; flex: 0 0 auto; width: 100%; max-height: 140px; min-height: 34px; overflow: auto; box-sizing: border-box; border: 1px solid color-mix(in srgb, var(--border-color, #484848) 80%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--comfy-input-bg, #181818) 92%, #000 8%); font-size: 12px; opacity: .88; }
|
|
.activity-wrap > .status { position: static; display: block; max-width: none; margin: 5px 8px 0; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: transparent; border-radius: 0; font-size: 12px; opacity: .85; }
|
|
.activity-title { padding: 6px 8px 0; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; opacity: .55; }
|
|
.activity-panel { padding: 2px 8px 2px; }
|
|
.activity-current { color: var(--input-text, var(--fg-color, #ddd)); font-weight: 600; }
|
|
.activity-log { margin-top: 2px; }
|
|
.activity-entry, .activity-candidate { white-space: pre-wrap; overflow-wrap: anywhere; opacity: .72; }
|
|
.activity-candidate { color: color-mix(in srgb, currentColor 78%, #7c8cf8 22%); }
|
|
.activity-toggle {
|
|
position: sticky; bottom: 5px; z-index: 2; display: block;
|
|
margin: 3px 8px 5px auto; padding: 3px 8px;
|
|
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
|
border-radius: 5px;
|
|
background: color-mix(in srgb, var(--comfy-input-bg, #181818) 94%, currentColor 6%);
|
|
box-shadow: 0 1px 5px color-mix(in srgb, #000 45%, transparent);
|
|
color: inherit; opacity: .78; cursor: pointer; font-size: 11px;
|
|
}
|
|
.activity-toggle:hover { opacity: 1; background: color-mix(in srgb, var(--comfy-input-bg, #181818) 84%, currentColor 16%); }
|
|
.empty { height: 100%; display: grid; place-items: center; text-align: center; opacity: .42; }
|
|
.copy {
|
|
position: absolute; z-index: 2; top: 6px; right: 8px; width: 28px; height: 28px;
|
|
display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
|
|
background: color-mix(in srgb, var(--comfy-input-bg, #181818) 88%, currentColor 12%);
|
|
color: inherit; cursor: pointer; opacity: 0; transition: opacity .12s, background .12s;
|
|
}
|
|
.preview:hover .copy, .copy:focus-visible { opacity: .82; }
|
|
.copy:hover { opacity: 1; background: color-mix(in srgb, var(--comfy-input-bg, #181818) 76%, currentColor 24%); }
|
|
.copy:focus-visible { outline: 1px solid #60a5fa; outline-offset: 1px; }
|
|
.copy svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
.copy .check-icon { display: none; }
|
|
.copy[data-copy-state="copied"] { color: #69d69b; opacity: 1; }
|
|
.copy[data-copy-state="copied"] .copy-icon { display: none; }
|
|
.copy[data-copy-state="copied"] .check-icon { display: block; }
|
|
.copy[data-copy-state="failed"] { color: #f87171; opacity: 1; }
|
|
.copy:disabled { display: none; }
|
|
.status { color: inherit; pointer-events: none; }
|
|
.preview[data-state="waiting"] .status,
|
|
.preview[data-state="loading_skill"] .status,
|
|
.preview[data-state="reading_skill"] .status,
|
|
.preview[data-state="reasoning"] .status,
|
|
.preview[data-state="error"] .status { transform: none; }
|
|
.preview[data-state="error"] .status { color: #fca5a5; opacity: .95; }
|
|
h1, h2, h3, h4, h5, h6 { margin: 1.1em 0 .55em; line-height: 1.25; }
|
|
h1 { font-size: 1.55em; } h2 { font-size: 1.35em; } h3 { font-size: 1.18em; }
|
|
p, ul, ol, blockquote, pre, table { margin: .65em 0; }
|
|
ul, ol { padding-left: 1.7em; } li + li { margin-top: .2em; }
|
|
blockquote { margin-left: 0; padding: .1em 1em; border-left: 3px solid #7c8cf8; opacity: .82; }
|
|
code { border-radius: 4px; padding: .12em .35em; background: color-mix(in srgb, currentColor 10%, transparent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
pre { overflow: auto; padding: 12px; border-radius: 7px; background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
pre code { padding: 0; background: transparent; white-space: pre; }
|
|
table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
|
|
th, td { padding: 6px 9px; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); }
|
|
th { background: color-mix(in srgb, currentColor 7%, transparent); }
|
|
a { color: #7c8cf8; } hr { border: 0; border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent); }
|
|
.preview[data-state="generating"] .content::after,
|
|
.preview[data-state="displaying"] .content::after { content: ""; display: inline-block; width: 6px; height: 1em; margin-left: 3px; vertical-align: -.12em; background: currentColor; animation: blink .85s steps(1) infinite; }
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
.preview[data-state="generating"] .content::after,
|
|
.preview[data-state="displaying"] .content::after { display: none; }
|
|
}
|
|
</style>
|
|
<div class="preview" data-state="idle">
|
|
<section class="result-wrap">
|
|
<div class="content"></div>
|
|
<button class="copy" type="button" data-copy-state="idle">
|
|
<svg class="copy-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3"></path></svg>
|
|
<svg class="check-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"></path></svg>
|
|
</button>
|
|
</section>
|
|
<section class="activity-wrap" hidden>
|
|
<div class="activity-title"></div>
|
|
<span class="status" hidden aria-live="polite"></span>
|
|
<div class="activity-panel"></div>
|
|
<button class="activity-toggle" type="button" hidden></button>
|
|
</section>
|
|
</div>`;
|
|
|
|
const state = {
|
|
displayedText: "",
|
|
receivedText: "",
|
|
finalText: "",
|
|
pendingUnits: [],
|
|
sourceEnded: false,
|
|
terminalError: "",
|
|
activeRunId: null,
|
|
lastSeq: -1,
|
|
streaming: false,
|
|
typingFrame: null,
|
|
lastTypingTime: 0,
|
|
typingBudget: 0,
|
|
renderTimer: null,
|
|
lastRenderTime: 0,
|
|
copyTimer: null,
|
|
statusKey: "ready",
|
|
statusDetail: "",
|
|
candidateText: "",
|
|
activityLog: [],
|
|
currentActivity: null,
|
|
activityExpanded: false,
|
|
root,
|
|
host: shadow.querySelector(".preview"),
|
|
content: shadow.querySelector(".content"),
|
|
status: shadow.querySelector(".activity-wrap .status"),
|
|
copyButton: shadow.querySelector(".copy"),
|
|
activityWrap: shadow.querySelector(".activity-wrap"),
|
|
activityTitle: shadow.querySelector(".activity-title"),
|
|
activityPanel: shadow.querySelector(".activity-panel"),
|
|
activityToggle: shadow.querySelector(".activity-toggle"),
|
|
};
|
|
setStatus(state, "ready");
|
|
state.activityTitle.textContent = message("processTitle");
|
|
setCopyState(state, "idle");
|
|
updateCopyAvailability(state);
|
|
state.copyButton.addEventListener("click", () => copyRawText(state));
|
|
state.activityToggle.addEventListener("click", () => {
|
|
state.activityExpanded = !state.activityExpanded;
|
|
renderActivity(state);
|
|
});
|
|
renderActivity(state);
|
|
root.addEventListener("pointerdown", event => event.stopPropagation());
|
|
root.addEventListener("wheel", event => event.stopPropagation(), { passive: true });
|
|
|
|
node.addDOMWidget("preview_api_result", "div", root, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
getMinHeight: () => 210,
|
|
});
|
|
node._ycyyApiResultPreview = state;
|
|
node.size ||= [MIN_WIDTH, MIN_HEIGHT];
|
|
node.size[0] = Math.max(node.size[0] || 0, MIN_WIDTH);
|
|
node.size[1] = Math.max(node.size[1] || 0, MIN_HEIGHT);
|
|
scheduleRender(state);
|
|
return state;
|
|
}
|
|
|
|
function disposePreview(node) {
|
|
const state = node?._ycyyApiResultPreview;
|
|
if (!state) return;
|
|
stopTyping(state);
|
|
clearTimeout(state.renderTimer);
|
|
clearTimeout(state.copyTimer);
|
|
node._ycyyApiResultPreview = null;
|
|
}
|
|
|
|
function resetStreamState(state, data, sequence) {
|
|
stopTyping(state);
|
|
state.activeRunId = data.run_id;
|
|
state.lastSeq = Number.isFinite(sequence) ? sequence : -1;
|
|
state.displayedText = "";
|
|
state.receivedText = "";
|
|
state.finalText = "";
|
|
state.candidateText = "";
|
|
state.activityLog = [];
|
|
state.currentActivity = null;
|
|
state.activityExpanded = true;
|
|
state.currentRound = 0;
|
|
state.pendingUnits = [];
|
|
state.sourceEnded = false;
|
|
state.terminalError = "";
|
|
state.streaming = true;
|
|
setStatus(state, "waiting");
|
|
renderActivity(state);
|
|
updateCopyAvailability(state);
|
|
scheduleRender(state, true);
|
|
}
|
|
|
|
function setFinalText(node, value) {
|
|
const state = createPreview(node);
|
|
const text = String(value ?? "");
|
|
if (state.streaming || state.typingFrame !== null || state.pendingUnits.length > 0) {
|
|
enqueueFinalText(state, text);
|
|
return;
|
|
}
|
|
const unchanged = state.displayedText === text && !state.streaming;
|
|
stopTyping(state);
|
|
state.displayedText = text;
|
|
state.receivedText = text;
|
|
state.finalText = text;
|
|
state.pendingUnits = [];
|
|
state.sourceEnded = true;
|
|
state.terminalError = "";
|
|
state.streaming = false;
|
|
state.activeRunId = null;
|
|
setStatus(state, "complete");
|
|
renderActivity(state);
|
|
updateCopyAvailability(state);
|
|
if (!unchanged) scheduleRender(state, true);
|
|
}
|
|
|
|
function connectedPreviewNodes(sourceNodeId) {
|
|
const graph = app.graph;
|
|
const source = graph?.getNodeById?.(sourceNodeId)
|
|
?? graph?.getNodeById?.(Number(sourceNodeId));
|
|
if (!source || nodeClass(source) !== OPENAI_NODE_CLASS) return [];
|
|
|
|
const resultOutput = source.outputs?.[0];
|
|
const targets = [];
|
|
for (const linkId of resultOutput?.links || []) {
|
|
const link = getLink(graph, linkId);
|
|
if (!link || link.origin_slot !== 0 || link.target_slot !== 0) continue;
|
|
const target = graph.getNodeById(link.target_id);
|
|
if (target && nodeClass(target) === NODE_CLASS) targets.push(target);
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function receiveStreamEvent(event) {
|
|
const data = event.detail || {};
|
|
for (const node of connectedPreviewNodes(data.node_id)) {
|
|
const state = createPreview(node);
|
|
const sequence = Number(data.seq);
|
|
if (data.phase === "start") {
|
|
resetStreamState(state, data, sequence);
|
|
continue;
|
|
}
|
|
if (state.activeRunId !== data.run_id) continue;
|
|
if (Number.isFinite(sequence) && sequence <= state.lastSeq) continue;
|
|
if (Number.isFinite(sequence)) state.lastSeq = sequence;
|
|
|
|
if (data.phase === "round_start") {
|
|
state.currentRound = Number(data.round) || 0;
|
|
state.candidateText = "";
|
|
state.currentActivity = { label: message("waiting"), detail: `Round ${state.currentRound + 1}` };
|
|
appendActivity(state, { type: "round", text: `Round ${state.currentRound + 1}` });
|
|
setStatus(state, "waiting");
|
|
renderActivity(state);
|
|
} else if (data.phase === "candidate_delta") {
|
|
state.candidateText += String(data.delta || "");
|
|
state.currentActivity = { label: message("drafting"), detail: `Round ${state.currentRound + 1}` };
|
|
setStatus(state, "drafting");
|
|
renderActivity(state);
|
|
} else if (data.phase === "tool_call_start") {
|
|
const detail = data.path ? `${data.tool} · ${data.path}` : data.tool;
|
|
state.currentActivity = { label: message("toolRunning"), detail };
|
|
appendActivity(state, { type: "tool", text: `${message("toolRunning")} ${detail}` });
|
|
setStatus(state, "tool_running");
|
|
renderActivity(state);
|
|
} else if (data.phase === "tool_call_end") {
|
|
const detail = data.path ? `${data.tool} · ${data.path}` : data.tool;
|
|
appendActivity(state, { type: "tool", text: `${detail} · ${data.status || "success"}` });
|
|
renderActivity(state);
|
|
} else if (data.phase === "round_end") {
|
|
const hasTools = Boolean(data.has_tool_calls);
|
|
if (hasTools) {
|
|
state.candidateText = "";
|
|
state.currentActivity = { label: message("toolRunning"), detail: `Round ${state.currentRound + 1} complete` };
|
|
} else {
|
|
const promotedText = state.candidateText;
|
|
state.receivedText = promotedText;
|
|
state.finalText = promotedText;
|
|
state.candidateText = "";
|
|
state.currentActivity = { label: message("generating"), detail: "" };
|
|
setStatus(state, promotedText ? "promoting" : "generating");
|
|
if (promotedText) {
|
|
state.pendingUnits = splitTextUnits(promotedText);
|
|
state.displayedText = "";
|
|
state.sourceEnded = false;
|
|
startTyping(state);
|
|
}
|
|
}
|
|
renderActivity(state);
|
|
scheduleRender(state, true);
|
|
} else if (data.phase === "activity") {
|
|
const activity = [
|
|
"loading_skill", "reading_skill", "reasoning",
|
|
].includes(data.activity) ? data.activity : null;
|
|
if (activity && !state.receivedText) {
|
|
setStatus(state, activity);
|
|
renderActivity(state);
|
|
scheduleRender(state, true);
|
|
}
|
|
} else if (data.phase === "delta") {
|
|
if (!state.receivedText) {
|
|
setStatus(state, "generating");
|
|
renderActivity(state);
|
|
}
|
|
enqueueDelta(state, data.delta);
|
|
} else if (data.phase === "end") {
|
|
if (state.pendingUnits.length || state.displayedText !== String(data.text ?? state.receivedText)) {
|
|
setStatus(state, "displaying");
|
|
}
|
|
enqueueFinalText(state, data.text ?? state.receivedText);
|
|
if (data.stop_reason && data.stop_reason !== "stop") {
|
|
state.terminalError = `stop_reason: ${data.stop_reason}`;
|
|
}
|
|
state.candidateText = "";
|
|
state.currentActivity = null;
|
|
renderActivity(state);
|
|
} else if (data.phase === "error") {
|
|
state.sourceEnded = true;
|
|
state.finalText = state.receivedText;
|
|
state.terminalError = data.message || "unknown";
|
|
setStatus(state, "error", state.terminalError);
|
|
renderActivity(state);
|
|
updateCopyAvailability(state);
|
|
scheduleRender(state, true);
|
|
startTyping(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
function refreshLocale() {
|
|
currentLocale = app.ui?.settings?.getSettingValue?.(LOCALE_SETTING) || "en";
|
|
for (const node of app.graph?._nodes || []) {
|
|
const state = node?._ycyyApiResultPreview;
|
|
if (!state) continue;
|
|
if (state.activityTitle) state.activityTitle.textContent = message("processTitle");
|
|
setCopyState(state, state.copyButton.dataset.copyState || "idle");
|
|
setStatus(state, state.statusKey, state.statusDetail);
|
|
renderActivity(state);
|
|
scheduleRender(state);
|
|
}
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "YCYY.Preview.API.Result",
|
|
|
|
async setup() {
|
|
refreshLocale();
|
|
app.ui?.settings?.addEventListener?.(`${LOCALE_SETTING}.change`, refreshLocale);
|
|
api.addEventListener(STREAM_EVENT, receiveStreamEvent);
|
|
},
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== NODE_CLASS && nodeType.comfyClass !== NODE_CLASS) return;
|
|
|
|
const originalCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const result = originalCreated?.apply(this, arguments);
|
|
createPreview(this);
|
|
return result;
|
|
};
|
|
|
|
const originalConfigured = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function () {
|
|
const result = originalConfigured?.apply(this, arguments);
|
|
setTimeout(() => createPreview(this), 0);
|
|
return result;
|
|
};
|
|
|
|
const originalExecuted = nodeType.prototype.onExecuted;
|
|
nodeType.prototype.onExecuted = function (executionMessage) {
|
|
originalExecuted?.apply(this, arguments);
|
|
const value = Array.isArray(executionMessage?.text)
|
|
? executionMessage.text[0]
|
|
: (executionMessage?.text ?? "");
|
|
setFinalText(this, value);
|
|
};
|
|
|
|
const originalRemoved = nodeType.prototype.onRemoved;
|
|
nodeType.prototype.onRemoved = function () {
|
|
disposePreview(this);
|
|
return originalRemoved?.apply(this, arguments);
|
|
};
|
|
},
|
|
|
|
async nodeCreated(node) {
|
|
if (nodeClass(node) === NODE_CLASS) createPreview(node);
|
|
},
|
|
|
|
async onNodeOutputsUpdated(nodeOutputs) {
|
|
for (const [nodeId, output] of Object.entries(nodeOutputs || {})) {
|
|
const node = app.graph?.getNodeById?.(nodeId)
|
|
?? app.graph?.getNodeById?.(Number(nodeId));
|
|
if (node && nodeClass(node) === NODE_CLASS) {
|
|
const value = Array.isArray(output?.text)
|
|
? output.text[0]
|
|
: (output?.text ?? "");
|
|
setFinalText(node, value);
|
|
}
|
|
}
|
|
},
|
|
});
|