Compare commits

...

18 Commits

Author SHA1 Message Date
shrimbly
e7400c9546 fix(prompt-node): satisfy declared variables case-insensitively on connect
Wiring a node titled Subject into the blank socket now adopts a declared
@subject variable instead of creating a case-twin socket, and the stale
unconnected placeholder is dropped once a connected socket claims its name.
2026-07-04 09:24:13 +12:00
shrimbly
8db3cb65a2 fix(prompt-node): render line breaks as <br> so the caret stays valid
A trailing newline text node produces no line box under pre-wrap, leaving
the caret in a broken half-line position after Enter — and Backspace then
deleted the preceding character instead of the break. Newlines now render
as <br> elements with a padding break at the end of content (stripped when
parsing), and Enter inserts a <br> with the caret anchored in a text node.
2026-07-04 06:26:27 +12:00
shrimbly
16dee03ca5 fix(prompt-node): address review feedback on menu and rename edge cases
Create option now respects case-insensitive matches, the mention menu
clamps to the viewport and carries listbox semantics, rejected socket
renames surface a warning toast, and template-only translations use $t.
2026-07-04 05:21:11 +12:00
shrimbly
05c9a06eab Merge remote-tracking branch 'origin/main' into prompt-node-lite 2026-07-04 05:05:00 +12:00
shrimbly
9021fa268f refactor(prompt-node): simplify sync plumbing and editor event handling
Socket reconcile moves into the node via widget.callback (widget is now
presentational), mention detection collapses to one selectionchange
listener, InputSlot reuses EditableText, visited drops from the platform
resolution API, and the platform modules merge into promptTemplate.ts.
Also fixes stale slot labels after rename by emitting
node:slot-label:changed.
2026-07-04 04:38:21 +12:00
shrimbly
c272c56588 polish(prompt-node): list matching variables above the create option 2026-07-03 19:11:44 +12:00
shrimbly
56ba01f55a polish(prompt-node): lighter resolved chip and lower editor min height 2026-07-03 19:10:38 +12:00
shrimbly
f1c3754110 polish(prompt-node): reduce default node height to 200 2026-07-03 19:08:23 +12:00
shrimbly
bd68732d34 polish(prompt-node): soften resolved chip color and shrink default node 2026-07-03 19:06:32 +12:00
shrimbly
2279f7ec4e fix(prompt-node): keep mention menu closed after Escape 2026-07-03 11:28:55 +12:00
shrimbly
e97eb32086 refactor(prompt-node): strip saved-prompt library to core variable node 2026-07-03 11:06:44 +12:00
shrimbly
4fe349f8b7 refactor(prompt-node): rebuild prompt manager on BaseModalLayout
- Replace the headless reka dialog chrome with the shared BaseModalLayout, opened via dialogService.showLayoutDialog
- Add an optional anchorClass passthrough to SearchInput to theme the bar surface
- Export SELF_STYLED_PANEL_CONTENT_CLASS for self-styled layout dialogs
- Group prompt versions by prompt-id tag so updated prompts no longer duplicate in the list
- Polish the manager list, search, and new-prompt button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:08:47 +12:00
shrimbly
5a60c5b1db polish(prompt-node): refine manager dialog layout and styling
Consistent surfaces and spacing across the list/editor/history panes, a selected-state token for list rows, a footer action bar (delete left, save right), dividers and a Current badge in history, and clearer empty states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:00:55 +12:00
shrimbly
8d0e2dbc59 feat(prompt-node): editable prompt manager with version history
- Extract the chip editor into a reusable PromptEditor component (node passes variable/connected/create context; the manager uses it standalone).
- Make the manager dialog edit prompt content and create new prompts via the shared editor; saving an edit writes a new version, a name-only change updates metadata.
- Add a version history panel: restore an older version by saving it as the new latest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:54:00 +12:00
shrimbly
d7e74d4309 feat(prompt-node): manager dialog, grouped @ menu, and version-aware storage
- Add a 'Manage prompts' dialog (library button) to list, search, preview, rename, and delete saved prompts.
- Group the @ mention menu under Variables and Saved prompts headers.
- Move prompt storage to a stable prompt_id shared across immutable version-assets: fetchPrompts collapses to the newest version, plus savePromptVersion/fetchPromptVersions and version-aware delete/rename. References resolve to the latest version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:38:12 +12:00
shrimbly
9e346ccfd0 feat(prompt-node): variable input sockets, editable titles, self-hosted save
- Declare a variable by typing @name; the node reconciles input sockets to the declared variables (dropping ones that are neither declared nor connected).
- Editable input socket titles: double-click to rename in place, preserving the link and rewriting matching @references.
- Higher-contrast chips (azure when resolved, red when not) and recolor reactively when a socket's connection changes, not just on text edits.
- Restyle the @ mention menu with the Button component and drop the leading icons.
- Store prompts via the asset API in a way the self-hosted backend accepts: repeated tag fields, a name field, and a category first tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:43:16 +12:00
shrimbly
d7200f9dfc fix(dev): apply changeOrigin to https dev proxy targets
Plain http backends are unchanged, but https dev targets (cloud, or a remote backend over Tailscale) need changeOrigin so the TLS SNI matches the target host and the upstream can route the connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:42:38 +12:00
shrimbly
6a7017b747 feat: add prompt node with composable prompt references
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:06:25 +12:00
14 changed files with 1413 additions and 3 deletions

View File

@@ -0,0 +1,326 @@
<template>
<div class="relative size-full">
<div
ref="editorEl"
role="textbox"
aria-multiline="true"
:aria-label="$t('promptNode.editorLabel')"
:contenteditable="readonly ? 'false' : 'true'"
spellcheck="false"
data-testid="prompt-editor"
class="size-full overflow-auto rounded-sm border border-border-default bg-component-node-widget-background p-2 wrap-break-word whitespace-pre-wrap outline-none"
@input="syncFromEditor"
@keydown="onKeydown"
@paste="onPaste"
@blur="closeMenu"
/>
<span
v-if="isEmpty && placeholder"
class="pointer-events-none absolute top-2 left-2 text-muted-foreground"
>
{{ placeholder }}
</span>
<Teleport to="body">
<div
v-if="menuOpen"
role="listbox"
class="fixed z-3000 max-h-60 w-56 overflow-y-auto rounded-lg border border-border-default bg-base-background p-1 text-xs shadow-lg"
:style="{ top: `${menuTop}px`, left: `${menuLeft}px` }"
@mousedown.prevent
>
<template v-if="menuItems.length">
<Button
v-for="(item, index) in menuItems"
:key="item.name"
role="option"
:aria-selected="index === highlighted"
variant="textonly"
size="sm"
:class="
cn(
'w-full justify-start',
index === highlighted && 'bg-secondary-background-hover'
)
"
@mouseenter="highlighted = index"
@click="selectItem(item)"
>
<span class="truncate">{{
item.create
? $t('promptNode.createVariable', { name: item.name })
: item.name
}}</span>
</Button>
</template>
<div v-else class="px-2 py-1.5 text-muted-foreground">
{{ $t('promptNode.noMatches') }}
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import {
CHIP_SELECTOR,
createChipElement,
parseElementToTemplate,
renderTemplateToElement
} from '@/components/graph/widgets/promptTemplateDom'
import Button from '@/components/ui/button/Button.vue'
import type { PromptTemplate } from '@/platform/prompts/promptTemplate'
const {
variableNames = [],
connectedNames = [],
placeholder = '',
readonly = false
} = defineProps<{
/** `@` variable references this editor may offer and resolve. */
variableNames?: string[]
/** Variables that are wired up, so their chips render as resolved. */
connectedNames?: string[]
placeholder?: string
readonly?: boolean
}>()
const modelValue = defineModel<PromptTemplate>({ default: () => [] })
const editorEl = ref<HTMLElement>()
const isEmpty = computed(() => (modelValue.value ?? []).length === 0)
let lastSerialized = ''
function renderFromModel() {
if (!editorEl.value) return
renderTemplateToElement(editorEl.value, modelValue.value ?? [])
refreshChipStates()
}
function syncFromEditor() {
if (!editorEl.value) return
const template = parseElementToTemplate(editorEl.value)
lastSerialized = JSON.stringify(template)
modelValue.value = template
refreshChipStates()
}
watch(modelValue, (value) => {
const serialized = JSON.stringify(value ?? [])
if (serialized === lastSerialized) return
lastSerialized = serialized
renderFromModel()
})
watch(() => connectedNames, refreshChipStates)
onMounted(() => {
renderFromModel()
lastSerialized = JSON.stringify(modelValue.value ?? [])
})
function refreshChipStates() {
const host = editorEl.value
if (!host) return
const connected = new Set(connectedNames)
for (const chip of host.querySelectorAll<HTMLElement>(CHIP_SELECTOR)) {
const resolvable = connected.has(chip.getAttribute('data-chip-name') ?? '')
chip.classList.toggle('bg-primary-background/50', resolvable)
chip.classList.toggle('bg-destructive-background', !resolvable)
}
}
// --- @ mention autocomplete ----------------------------------------------
interface MenuItem {
name: string
create?: boolean
}
const menuOpen = ref(false)
const menuItems = ref<MenuItem[]>([])
const highlighted = ref(0)
const menuTop = ref(0)
const menuLeft = ref(0)
interface MentionAnchor {
node: Text
start: number
end: number
}
let mentionAnchor: MentionAnchor | null = null
function closeMenu() {
menuOpen.value = false
mentionAnchor = null
}
function recomputeMenu(query: string) {
const lower = query.toLowerCase()
const vars: MenuItem[] = variableNames
.filter((name) => name.toLowerCase().includes(lower))
.map((name) => ({ name }))
const create: MenuItem[] =
query && !variableNames.some((name) => name.toLowerCase() === lower)
? [{ name: query, create: true }]
: []
menuItems.value = [...vars, ...create]
highlighted.value = 0
}
// A single caret-position listener drives mention detection: typing, clicking,
// and arrow keys all land here, while Escape (no caret move) does not — so a
// dismissed menu stays closed.
useEventListener(document, 'selectionchange', detectMention)
function detectMention() {
const host = editorEl.value
const selection = window.getSelection()
if (
!host ||
!selection ||
!selection.isCollapsed ||
selection.rangeCount === 0
) {
return closeMenu()
}
const node = selection.anchorNode
if (!node || node.nodeType !== Node.TEXT_NODE || !host.contains(node)) {
return closeMenu()
}
const textNode = node as Text
const offset = selection.anchorOffset
const before = (textNode.textContent ?? '').slice(0, offset)
const match = /(?:^|\s)@([^\s@]*)$/.exec(before)
if (!match) return closeMenu()
mentionAnchor = {
node: textNode,
start: offset - match[1].length - 1,
end: offset
}
recomputeMenu(match[1])
positionMenu()
menuOpen.value = true
}
function positionMenu() {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0).cloneRange()
range.collapse(true)
const rect = range.getBoundingClientRect()
// Keep the menu (w-56 / max-h-60) inside the viewport near window edges.
menuTop.value = Math.max(0, Math.min(rect.bottom + 4, innerHeight - 240))
menuLeft.value = Math.max(0, Math.min(rect.left, innerWidth - 224))
}
function selectItem(item: MenuItem) {
if (!mentionAnchor) return
const { node, start, end } = mentionAnchor
const parent = node.parentNode
if (!parent) return
const text = node.textContent ?? ''
node.textContent = text.slice(0, start)
const after = document.createTextNode(` ${text.slice(end)}`)
const chip = createChipElement(item.name)
parent.insertBefore(after, node.nextSibling)
parent.insertBefore(chip, after)
setCaret(after, 1)
closeMenu()
syncFromEditor()
}
function setCaret(node: Node, offset: number) {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.setStart(node, Math.min(offset, node.textContent?.length ?? 0))
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}
function insertTextAtCaret(value: string) {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(value)
range.insertNode(node)
setCaret(node, value.length)
}
function insertLineBreak() {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
range.deleteContents()
const br = document.createElement('br')
range.insertNode(br)
// A break at the end of the content has no line box for the caret to land
// in; pad it with a second break (stripped as padding when parsing).
if (!hasContentAfter(br)) br.after(document.createElement('br'))
// Anchor the caret in a text node — Chrome renders element-anchored caret
// positions (between two <br>s) unreliably.
let anchor = br.nextSibling
if (!anchor || anchor.nodeType !== Node.TEXT_NODE) {
anchor = document.createTextNode('')
br.after(anchor)
}
setCaret(anchor, 0)
}
function hasContentAfter(node: Node): boolean {
for (let next = node.nextSibling; next; next = next.nextSibling) {
if (next.nodeType !== Node.TEXT_NODE || next.textContent) return true
}
return false
}
function onKeydown(event: KeyboardEvent) {
event.stopPropagation()
if (menuOpen.value) {
const count = menuItems.value.length
if (event.key === 'ArrowDown' && count) {
event.preventDefault()
highlighted.value = (highlighted.value + 1) % count
} else if (event.key === 'ArrowUp' && count) {
event.preventDefault()
highlighted.value = (highlighted.value - 1 + count) % count
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
if (count) selectItem(menuItems.value[highlighted.value])
} else if (event.key === 'Escape') {
event.preventDefault()
closeMenu()
}
return
}
if (event.key === 'Enter') {
event.preventDefault()
insertLineBreak()
syncFromEditor()
}
}
function onPaste(event: ClipboardEvent) {
event.preventDefault()
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text) {
insertTextAtCaret(text)
syncFromEditor()
}
}
</script>

View File

@@ -0,0 +1,107 @@
import { render, screen, waitFor } from '@testing-library/vue'
import { shallowReactive } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PromptNodeWidget from '@/components/graph/widgets/PromptNodeWidget.vue'
import type { PromptTemplate } from '@/platform/prompts/promptTemplate'
const g = vi.hoisted(() => {
const promptNode = {
id: '1',
inputs: [] as { name?: string; link: number | null }[]
}
const graph = {
getNodeById: (id: string) => (id === '1' ? promptNode : undefined)
}
return { graph, promptNode }
})
// Mirror the real node manager, which exposes node.inputs as a reactive array,
// so the widget's connection computeds update when sockets change.
g.promptNode.inputs = shallowReactive(g.promptNode.inputs)
/** Sets the node's connected variable input sockets by name (mutates in place). */
function connectSockets(names: string[]) {
g.promptNode.inputs.splice(
0,
g.promptNode.inputs.length,
...names.map((name, i) => ({ name, link: i + 1 }))
)
}
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: { graph: g.graph } })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
promptNode: {
editorPlaceholder:
"Write a prompt, or type {'@'} to reference a connected input",
editorLabel: 'Prompt editor',
noMatches: 'No matches',
createVariable: 'Create variable: {name}'
}
}
}
})
interface MountOptions {
template?: PromptTemplate
connected?: string[]
}
function renderWidget({ template = [], connected = [] }: MountOptions = {}) {
connectSockets(connected)
return render(PromptNodeWidget, {
props: { modelValue: template, nodeId: '1' },
global: { plugins: [i18n] }
})
}
beforeEach(() => {
vi.clearAllMocks()
g.promptNode.inputs.splice(0, g.promptNode.inputs.length)
})
describe('PromptNodeWidget', () => {
it('marks a variable chip unresolved when no socket with that name is connected', () => {
renderWidget({
template: [{ type: 'var', name: 'setting' }],
connected: []
})
expect(screen.getByText('@setting')).toHaveClass(
'bg-destructive-background'
)
})
it('resolves a variable chip when a socket with that name is connected', () => {
renderWidget({
template: [{ type: 'var', name: 'setting' }],
connected: ['setting']
})
expect(screen.getByText('@setting')).toHaveClass('bg-primary-background/50')
})
it('turns a variable chip blue when its socket becomes connected', async () => {
renderWidget({ template: [{ type: 'var', name: 'animal' }] })
const chip = screen.getByText('@animal')
expect(chip).toHaveClass('bg-destructive-background')
g.promptNode.inputs.push({ name: 'animal', link: 1 })
await waitFor(() => expect(chip).toHaveClass('bg-primary-background/50'))
expect(chip).not.toHaveClass('bg-destructive-background')
})
it('shows a placeholder with a literal @ when empty', () => {
renderWidget()
// Verifies the `@` in the message is escaped (`{'@'}`) so vue-i18n does not
// parse it as linked-message syntax and fail to compile.
expect(screen.getByText(/type @ to reference/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,54 @@
<template>
<div
class="flex size-full min-h-[100px] flex-col text-xs"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@wheel.stop
>
<div class="min-h-0 flex-1">
<PromptEditor
v-model="modelValue"
:variable-names
:connected-names
:placeholder="$t('promptNode.editorPlaceholder')"
:readonly="isReadOnly"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PromptEditor from '@/components/graph/widgets/PromptEditor.vue'
import type { PromptTemplate } from '@/platform/prompts/promptTemplate'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const { widget, nodeId } = defineProps<{
widget?: SimplifiedWidget<PromptTemplate>
nodeId: string
}>()
const modelValue = defineModel<PromptTemplate>({ default: () => [] })
const canvasStore = useCanvasStore()
const isReadOnly = computed(() =>
Boolean(widget?.options?.read_only || widget?.options?.disabled)
)
const inputSockets = computed(
() => canvasStore.canvas?.graph?.getNodeById(toNodeId(nodeId))?.inputs ?? []
)
const variableNames = computed(() =>
inputSockets.value.flatMap((input) => (input.name ? [input.name] : []))
)
const connectedNames = computed(() =>
inputSockets.value.flatMap((input) =>
input.name && input.link != null ? [input.name] : []
)
)
</script>

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
createChipElement,
parseElementToTemplate,
renderTemplateToElement
} from '@/components/graph/widgets/promptTemplateDom'
import type { PromptTemplate } from '@/platform/prompts/promptTemplate'
function host(): HTMLElement {
return document.createElement('div')
}
describe('promptTemplateDom', () => {
it('renders chips with their variable name', () => {
const chip = createChipElement('setting')
expect(chip.getAttribute('data-chip-name')).toBe('setting')
expect(chip.textContent).toBe('@setting')
expect(chip.contentEditable).toBe('false')
})
it('round-trips text and variable segments', () => {
const template: PromptTemplate = [
{ type: 'text', value: 'a portrait of ' },
{ type: 'var', name: 'subject' },
{ type: 'text', value: ', set in ' },
{ type: 'var', name: 'setting' }
]
const el = host()
renderTemplateToElement(el, template)
expect(parseElementToTemplate(el)).toEqual(template)
})
it('merges adjacent text nodes', () => {
const el = host()
el.append(document.createTextNode('foo'))
el.append(document.createTextNode('bar'))
expect(parseElementToTemplate(el)).toEqual([
{ type: 'text', value: 'foobar' }
])
})
it('treats <br> elements as newlines', () => {
const el = host()
el.append(document.createTextNode('line1'))
el.append(document.createElement('br'))
el.append(document.createTextNode('line2'))
expect(parseElementToTemplate(el)).toEqual([
{ type: 'text', value: 'line1\nline2' }
])
})
it('renders newlines as <br> elements', () => {
const el = host()
renderTemplateToElement(el, [{ type: 'text', value: 'line1\nline2' }])
expect(Array.from(el.childNodes).map((node) => node.nodeName)).toEqual([
'#text',
'BR',
'#text'
])
})
it('round-trips a trailing newline through a padded break', () => {
const template: PromptTemplate = [{ type: 'text', value: 'line1\n' }]
const el = host()
renderTemplateToElement(el, template)
expect(Array.from(el.childNodes).map((node) => node.nodeName)).toEqual([
'#text',
'BR',
'BR'
])
expect(parseElementToTemplate(el)).toEqual(template)
})
it('parses a lone <br> as an empty template', () => {
const el = host()
el.append(document.createElement('br'))
expect(parseElementToTemplate(el)).toEqual([])
})
it('ignores empty text nodes', () => {
const el = host()
el.append(document.createTextNode(''))
el.append(createChipElement('x'))
expect(parseElementToTemplate(el)).toEqual([{ type: 'var', name: 'x' }])
})
})

View File

@@ -0,0 +1,122 @@
import type {
PromptSegment,
PromptTemplate
} from '@/platform/prompts/promptTemplate'
export const CHIP_SELECTOR = '[data-chip-name]'
const CHIP_CLASS =
'prompt-chip mx-0.5 inline-flex items-center rounded-sm px-1 align-baseline select-none cursor-default bg-primary-background/50 text-base-foreground'
function isChipElement(node: Node): node is HTMLElement {
return (
node.nodeType === Node.ELEMENT_NODE &&
(node as HTMLElement).hasAttribute('data-chip-name')
)
}
export function createChipElement(name: string): HTMLSpanElement {
const el = document.createElement('span')
el.className = CHIP_CLASS
el.contentEditable = 'false'
el.setAttribute('data-chip-name', name)
el.textContent = `@${name}`
return el
}
/**
* Builds a flat fragment of text nodes, `<br>` breaks, and chips. Newlines
* render as `<br>` elements because a trailing `"\n"` text node produces no
* line box, leaving the caret without a valid position. A break at the very
* end gets a padding `<br>` so the empty last line renders.
*/
function createTemplateFragment(template: PromptTemplate): DocumentFragment {
const fragment = document.createDocumentFragment()
for (const segment of template) {
if (segment.type === 'var') {
fragment.append(createChipElement(segment.name))
continue
}
const lines = segment.value.split('\n')
lines.forEach((line, index) => {
if (index > 0) fragment.append(document.createElement('br'))
if (line) fragment.append(document.createTextNode(line))
})
}
if (fragment.lastChild?.nodeName === 'BR') {
fragment.append(document.createElement('br'))
}
return fragment
}
/** Renders a template into a contenteditable host as flat text nodes + chips. */
export function renderTemplateToElement(
host: HTMLElement,
template: PromptTemplate
): void {
host.replaceChildren(createTemplateFragment(template))
}
/** Reconstructs a template from the contenteditable host's current DOM. */
export function parseElementToTemplate(host: HTMLElement): PromptTemplate {
const segments: PromptSegment[] = []
appendNode(host, segments)
if (endsWithPaddingBreak(host)) stripTrailingNewline(segments)
return mergeText(segments)
}
/**
* A `<br>` as the final rendered node is presentational padding (ours, or the
* one browsers leave in an emptied contenteditable) — not a content newline.
*/
function endsWithPaddingBreak(host: HTMLElement): boolean {
for (let node = host.lastChild; node; node = node.previousSibling) {
if (node.nodeType === Node.TEXT_NODE && !node.textContent) continue
return node.nodeName === 'BR'
}
return false
}
function stripTrailingNewline(segments: PromptSegment[]): void {
const last = segments.at(-1)
if (last?.type !== 'text' || !last.value.endsWith('\n')) return
last.value = last.value.slice(0, -1)
if (!last.value) segments.pop()
}
function appendNode(node: Node, segments: PromptSegment[]): void {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const value = child.textContent ?? ''
if (value) segments.push({ type: 'text', value })
} else if (isChipElement(child)) {
segments.push({
type: 'var',
name: child.getAttribute('data-chip-name') ?? ''
})
} else if (child.nodeName === 'BR') {
segments.push({ type: 'text', value: '\n' })
} else {
// Stray wrappers (e.g. from paste): treat block elements as line breaks.
if (isBlockElement(child)) segments.push({ type: 'text', value: '\n' })
appendNode(child, segments)
}
}
}
function isBlockElement(node: Node): boolean {
return node.nodeName === 'DIV' || node.nodeName === 'P'
}
function mergeText(segments: PromptSegment[]): PromptTemplate {
const merged: PromptSegment[] = []
for (const segment of segments) {
const previous = merged.at(-1)
if (segment.type === 'text' && previous?.type === 'text') {
previous.value += segment.value
} else {
merged.push(segment)
}
}
return merged
}

View File

@@ -21,6 +21,7 @@ if (!isCloud) {
import './noteNode'
import './painter'
import './previewAny'
import './promptNode'
import './rerouteNode'
import './saveImageExtraOutput'
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)

View File

@@ -0,0 +1,229 @@
import type {
INodeInputSlot,
ISlotType,
LLink
} from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { PromptTemplate } from '@/platform/prompts/promptTemplate'
import {
autoSocketName,
planVariableSockets,
renameVariableInTemplate,
resolvePromptTemplate
} from '@/platform/prompts/promptTemplate'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
const PROMPT_WIDGET_NAME = 'prompt'
function toTemplate(value: unknown): PromptTemplate {
return Array.isArray(value) ? (value as PromptTemplate) : []
}
/** Reads a statically-known string value from a connected source node. */
function readStaticString(node: LGraphNode): string {
const widget = node.widgets?.find((w) => typeof w.value === 'string')
return typeof widget?.value === 'string' ? widget.value : ''
}
class PromptNode extends LGraphNode {
static override category: string
override isVirtualNode = true
constructor(title?: string) {
super(title ?? 'Prompt')
this.addOutput('STRING', 'STRING')
this.addInput('', 'STRING')
this.serialize_widgets = true
// Bridge the widget value through the widget value store, mirroring the
// multiline string widget, so the Vue node renderer drives the editor.
let fallback: PromptTemplate = []
const state = () => {
const graphId = resolveNodeRootGraphId(this, app.rootGraph?.id ?? '')
if (!graphId) return undefined
return useWidgetValueStore().getWidget(
widgetId(graphId, this.id, PROMPT_WIDGET_NAME)
)
}
const widget = this.addDOMWidget<HTMLElement, PromptTemplate>(
PROMPT_WIDGET_NAME,
'prompteditor',
document.createElement('div'),
{
getValue: () => toTemplate(state()?.value ?? fallback),
setValue: (value) => {
fallback = toTemplate(value)
const widgetState = state()
if (widgetState) widgetState.value = fallback
},
getMinHeight: () => 100
}
)
// Guarded so per-keystroke edits skip socket churn; only a change to the
// declared variable set triggers reconciliation.
let lastDeclaredKey: string | null = null
widget.callback = () => {
if (app.configuringGraph) return
const declared = this.declaredVarNames()
const key = JSON.stringify(declared)
if (key === lastDeclaredKey) return
lastDeclaredKey = key
this.reconcileVariableInputs(declared)
}
this.setSize([340, 200])
}
private getTemplate(): PromptTemplate {
return toTemplate(
this.widgets?.find((w) => w.name === PROMPT_WIDGET_NAME)?.value
)
}
/** Resolves the editor template to its final string at submission time. */
resolvePromptText(visited: ReadonlySet<string> = new Set()): string {
return resolvePromptTemplate(this.getTemplate(), (name) =>
this.resolveVar(name, visited)
)
}
private resolveVar(name: string, visited: ReadonlySet<string>): string {
const { graph } = this
if (!graph) return ''
const input = (this.inputs ?? []).find(
(slot) => slot.name === name && slot.link != null
)
if (!input || input.link == null) return ''
const link = graph.links[input.link]
const source = link ? graph.getNodeById(link.origin_id) : null
if (!source) return ''
const key = `node:${source.id}`
if (visited.has(key)) return ''
const next = new Set(visited).add(key)
return source instanceof PromptNode
? source.resolvePromptText(next)
: readStaticString(source)
}
override applyToGraph(extraLinks: LLink[] = []) {
const text = this.resolvePromptText()
applyFirstWidgetValueToGraph(this, extraLinks, () => text)
}
override onConnectionsChange(
type: ISlotType,
_index: number | undefined,
_connected: boolean
) {
if (app.configuringGraph) return
if (type !== LiteGraph.INPUT) return
this.reconcileVariableInputs(this.declaredVarNames())
}
override onAfterGraphConfigured() {
this.reconcileVariableInputs(this.declaredVarNames())
}
/**
* Renames a variable input socket in place (preserving its link) and updates
* every matching `@reference` in the editor text. Returns false when the
* rename cannot apply (name collision or missing socket).
*/
renameVariableInput(oldName: string, newName: string): boolean {
const name = newName.trim()
if (!name || name === oldName) return true
if ((this.inputs ?? []).some((slot) => slot.name === name)) return false
const input = (this.inputs ?? []).find((slot) => slot.name === oldName)
if (!input) return false
input.name = name
const widget = this.widgets?.find((w) => w.name === PROMPT_WIDGET_NAME)
if (widget) {
widget.value = renameVariableInTemplate(this.getTemplate(), oldName, name)
}
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT
})
return true
}
private declaredVarNames(): string[] {
const names: string[] = []
for (const segment of this.getTemplate()) {
if (segment.type === 'var' && !names.includes(segment.name)) {
names.push(segment.name)
}
}
return names
}
/**
* Makes input sockets mirror the declared variables: one named socket per
* variable, freshly connected sockets named after their source, and a single
* trailing placeholder for ad-hoc wiring. Sockets that are neither declared
* nor connected are dropped.
*/
private reconcileVariableInputs(declared: readonly string[]) {
for (const input of this.inputs ?? []) {
if (input.link != null && !input.name) {
input.name = this.uniqueVarName(input)
}
}
const { namesToAdd, indicesToRemove } = planVariableSockets(
(this.inputs ?? []).map((slot) => ({
name: slot.name ?? '',
connected: slot.link != null
})),
declared
)
// The placeholder remove/re-add below always mutates the inputs array
// length. That doubles as the change signal for the widget: in-place
// `link`/`name` writes are invisible to the node manager's shallowReactive
// wrapper, so callers rely on this churn to refresh connection state.
for (const index of [...indicesToRemove].reverse()) this.removeInput(index)
for (const name of namesToAdd) this.addInput(name, 'STRING')
this.addInput('', 'STRING')
}
private uniqueVarName(input: INodeInputSlot): string {
const link = input.link != null ? this.graph?.links[input.link] : null
const source = link ? this.graph?.getNodeById(link.origin_id) : null
return autoSocketName(
source?.title ?? '',
this.declaredVarNames(),
(this.inputs ?? []).map((slot) => ({
name: slot.name ?? '',
connected: slot.link != null
}))
)
}
}
app.registerExtension({
name: 'Comfy.PromptNode',
registerCustomNodes() {
LiteGraph.registerNodeType(
'Prompt',
Object.assign(PromptNode, { title: 'Prompt' })
)
PromptNode.category = 'utils'
}
})

View File

@@ -1,4 +1,11 @@
{
"promptNode": {
"editorPlaceholder": "Write a prompt, or type {'@'} to reference a connected input",
"editorLabel": "Prompt editor",
"noMatches": "No matches",
"createVariable": "Create variable: {name}",
"renameConflict": "An input named \"{name}\" already exists"
},
"g": {
"shortcutSuffix": " ({shortcut})",
"user": "User",

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest'
import type { PromptTemplate } from '@/platform/prompts/promptTemplate'
import {
autoSocketName,
planVariableSockets,
renameVariableInTemplate,
resolvePromptTemplate
} from '@/platform/prompts/promptTemplate'
describe('resolvePromptTemplate', () => {
it('concatenates text segments', () => {
const template: PromptTemplate = [
{ type: 'text', value: 'hello ' },
{ type: 'text', value: 'world' }
]
expect(resolvePromptTemplate(template, () => '')).toBe('hello world')
})
it('delegates variable segments to resolveVar', () => {
const template: PromptTemplate = [
{ type: 'text', value: 'style: ' },
{ type: 'var', name: 'art_style' }
]
expect(
resolvePromptTemplate(template, (name) =>
name === 'art_style' ? 'anime' : ''
)
).toBe('style: anime')
})
})
describe('planVariableSockets', () => {
it('adds a socket for a newly declared variable', () => {
expect(planVariableSockets([], ['animal'])).toEqual({
namesToAdd: ['animal'],
indicesToRemove: []
})
})
it('replaces an empty placeholder with the declared socket', () => {
expect(
planVariableSockets([{ name: '', connected: false }], ['animal'])
).toEqual({ namesToAdd: ['animal'], indicesToRemove: [0] })
})
it('keeps a socket that is still declared', () => {
expect(
planVariableSockets([{ name: 'animal', connected: false }], ['animal'])
).toEqual({ namesToAdd: [], indicesToRemove: [] })
})
it('keeps a connected socket whose declaration was removed', () => {
expect(
planVariableSockets([{ name: 'animal', connected: true }], [])
).toEqual({ namesToAdd: [], indicesToRemove: [] })
})
it('drops an undeclared, unconnected socket and adds the new one', () => {
expect(
planVariableSockets([{ name: 'old', connected: false }], ['new'])
).toEqual({ namesToAdd: ['new'], indicesToRemove: [0] })
})
it('handles a mix of connected, stale, and placeholder sockets', () => {
const plan = planVariableSockets(
[
{ name: 'gender', connected: true },
{ name: 'stale', connected: false },
{ name: '', connected: false }
],
['gender', 'color']
)
expect(plan).toEqual({ namesToAdd: ['color'], indicesToRemove: [1, 2] })
})
it('does not add a declared variable twice', () => {
expect(planVariableSockets([], ['animal', 'animal'])).toEqual({
namesToAdd: ['animal'],
indicesToRemove: []
})
})
it('drops a stale placeholder when a connected socket claims its name', () => {
expect(
planVariableSockets(
[
{ name: 'subject', connected: false },
{ name: 'subject', connected: true }
],
['subject']
)
).toEqual({ namesToAdd: [], indicesToRemove: [0] })
})
})
describe('autoSocketName', () => {
it('adopts a declared variable matching the title case-insensitively', () => {
const name = autoSocketName(
'Subject',
['subject'],
[
{ name: 'subject', connected: false },
{ name: '', connected: true }
]
)
expect(name).toBe('subject')
})
it('does not adopt a variable another connected socket claims', () => {
const name = autoSocketName(
'Subject',
['subject'],
[
{ name: 'subject', connected: true },
{ name: '', connected: true }
]
)
expect(name).toBe('Subject')
})
it('falls back to the source title when nothing matches', () => {
expect(autoSocketName('Camera', ['subject'], [])).toBe('Camera')
})
it('suffixes a title already taken by another socket', () => {
expect(
autoSocketName('Camera', [], [{ name: 'Camera', connected: true }])
).toBe('Camera 2')
})
it('defaults to var for a blank title', () => {
expect(autoSocketName(' ', [], [])).toBe('var')
})
})
describe('renameVariableInTemplate', () => {
it('renames every matching variable reference', () => {
const template = renameVariableInTemplate(
[
{ type: 'text', value: 'a ' },
{ type: 'var', name: 'animal' },
{ type: 'text', value: ' and a ' },
{ type: 'var', name: 'animal' }
],
'animal',
'creature'
)
expect(template).toEqual([
{ type: 'text', value: 'a ' },
{ type: 'var', name: 'creature' },
{ type: 'text', value: ' and a ' },
{ type: 'var', name: 'creature' }
])
})
it('leaves other variables and text untouched', () => {
const template = renameVariableInTemplate(
[
{ type: 'var', name: 'color' },
{ type: 'text', value: 'animal' }
],
'animal',
'creature'
)
expect(template).toEqual([
{ type: 'var', name: 'color' },
{ type: 'text', value: 'animal' }
])
})
})

View File

@@ -0,0 +1,116 @@
export type PromptSegment =
| { type: 'text'; value: string }
| { type: 'var'; name: string }
export type PromptTemplate = PromptSegment[]
/**
* Resolves a prompt template to its final string, delegating variable
* references to the caller. Missing references resolve to an empty string so
* resolution never throws and never blocks submission.
*/
export function resolvePromptTemplate(
template: PromptTemplate,
resolveVar: (name: string) => string
): string {
let result = ''
for (const segment of template) {
result += segment.type === 'text' ? segment.value : resolveVar(segment.name)
}
return result
}
/** Rewrites every `@variable` reference named `oldName` to `newName`. */
export function renameVariableInTemplate(
template: PromptTemplate,
oldName: string,
newName: string
): PromptTemplate {
return template.map((segment) =>
segment.type === 'var' && segment.name === oldName
? { ...segment, name: newName }
: segment
)
}
export interface SocketState {
name: string
connected: boolean
}
/**
* Names a freshly connected socket after its source node. A declared variable
* matching the title case-insensitively is adopted (wiring a node titled
* "Subject" satisfies `@subject`) unless another connected socket already
* claims it; otherwise the title is made unique with a numeric suffix.
*/
export function autoSocketName(
sourceTitle: string,
declared: readonly string[],
sockets: readonly SocketState[]
): string {
const base = sourceTitle.trim() || 'var'
const lower = base.toLowerCase()
const match = declared.find((name) => name.toLowerCase() === lower)
if (
match &&
!sockets.some((socket) => socket.connected && socket.name === match)
) {
return match
}
const taken = new Set(sockets.map((socket) => socket.name).filter(Boolean))
if (!taken.has(base)) return base
let suffix = 2
while (taken.has(`${base} ${suffix}`)) suffix++
return `${base} ${suffix}`
}
/**
* Computes how a node's variable input sockets should change to mirror the
* declared `@variables`: drop sockets that are neither declared nor connected,
* and add a socket for each declared variable missing one. Connected sockets are
* always kept, even when their declaration was removed, so a live link is never
* silently severed. An unconnected socket whose name a connected socket also
* claims is dropped as stale (auto-naming can satisfy a declared variable's
* placeholder). Indices are relative to the supplied array; remove them in
* descending order so earlier indices stay valid.
*/
export function planVariableSockets(
sockets: readonly SocketState[],
declared: readonly string[]
): { namesToAdd: string[]; indicesToRemove: number[] } {
const wanted = new Set(declared)
const connectedNames = new Set(
sockets
.filter((socket) => socket.connected)
.map((socket) => socket.name)
.filter(Boolean)
)
const indicesToRemove = sockets.flatMap((socket, index) =>
!socket.connected &&
(!(socket.name && wanted.has(socket.name)) ||
connectedNames.has(socket.name))
? [index]
: []
)
const removed = new Set(indicesToRemove)
const survivingNames = new Set(
sockets
.filter((_, index) => !removed.has(index))
.map((socket) => socket.name)
.filter(Boolean)
)
const namesToAdd: string[] = []
for (const name of declared) {
if (!survivingNames.has(name) && !namesToAdd.includes(name)) {
namesToAdd.push(name)
}
}
return { namesToAdd, indicesToRemove }
}

View File

@@ -35,14 +35,23 @@
<!-- Slot Name -->
<div class="flex h-full min-w-0 items-center">
<EditableText
v-if="editing"
:model-value="slotData.name ?? ''"
is-editing
@edit="commit"
@cancel="cancel"
/>
<span
v-if="!props.dotOnly && !hasNoLabel"
v-else-if="!props.dotOnly && !hasNoLabel"
:class="
cn(
'truncate text-node-component-slot-text',
hasError && 'font-medium text-error'
hasError && 'font-medium text-error',
isEditable && 'cursor-text'
)
"
@dblclick.stop="startEdit"
>
{{
slotData.label ||
@@ -58,10 +67,12 @@
import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useEditableSlotTitle } from '@/renderer/extensions/vueNodes/composables/useEditableSlotTitle'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -84,6 +95,11 @@ interface InputSlotProps {
const props = defineProps<InputSlotProps>()
const { editing, isEditable, startEdit, commit, cancel } = useEditableSlotTitle(
() => props.nodeId ?? '',
() => props.slotData.name ?? ''
)
const hasNoLabel = computed(
() =>
!props.slotData.label &&

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useEditableSlotTitle } from '@/renderer/extensions/vueNodes/composables/useEditableSlotTitle'
const h = vi.hoisted(() => ({
node: undefined as
| { renameVariableInput: ReturnType<typeof vi.fn> }
| undefined,
toastAdd: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { graph: { getNodeById: () => h.node } }
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: h.toastAdd })
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
function renamableNode() {
return { renameVariableInput: vi.fn(() => true) }
}
beforeEach(() => {
vi.clearAllMocks()
h.node = undefined
})
describe('useEditableSlotTitle', () => {
it('is not editable when the node does not support renaming', () => {
h.node = undefined
const { isEditable, startEdit, editing } = useEditableSlotTitle(
() => '1',
() => 'color'
)
expect(isEditable.value).toBe(false)
startEdit()
expect(editing.value).toBe(false)
})
it('renames the slot on commit when the value changed', () => {
h.node = renamableNode()
const { startEdit, commit, editing } = useEditableSlotTitle(
() => '1',
() => 'color'
)
startEdit()
expect(editing.value).toBe(true)
commit('shade')
expect(editing.value).toBe(false)
expect(h.node.renameVariableInput).toHaveBeenCalledWith('color', 'shade')
expect(h.toastAdd).not.toHaveBeenCalled()
})
it('does not rename when the name is unchanged or blank', () => {
h.node = renamableNode()
const { startEdit, commit } = useEditableSlotTitle(
() => '1',
() => 'color'
)
startEdit()
commit(' ')
startEdit()
commit('color')
expect(h.node.renameVariableInput).not.toHaveBeenCalled()
})
it('warns when the rename is rejected', () => {
h.node = { renameVariableInput: vi.fn(() => false) }
const { startEdit, commit } = useEditableSlotTitle(
() => '1',
() => 'color'
)
startEdit()
commit('taken')
expect(h.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('discards the edit on cancel', () => {
h.node = renamableNode()
const { startEdit, cancel, commit, editing } = useEditableSlotTitle(
() => '1',
() => 'color'
)
startEdit()
cancel()
expect(editing.value).toBe(false)
commit('shade')
expect(h.node.renameVariableInput).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,63 @@
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
interface RenamableNode {
renameVariableInput: (oldName: string, newName: string) => boolean
}
function isRenamable(node: unknown): node is RenamableNode {
return (
typeof node === 'object' &&
node !== null &&
typeof (node as RenamableNode).renameVariableInput === 'function'
)
}
/**
* Inline rename for an input slot title. Editing is only offered when the owning
* node opts in by exposing a `renameVariableInput` method, so other node types
* keep their read-only slot labels.
*/
export function useEditableSlotTitle(
nodeId: () => string,
currentName: () => string
) {
const canvasStore = useCanvasStore()
const editing = ref(false)
function renamableNode(): RenamableNode | undefined {
const node = canvasStore.canvas?.graph?.getNodeById(toNodeId(nodeId()))
return isRenamable(node) ? node : undefined
}
const isEditable = computed(() => !!renamableNode())
function startEdit() {
if (renamableNode()) editing.value = true
}
function commit(newName: string) {
if (!editing.value) return
editing.value = false
const name = newName.trim()
if (!name || name === currentName()) return
const renamed = renamableNode()?.renameVariableInput(currentName(), name)
if (renamed === false) {
useToastStore().add({
severity: 'warn',
summary: t('promptNode.renameConflict', { name }),
life: 3000
})
}
}
function cancel() {
editing.value = false
}
return { editing, isEditable, startEdit, commit, cancel }
}

View File

@@ -75,6 +75,9 @@ const WidgetBoundingBoxes = defineAsyncComponent(
const WidgetColors = defineAsyncComponent(
() => import('@/components/palette/WidgetColors.vue')
)
const WidgetPromptEditor = defineAsyncComponent(
() => import('@/components/graph/widgets/PromptNodeWidget.vue')
)
export const FOR_TESTING = {
WidgetButton,
@@ -241,6 +244,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
aliases: ['COLORS'],
essential: false
}
],
[
'prompteditor',
{
component: WidgetPromptEditor,
aliases: ['PROMPTEDITOR'],
essential: false
}
]
]
@@ -281,7 +292,8 @@ const EXPANDING_TYPES = [
'painter',
'imagecompare',
'range',
'boundingboxes'
'boundingboxes',
'prompteditor'
] as const
export function shouldExpand(type: string): boolean {