Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

1145 lines
44 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// AIAssistant — chat-style assistant panel docked beside the editor.
// Wraps the existing /ai/patch flow as the apply_prompt_patch tool of a
// Claude-driven chat agent (/promptchain/ai/chat). Per-node persistent
// chat lives on node.properties.pcrAiChat / pcrAiProposals.
//
// Three modes (composer dropdown, persisted on node.properties.pcrAiAutoMode):
// ask — propose, wait for Accept/Reject
// auto — apply each tool result immediately (read-only proposal card)
// auto-run — apply + queue prompt (toast on queue failure)
import { onMount, onDestroy, tick, untrack } from "svelte";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { useApi } from "../../lib/api-context.js";
import {
readNodePrompt,
isStandaloneMainPromptChain,
extractNGrams,
matchCharactersInDb,
applyPromptText,
cryptoId,
} from "../../lib/ai-patch-helpers.js";
import ChatTimeline from "./ChatTimeline.svelte";
import ModeDropdown from "./ModeDropdown.svelte";
import CommandsMenu from "./CommandsMenu.svelte";
const DEFAULT_WIDTH = 320;
const MIN_WIDTH = 200;
let {
node,
shared,
onToggle = () => {},
onRegister = null,
} = $props();
const { getCanvasScale } = useApi();
let panelEl;
let dividerEl;
let inputEl;
let bodyEl;
// Auto-scroll-to-bottom on new turn / streaming status — but only when
// the user is already pinned to the bottom (within 32px). If they've
// scrolled up to re-read past content, we leave them alone.
let pinnedToBottom = $state(true);
function recordPinState() {
if (!bodyEl) return;
const remaining = bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight;
pinnedToBottom = remaining < 32;
}
// Experimental gate: the assistant only exists when the user opted in via
// Settings > PromptChain > AI Assistant (experimental). A saved workflow
// with the panel open must NOT resurrect it while the gate is off — the
// menubar button would be hidden, leaving no way to close it.
function aiAssistantEnabled() {
return app.ui?.settings?.getSettingValue?.("PromptChain.AIAssistantEnabled") === true;
}
let isVisible = $state(!!node.properties?.pcrAiAssistant && aiAssistantEnabled());
let panelWidth = $state(node.properties?.pcrAiPanelWidth ?? DEFAULT_WIDTH);
let inputText = $state("");
// Images staged for the next submit: {hash, url, name}. Uploaded to the
// user folder immediately on attach (never base64 in chat state); only
// the hash rides the /ai/chat request. Cleared after each send.
let attachedImages = $state([]);
// Images held alongside a queued message (submit while busy / rendering).
let queuedImages = $state([]);
let uploadingImage = $state(false);
// Whether the configured model can see images — gates the attach button.
// Defaults true; the probe narrows it.
let visionCapable = $state(true);
// ── Chat state ─────────────────────────────────────────────────
// chat[] — display turns: {role, text, proposalId?, timestamp}
// proposals{} — keyed by tool_use id: {tool_input, tool_result, status, createdAt, appliedAt?}
// historyRaw[] — canonical Anthropic messages (system|user|assistant blocks);
// ferried back to /ai/chat unchanged so Claude sees its prior tool_use blocks
// mode — "ask" | "auto" | "auto-run"
// busy — in-flight; refuses new submits. NOT persisted (a crash mid-stream
// would brick the panel until reload).
let chat = $state(node.properties?.pcrAiChat ?? []);
let proposals = $state(node.properties?.pcrAiProposals ?? {});
let historyRaw = $state(node.properties?.pcrAiChatRaw ?? []);
// Running mechanical recap of turns the server has dropped from the
// model's context window (see _compact_history). Ferried back each turn
// so the collapse is cumulative; non-empty drives the "condensed" marker.
let chatSummary = $state(node.properties?.pcrAiChatSummary ?? "");
let mode = $state(node.properties?.pcrAiAutoMode ?? "ask");
// extraVerbs gates the macro-verb prompt block (Expand / Vary /
// Condense / Reword / Enrich) server-side.
let extraVerbs = $state(node.properties?.pcrAiExtraVerbs ?? true);
let busy = $state(false);
// Tracks the in-flight chat request_id so the stop button can hit
// /promptchain/ai/cancel. Empty when no request is in flight.
let activeRequestId = $state("");
// When the user submits while busy (chat running) or while ComfyUI
// is rendering in auto-run mode, the message is queued here instead
// of dropped. Drained when the in-flight work completes.
let queuedMessage = $state("");
// True for one-shot when the user clicked the stop button — keeps
// the catch path from surfacing the resulting fetch error as a banner.
let stoppedByUser = false;
// Verbatim text of the in-flight chat message. Used by the
// render-yield handler to re-queue this same message when a
// ComfyUI render starts mid-chat (VRAM contention).
let activeMessageText = "";
// True while /ai/chat is awaiting a response (LLM streaming).
// Goes false the moment the response arrives, even before proposal
// application finishes — so the render-yield only cancels real
// LLM work, not the synchronous apply step that follows.
let llmCallInFlight = false;
let errorBanner = $state("");
// Non-error transient status (e.g. "Ollama down, attempting start...").
// Rendered in an amber-not-red banner so the user doesn't read it as a
// hard failure when it's an in-progress recovery.
let infoBanner = $state("");
// True while we're checking provider availability / waking Ollama.
// Suppresses the empty-state hint so the panel doesn't read as
// "ready to use" while a recovery banner is also showing.
let probing = $state(false);
// Live stream status — updates from agent_text/tool_call/status WS events
// while a request is in flight.
let thinkingState = $state(null);
let elapsedTimer = null;
let elapsedStart = 0;
let wsUnsub = null;
// Submit-button enablement. While busy the button morphs into a stop
// control (always clickable), so canSubmit only governs the "send"
// pathway. An attached image alone is a valid submit (reverse-prompt),
// so either non-empty text OR a staged image enables it.
let canSubmit = $derived(
(inputText.trim().length > 0 || attachedImages.length > 0) && !busy
);
// ── Persistence ────────────────────────────────────────────────
// Mirror chat-state changes back to the node so they survive workflow
// save/load. busy is intentionally NOT persisted.
$effect(() => {
if (!node.properties) return;
node.properties.pcrAiChat = chat;
});
$effect(() => {
if (!node.properties) return;
node.properties.pcrAiProposals = proposals;
});
$effect(() => {
if (!node.properties) return;
node.properties.pcrAiChatRaw = historyRaw;
});
$effect(() => {
if (!node.properties) return;
node.properties.pcrAiChatSummary = chatSummary;
});
$effect(() => {
if (!node.properties) return;
node.properties.pcrAiAutoMode = mode;
});
$effect(() => {
if (!node.properties) return;
node.properties.pcrAiExtraVerbs = extraVerbs;
});
// Auto-scroll on new content. Tracks chat length, busy, thinkingState,
// proposals so any chat-stream change retriggers it. Uses untrack on
// pinnedToBottom so reading the pin state doesn't self-subscribe.
$effect(() => {
void chat.length;
void busy;
void thinkingState;
void proposals;
if (!bodyEl) return;
if (!untrack(() => pinnedToBottom)) return;
tick().then(() => {
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
});
});
// Grow textarea to fit content, capped at CSS max-height. Border-box
// adjusted to avoid the 2px scrollbar flicker.
const INPUT_MAX_H = 160;
function autoGrowInput() {
if (!inputEl) return;
inputEl.style.height = "auto";
inputEl.style.overflowY = "hidden";
const borderY = inputEl.offsetHeight - inputEl.clientHeight;
const target = inputEl.scrollHeight + borderY;
if (target >= INPUT_MAX_H) {
inputEl.style.height = `${INPUT_MAX_H}px`;
inputEl.style.overflowY = "auto";
} else {
inputEl.style.height = `${target}px`;
}
}
// ── Workflow queue helpers ──────────────────────────────────────
async function isQueueBusy() {
try {
const q = await api.getQueue();
const running = q?.Running?.length || 0;
const pending = q?.Pending?.length || 0;
return running + pending > 0;
} catch {
return false;
}
}
function showToast(severity, summary, detail) {
if (app?.extensionManager?.toast?.add) {
app.extensionManager.toast.add({ severity, summary, detail, life: 4000 });
} else {
console.warn(`[PromptChain AI] ${summary}: ${detail}`);
}
}
// ── Stream subscription ─────────────────────────────────────────
function subscribeStream(reqId) {
elapsedStart = Date.now();
thinkingState = { status: "Thinking", elapsed: 0, tokens: 0 };
const handler = (e) => {
const data = e.detail || {};
// Match the chat reqId AND any patch sub-call (`<reqId>-patch-XXXX`)
// so the Qwen patch flow's status events surface live too.
const did = data.request_id || "";
if (did !== reqId && !did.startsWith(`${reqId}-patch-`)) return;
switch (data.event) {
case "thinking":
thinkingState = {
...thinkingState,
tokens: typeof data.tokens === "number"
? data.tokens
: (thinkingState?.tokens || 0) + 1,
};
break;
case "status":
thinkingState = { ...thinkingState, status: data.content || "Thinking" };
// Reset elapsed when leaving "Loading model" — VRAM populate
// shouldn't read as model latency.
if (data.content && data.content !== "Loading model"
&& thinkingState?._loading) {
elapsedStart = Date.now();
thinkingState.elapsed = 0;
}
thinkingState._loading = data.content === "Loading model";
break;
case "tool_call":
thinkingState = { ...thinkingState, status: `Calling ${data.content || "tool"}` };
break;
case "agent_tool_call":
thinkingState = { ...thinkingState, status: "Patching prompt" };
break;
case "agent_tool_result":
thinkingState = { ...thinkingState, status: "Finishing up" };
break;
case "error":
// Server-side error event; the POST result will also surface it,
// so we let the response handler set the banner.
break;
}
};
api.addEventListener("promptchain_ai_stream", handler);
wsUnsub = () => api.removeEventListener("promptchain_ai_stream", handler);
elapsedTimer = setInterval(() => {
if (thinkingState?._loading) return;
thinkingState = {
...thinkingState,
elapsed: Math.floor((Date.now() - elapsedStart) / 1000),
};
}, 1000);
}
function teardownStream() {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
wsUnsub?.();
wsUnsub = null;
thinkingState = null;
}
// ── Submit ─────────────────────────────────────────────────────
// Top-level entry point from Enter / send button. Decides whether to
// submit immediately or queue. Queueing happens when:
// - chat is already busy with an in-flight LLM request, OR
// - ComfyUI is currently rendering (VRAM contention: the chat
// LLM and the image model both want the GPU)
// The queued message fires when the in-flight thing finishes (chat
// drain in finally, comfy drain on "status" event with queue_remaining=0).
async function handleSubmitOrQueue() {
const userText = inputText.trim();
if (!userText && attachedImages.length === 0) return;
if (busy || await isQueueBusy()) {
queuedMessage = userText;
queuedImages = attachedImages;
attachedImages = [];
inputText = "";
queueMicrotask(autoGrowInput);
return;
}
await handleSubmit();
}
// Click handler for the stop button (button only renders in stop mode
// when busy). Hits the backend cancel route — the active task is
// cancelled and the existing fetch will reject; the catch path
// rolls back the user's turn without surfacing an error banner.
async function handleStop() {
if (!activeRequestId) return;
stoppedByUser = true;
try {
await fetch("/promptchain/ai/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: activeRequestId }),
});
} catch {
// Best-effort: if cancel POST fails the in-flight task may still
// complete server-side, but the user's intent was to stop — let
// the eventual response drop the assistant turn anyway via the
// stoppedByUser flag.
}
}
// Render-yield: ComfyUI just started executing a prompt. If our chat
// LLM is mid-stream, both are now fighting for VRAM — cancel chat
// and re-queue its in-flight message so it fires when the render
// finishes. llmCallInFlight (not busy) is the right gate because
// busy stays true through synchronous proposal application after the
// LLM has already released VRAM.
function handleComfyExecutionStart() {
if (!llmCallInFlight) return;
if (!activeRequestId) return;
if (activeMessageText) {
queuedMessage = activeMessageText;
}
stoppedByUser = true;
const idForCancel = activeRequestId;
fetch("/promptchain/ai/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: idForCancel }),
}).catch(() => {});
}
async function handleSubmit() {
if (!canSubmit) return;
const userText = inputText.trim();
// Snapshot staged images for THIS submit, then clear the tray so a
// follow-up doesn't accidentally re-send them.
const turnImages = attachedImages;
attachedImages = [];
if (!userText && turnImages.length === 0) return;
const nodePrompt = readNodePrompt(node);
const reqId = cryptoId();
activeRequestId = reqId;
activeMessageText = userText;
llmCallInFlight = true;
busy = true;
errorBanner = "";
// Append the user's turn locally so the UI feels responsive. The
// turnId lets rollback survive Svelte re-runs that replace the
// chat array — object-identity matching breaks if the array is
// rebuilt from persistence between push and rollback.
const now = Date.now();
const turnId = cryptoId();
const userTurn = { role: "user", text: userText, timestamp: now, _turnId: turnId };
if (turnImages.length) {
// Thumbnails for display only — {hash,url}. Persisted with the turn.
userTurn.images = turnImages.map((i) => ({ hash: i.hash, url: i.url }));
}
chat = [...chat, userTurn];
inputText = "";
queueMicrotask(autoGrowInput);
subscribeStream(reqId);
try {
// Bios preflight (matches the legacy single-shot path so /ai/patch
// sees identical inputs whether called directly or via the agent).
// Limit nodePrompt n-grams to header lines (// Character: ...,
// // Outfit: ...) — bio prose body words ("Tony Stark from Marvel",
// "Iron Man powered armor", "boot thrusters with jet") otherwise
// generate junk single-word grams that stage-3 prefix-match against
// unrelated DB chars (powered_ciel, jet_black, still_in_love_...),
// loading 5 phantom bios and breaking downstream rendering.
const nodePromptHeaders = (nodePrompt || "")
.split("\n")
.filter((l) => l.trim().startsWith("//"))
.join("\n");
const ngrams = Array.from(new Set([
...extractNGrams(userText),
...extractNGrams(nodePromptHeaders),
]));
const bios = await matchCharactersInDb(ngrams, userText, nodePrompt);
const modelInfo = node?._pcrGetModelInfo?.() || null;
const isStandaloneMain = isStandaloneMainPromptChain(node);
const priorPromptState = node?.properties?.pcrPromptState || null;
const node_ctx = {
node_prompt: nodePrompt,
bios,
tag_format: node?.properties?.pcrTagFormat || "spaces",
model_hash: modelInfo?.hash || "",
prompt_style: node?.properties?.pcrPromptStyle || "tags",
is_standalone_main: isStandaloneMain,
prompt_state: priorPromptState,
extra_verbs: extraVerbs,
};
// Append the user message to canonical history before sending.
const historyForRequest = [
...historyRaw,
{ role: "user", content: [{ type: "text", text: userText }] },
];
const r = await fetch("/promptchain/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: reqId,
mode,
node_ctx,
history: historyForRequest,
history_summary: chatSummary,
images: turnImages.map((i) => ({ hash: i.hash })),
}),
});
const data = await r.json().catch(() => ({}));
// LLM is done streaming — the remainder of this turn is local
// proposal application, which doesn't consume backend VRAM.
// Releasing the flag here lets a concurrent render proceed
// without us cancelling our own apply step.
llmCallInFlight = false;
if (!r.ok) {
// User-initiated cancel: drop the turn silently. The server
// returns a non-2xx after a task.cancel() — that's expected,
// not an error from the user's POV.
if (!stoppedByUser) {
errorBanner = data?.error || `HTTP ${r.status}`;
}
chat = chat.filter(t => t._turnId !== turnId);
if (turnImages.length) attachedImages = turnImages; // let the user retry
return;
}
const newTurns = Array.isArray(data.new_turns) ? data.new_turns : [];
const newProposals = data.new_proposals || {};
chat = [...chat, ...newTurns];
proposals = { ...proposals, ...newProposals };
historyRaw = Array.isArray(data.history_for_persistence)
? data.history_for_persistence
: historyForRequest;
if (typeof data.history_summary === "string") {
chatSummary = data.history_summary;
}
// Mode-aware auto-apply. Server already marks status="applied" in
// auto / auto-run modes; here we mutate the editor doc and (for
// auto-run) queue the prompt.
for (const [pid, p] of Object.entries(newProposals)) {
if (p.status !== "applied") continue;
applyProposalLocal(pid, p, /*persistApplied=*/false);
}
if (mode === "auto-run") {
const anyApplied = Object.values(newProposals).some(p => p.status === "applied");
if (anyApplied) {
try {
await app.queuePrompt(0, 1);
} catch (e) {
const msg = e?.message || String(e);
showToast("warn", "PromptChain AI", `Queue failed: ${msg}`);
}
}
}
} catch (e) {
if (stoppedByUser) {
// User-initiated cancel — roll back the user's turn (no
// assistant response to attach to it) without an error banner.
chat = chat.filter(t => t._turnId !== turnId);
} else {
errorBanner = e?.message || "Request failed";
chat = chat.filter(t => t._turnId !== turnId);
}
if (turnImages.length) attachedImages = turnImages; // let the user retry
} finally {
busy = false;
activeRequestId = "";
activeMessageText = "";
llmCallInFlight = false;
stoppedByUser = false;
teardownStream();
// Drain any queued message now that we're no longer busy with
// chat. The queue may still need to wait on ComfyUI rendering —
// drainQueueIfReady handles that gate.
drainQueueIfReady();
}
}
// Fires the queued message when both gates are clear: chat is idle
// AND ComfyUI is not rendering (VRAM contention guard, applied in
// every mode — chat and image model can't both have the GPU).
// Called from handleSubmit's finally and from the comfy "status"
// listener when queue_remaining transitions to 0.
async function drainQueueIfReady() {
if (!queuedMessage && queuedImages.length === 0) return;
if (busy) return;
if (await isQueueBusy()) return;
const pending = queuedMessage;
queuedMessage = "";
if (queuedImages.length) {
attachedImages = queuedImages;
queuedImages = [];
}
inputText = pending;
queueMicrotask(autoGrowInput);
handleSubmit();
}
function handleKeydown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmitOrQueue();
}
}
// ── Image attach (button / paste / drag-drop) ──────────────────
let fileInputEl;
let dragOver = $state(false);
// Read a File as a data URL, upload it to the user folder, and stage the
// returned {hash,url}. Pixels go to disk immediately; chat state only
// ever holds the hash + serve URL.
async function uploadFile(file) {
if (!file || !file.type?.startsWith("image/")) return;
uploadingImage = true;
try {
const dataUrl = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.onerror = rej;
fr.readAsDataURL(file);
});
const data = await postJson("/promptchain/ai/upload-image", {
data: dataUrl, media_type: file.type,
});
if (!data?.hash) {
showToast("warn", "PromptChain AI", data?.error || "Image upload failed");
return;
}
attachedImages = [
...attachedImages,
{ hash: data.hash, url: data.url, name: file.name || "image" },
];
focusInput(); // ready to type a request after dropping/pasting
} catch (e) {
showToast("warn", "PromptChain AI", `Image upload failed: ${e?.message || e}`);
} finally {
uploadingImage = false;
}
}
function removeAttachedImage(hash) {
attachedImages = attachedImages.filter((i) => i.hash !== hash);
}
function pickFile() { fileInputEl?.click(); }
function onFileChange(e) {
for (const f of e.target.files || []) uploadFile(f);
e.target.value = ""; // let the same file be picked again later
}
function onComposerPaste(e) {
for (const it of e.clipboardData?.items || []) {
if (it.type?.startsWith("image/")) {
const f = it.getAsFile();
if (f) { e.preventDefault(); uploadFile(f); }
}
}
}
function onComposerDrop(e) {
e.preventDefault();
dragOver = false;
for (const f of e.dataTransfer?.files || []) uploadFile(f);
}
function onComposerDragOver(e) {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
dragOver = true;
}
}
// dragleave fires when crossing into child elements too; only clear the
// overlay when the pointer actually leaves the panel.
function onPanelDragLeave(e) {
if (!panelEl) return;
if (!e.relatedTarget || !panelEl.contains(e.relatedTarget)) dragOver = false;
}
// Name/provider heuristic for whether the configured model can see
// images — drives the attach button's enabled state. Permissive by
// design (the server tries regardless); this just sets expectations.
function inferVision(cfg) {
if (cfg?.provider === "cloud") {
const svc = cfg.cloud?.service || "claude";
if (svc === "claude") return true;
return /gpt-4o|gpt-4\.1|o\d|vision|gemini/.test((cfg.cloud?.model || "").toLowerCase());
}
return /vl|vision|llava|moondream|minicpm-?v|glm-?4\.?\d*v|gemma3|qwen2?\.?5?-?vl|pixtral|internvl/
.test((cfg?.local?.model || "").toLowerCase());
}
// ── Proposal lifecycle ─────────────────────────────────────────
function applyProposalLocal(proposalId, proposal, persistApplied = true) {
const view = node?._pcrEditor;
const outputText = proposal?.tool_result?.output_text || "";
const promptState = proposal?.tool_result?.prompt_state;
if (view && outputText) {
applyPromptText(view, outputText, node, promptState);
}
if (persistApplied) {
const next = { ...proposals };
next[proposalId] = {
...proposal,
status: "applied",
appliedAt: Date.now(),
};
proposals = next;
}
}
async function applyProposal(proposalId) {
const p = proposals[proposalId];
if (!p) return;
applyProposalLocal(proposalId, p, /*persistApplied=*/true);
// If the user switched to auto-run while this proposal was pending
// (race-recovery accept), honor the queue intent. Mirrors the
// post-submit auto-queue path.
if (mode === "auto-run") {
try {
await app.queuePrompt(0, 1);
} catch (e) {
showToast("warn", "PromptChain AI", `Queue failed: ${e?.message || e}`);
}
}
}
function rejectProposal(proposalId) {
const p = proposals[proposalId];
if (!p) return;
const next = { ...proposals };
next[proposalId] = { ...p, status: "rejected" };
proposals = next;
}
function clearChat() {
chat = [];
proposals = {};
historyRaw = [];
chatSummary = "";
errorBanner = "";
}
// ── Per-turn actions (regenerate / edit-resend) ────────────────
// User-text messages appear in BOTH the display `chat` array and the
// canonical `historyRaw`, in the same order (tool_use/tool_result blocks
// pad historyRaw but never user-text). So the Nth user turn in chat maps
// to the Nth user-text message in historyRaw — used to rewind both in
// lockstep when re-running from an earlier point.
function userTextRawIndices() {
const idxs = [];
historyRaw.forEach((m, i) => {
if ((m?.role || "") !== "user") return;
const c = m.content;
const hasText = typeof c === "string"
|| (Array.isArray(c) && c.some((b) => b?.type === "text"));
if (hasText) idxs.push(i);
});
return idxs;
}
// Rewind to just before the user turn at chatIndex, then re-run it with
// (possibly edited) text. Everything after that turn — assistant replies,
// proposals, canonical tool exchanges — is discarded (standard chat-edit
// fork semantics). The doc itself isn't reverted; the user re-applies if
// they want. handleSubmit re-appends the user turn.
function resendFromUserTurn(chatIndex, newText) {
if (busy) return;
const text = (newText ?? "").trim();
if (!text) return;
let ordinal = -1;
for (let i = 0; i <= chatIndex && i < chat.length; i++) {
if (chat[i]?.role === "user") ordinal++;
}
if (ordinal < 0) return;
const rawIdxs = userTextRawIndices();
if (ordinal < rawIdxs.length) {
historyRaw = historyRaw.slice(0, rawIdxs[ordinal]);
}
chat = chat.slice(0, chatIndex);
inputText = text;
queueMicrotask(autoGrowInput);
handleSubmit();
}
// Re-run the most recent user message unchanged (reroll for a different
// proposal/reply).
function regenerateLast() {
if (busy) return;
for (let i = chat.length - 1; i >= 0; i--) {
if (chat[i]?.role === "user") {
resendFromUserTurn(i, chat[i].text);
return;
}
}
}
// ── Visibility / lifecycle ─────────────────────────────────────
let toggleListener = null;
function emitToggle(visible) {
onToggle(visible);
toggleListener?.(visible);
}
// Focus the composer after the DOM settles (panel just shown, or an
// image just staged) so the user can type immediately.
function focusInput() {
tick().then(() => inputEl?.focus());
}
// Default the panel open on a fresh main node when a provider is configured —
// surfaces the flagship feature. Only when there's no explicit per-node
// preference yet (a node the user closed, or a saved workflow, keeps its
// state) and the global setting allows it.
async function maybeAutoOpen() {
if (!aiAssistantEnabled()) return; // experimental gate off
const pref = node.properties?.pcrAiAssistant;
const setting = app.ui?.settings?.getSettingValue?.("PromptChain.AutoOpenAIAssistant");
if (pref !== undefined) return; // explicit per-node state wins
if (setting === false) return; // globally disabled
try {
// First-run: default to Local if Ollama already serves the recommended
// model (no-op once any provider is chosen) so a fresh instance resolves
// before we check, instead of racing the onboarding config write.
await fetch("/promptchain/ai/auto-configure", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
}).catch(() => {});
const cfg = await (await fetch("/promptchain/ai/config")).json();
if (cfg.provider) show();
} catch {}
}
export function show() {
if (!aiAssistantEnabled()) return;
isVisible = true;
if (node.properties) node.properties.pcrAiAssistant = true;
emitToggle(true);
probeLocalProvider();
focusInput();
}
// Preflight ping when the panel opens. If the configured local
// provider isn't answering, try to wake it (server-side spawns
// `ollama serve`) before showing an error — Windows installs almost
// always have ollama in PATH, so the typical "user killed Ollama"
// case recovers in <2s without bothering them.
async function probeLocalProvider() {
probing = true;
try {
const cfgR = await fetch("/promptchain/ai/config");
const cfg = await cfgR.json().catch(() => ({}));
visionCapable = inferVision(cfg);
// No provider yet — first try to default to Local if Ollama is already
// serving the recommended model (zero-setup happy path). Only then, if
// still unconfigured and not dismissed, prompt the user to set one up.
if (!cfg.provider) {
const auto = await postJson("/promptchain/ai/auto-configure", {});
if (auto.configured) {
visionCapable = true; // recommended model (qwen3-vl) is vision-capable
return;
}
try {
const d = await (await fetch("/promptchain/ai-setup/dismissed")).json();
if (!d.dismissed) window.dispatchEvent(new CustomEvent("promptchain:show-ai-setup"));
} catch {}
return;
}
if (cfg.provider !== "local") return;
infoBanner = "Checking AI provider...";
const test = await postJson("/promptchain/ai/test", { provider: "local" });
if (test.ok) {
infoBanner = "";
return;
}
const base = cfg.local?.base_url || "localhost:11434";
infoBanner = `Ollama down at ${base}, attempting start...`;
const wake = await postJson("/promptchain/ai/wake-local", {});
infoBanner = "";
if (wake.ok) {
showToast("success", "PromptChain AI", "Started Ollama.");
} else {
errorBanner = wake.error
? `Couldn't start Ollama at ${base}. ${wake.error}`
: `Ollama offline at ${base}. Start it and try again.`;
}
} catch {
// best-effort — silent on probe failure
} finally {
probing = false;
}
}
async function postJson(url, body) {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.json().catch(() => ({}));
}
export function hide() {
isVisible = false;
if (node.properties) node.properties.pcrAiAssistant = false;
emitToggle(false);
}
export function toggle() { isVisible ? hide() : show(); }
export function getIsVisible() { return isVisible; }
export function setToggleListener(cb) { toggleListener = cb; }
// Upper bound mirrors ImagePanel.getMaxPanelWidth: row width minus the
// image panel/dividers. Editor stack is allowed to fully collapse — the
// panel can cover it. Returns +Infinity until the row has laid out so
// the initial state isn't accidentally clamped to MIN_WIDTH.
function getMaxPanelWidth() {
const row = panelEl?.parentElement;
const rowWidth = row?.offsetWidth || 0;
if (!rowWidth) return Number.POSITIVE_INFINITY;
const imagePanelW = row.querySelector(".pcr-image-panel")?.offsetWidth || 0;
const imageDividerW = row.querySelector(".pcr-image-divider")?.offsetWidth || 0;
const dividerW = dividerEl?.offsetWidth || 0;
return Math.max(MIN_WIDTH, rowWidth - imagePanelW - imageDividerW - dividerW);
}
function clampPanelWidth(value) {
return Math.min(Math.max(MIN_WIDTH, value), getMaxPanelWidth());
}
// Divider drag — capture phase, scaled by the litegraph zoom.
let dividerAc;
onMount(() => {
onRegister?.({ show, hide, toggle, getIsVisible, setToggleListener, cleanup });
if (isVisible) probeLocalProvider();
else maybeAutoOpen();
// If the user configures a provider later (e.g. finishes onboarding after
// this node mounted), re-attempt the auto-open — fixes the first-run race
// where the node mounts before any provider exists.
aiConfiguredHandler = () => { if (!isVisible) maybeAutoOpen(); };
window.addEventListener("promptchain:ai-configured", aiConfiguredHandler);
// Experimental gate flipped off in Settings while the panel is open —
// close it; the menubar button it would reopen from is gone too.
aiEnabledChangedHandler = (e) => {
if (e.detail?.value !== true && isVisible) hide();
};
window.addEventListener("promptchain:ai-assistant-enabled-changed", aiEnabledChangedHandler);
dividerAc = new AbortController();
let isDragging = false;
let startX = 0;
let startWidth = 0;
document.addEventListener("pointerdown", (e) => {
if (!dividerEl || (e.target !== dividerEl && !dividerEl.contains(e.target))) return;
isDragging = true;
startX = e.clientX;
startWidth = panelEl?.offsetWidth || panelWidth;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const canvas = document.querySelector("canvas.lgraphcanvas");
if (canvas && typeof e.pointerId === "number") {
try { if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); } catch {}
}
}, { capture: true, signal: dividerAc.signal });
document.addEventListener("pointermove", (e) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
const inFs = !!document.querySelector(".pcr-fs-overlay");
const scale = inFs ? 1 : getCanvasScale();
const delta = (e.clientX - startX) / scale;
panelWidth = clampPanelWidth(startWidth + delta);
}, { capture: true, signal: dividerAc.signal });
document.addEventListener("pointerup", () => {
if (!isDragging) return;
isDragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (node.properties) node.properties.pcrAiPanelWidth = panelWidth;
}, { capture: true, signal: dividerAc.signal });
// Clamp a saved oversized width to current row layout. Updates live
// state only, not node.properties — the saved preference is preserved
// so the panel can grow back if the node is later widened.
requestAnimationFrame(() => { panelWidth = clampPanelWidth(panelWidth); });
// Drain a queued message as soon as ComfyUI finishes rendering.
// The "status" event fires after every prompt finishes with the
// new exec_info.queue_remaining; we only act when there's
// actually something queued, so the listener is otherwise inert.
comfyStatusHandler = (e) => {
const remaining = e?.detail?.exec_info?.queue_remaining;
if (typeof remaining !== "number") return;
if (remaining !== 0) return;
drainQueueIfReady();
};
api.addEventListener("status", comfyStatusHandler);
// VRAM yield: when a render starts mid-chat, cancel chat and
// re-queue. The handler is mode-agnostic — chat and image gen
// can't share the GPU regardless of mode.
comfyExecStartHandler = () => handleComfyExecutionStart();
api.addEventListener("execution_start", comfyExecStartHandler);
});
let comfyStatusHandler = null;
let comfyExecStartHandler = null;
let aiConfiguredHandler = null;
let aiEnabledChangedHandler = null;
function cleanup() {
dividerAc?.abort();
}
onDestroy(() => {
dividerAc?.abort();
if (aiConfiguredHandler) window.removeEventListener("promptchain:ai-configured", aiConfiguredHandler);
if (aiEnabledChangedHandler) window.removeEventListener("promptchain:ai-assistant-enabled-changed", aiEnabledChangedHandler);
if (elapsedTimer) clearInterval(elapsedTimer);
wsUnsub?.();
if (comfyStatusHandler) {
api.removeEventListener("status", comfyStatusHandler);
comfyStatusHandler = null;
}
if (comfyExecStartHandler) {
api.removeEventListener("execution_start", comfyExecStartHandler);
comfyExecStartHandler = null;
}
});
function resetWidth(e) {
e.preventDefault();
e.stopPropagation();
panelWidth = DEFAULT_WIDTH;
if (node.properties) node.properties.pcrAiPanelWidth = panelWidth;
}
const EMPTY_HINT = "Describe a change to your prompt — \"add red socks\", \"swap to anime style\", \"increase weight on red_socks\". The assistant will propose an edit you can accept, edit automatically, or auto-run.";
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={panelEl}
class="pcr-ai-panel"
style:width="{panelWidth}px"
style:display={isVisible ? "flex" : "none"}
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
ondblclick={(e) => e.stopPropagation()}
ondrop={onComposerDrop}
ondragover={onComposerDragOver}
ondragleave={onPanelDragLeave}
>
{#if dragOver}
<div class="pcr-ai-drop-overlay">
<div class="pcr-ai-drop-overlay-msg">Drop image to attach</div>
</div>
{/if}
<div class="pcr-ai-panel-header">
<span class="pcr-ai-panel-icon">{"✨"}</span>
<span class="pcr-ai-panel-title">AI Assistant</span>
<span class="pcr-ai-panel-spacer"></span>
{#if chat.length > 0}
<span class="pcr-ai-panel-close pcr-ai-panel-clearchat" onclick={clearChat} title="Clear chat">{"⟲"}</span>
{/if}
<span class="pcr-ai-panel-close" onclick={hide} title="Close panel">{"✕"}</span>
</div>
<div class="pcr-ai-panel-body" bind:this={bodyEl} onscroll={recordPinState}>
{#if infoBanner}
<div class="pcr-ai-panel-info">{infoBanner}</div>
{/if}
{#if errorBanner}
<div class="pcr-ai-panel-error">
<strong>Error</strong>
<div>{errorBanner}</div>
<button class="pcr-ai-panel-link" type="button" onclick={() => errorBanner = ""}>Dismiss</button>
</div>
{/if}
<ChatTimeline
turns={chat}
{proposals}
{busy}
{thinkingState}
condensed={!!chatSummary}
emptyHint={EMPTY_HINT}
suppressEmptyHint={probing}
onAccept={applyProposal}
onReject={rejectProposal}
onRegenerate={regenerateLast}
onEditResend={resendFromUserTurn}
/>
</div>
<div class="pcr-ai-panel-composer">
{#if queuedMessage}
<div class="pcr-ai-queued" title="Will send when current generation finishes">
<span class="pcr-ai-queued-label">Queued</span>
<span class="pcr-ai-queued-text">{queuedMessage}</span>
<button
type="button"
class="pcr-ai-queued-cancel"
title="Cancel queued message"
onclick={() => { queuedMessage = ""; }}
>×</button>
</div>
{/if}
<div class="pcr-ai-panel-input-card">
{#if attachedImages.length}
<div class="pcr-ai-attach-row">
{#each attachedImages as img (img.hash)}
<div class="pcr-ai-attach-chip" title={img.name}>
<img src={img.url} alt={img.name} />
<button
type="button"
class="pcr-ai-attach-remove"
title="Remove image"
onclick={() => removeAttachedImage(img.hash)}
>×</button>
</div>
{/each}
{#if uploadingImage}
<div class="pcr-ai-attach-chip pcr-ai-attach-loading">…</div>
{/if}
</div>
{/if}
<textarea
bind:this={inputEl}
bind:value={inputText}
class="pcr-ai-panel-input"
placeholder="Ask a follow-up, refine, or attach an image..."
rows="1"
oninput={autoGrowInput}
onkeydown={handleKeydown}
onpaste={onComposerPaste}
></textarea>
<div class="pcr-ai-panel-composer-toolbar">
<div class="pcr-ai-panel-composer-group">
<input
bind:this={fileInputEl}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
multiple
style="display:none"
onchange={onFileChange}
/>
<button
type="button"
class="pcr-ai-attach-btn"
title={visionCapable ? "Attach image" : "Configured model may not support images"}
disabled={uploadingImage}
onclick={pickFile}
>
{@html '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'}
</button>
{#if !visionCapable}
<span class="pcr-ai-vision-warn" title="The configured model may not read images. Switch to a vision model (e.g. qwen3-vl) in AI settings.">⚠</span>
{/if}
<CommandsMenu
{extraVerbs}
onChangeExtraVerbs={(v) => { extraVerbs = v; }}
/>
<ModeDropdown
value={mode}
onChange={(v) => { mode = v; }}
/>
</div>
<div class="pcr-ai-panel-composer-group">
{#if busy}
<button
class="pcr-ai-panel-submit is-stop"
title="Stop generation"
type="button"
onclick={handleStop}
>
{@html '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'}
</button>
{:else}
<button
class="pcr-ai-panel-submit"
title="Send"
type="button"
disabled={!canSubmit}
onclick={handleSubmitOrQueue}
>
{@html '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>'}
</button>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={dividerEl}
class="pcr-ai-divider"
style:display={isVisible ? "flex" : "none"}
ondblclick={resetWidth}
title="Drag to resize · double-click to reset"
></div>