mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 23:57:53 +00:00
Compare commits
18 Commits
pysssss/ap
...
prompt-nod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7400c9546 | ||
|
|
8db3cb65a2 | ||
|
|
16dee03ca5 | ||
|
|
05c9a06eab | ||
|
|
9021fa268f | ||
|
|
c272c56588 | ||
|
|
56ba01f55a | ||
|
|
f1c3754110 | ||
|
|
bd68732d34 | ||
|
|
2279f7ec4e | ||
|
|
e97eb32086 | ||
|
|
4fe349f8b7 | ||
|
|
5a60c5b1db | ||
|
|
8d0e2dbc59 | ||
|
|
d7e74d4309 | ||
|
|
9e346ccfd0 | ||
|
|
d7200f9dfc | ||
|
|
6a7017b747 |
326
src/components/graph/widgets/PromptEditor.vue
Normal file
326
src/components/graph/widgets/PromptEditor.vue
Normal 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>
|
||||
107
src/components/graph/widgets/PromptNodeWidget.test.ts
Normal file
107
src/components/graph/widgets/PromptNodeWidget.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
54
src/components/graph/widgets/PromptNodeWidget.vue
Normal file
54
src/components/graph/widgets/PromptNodeWidget.vue
Normal 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>
|
||||
87
src/components/graph/widgets/promptTemplateDom.test.ts
Normal file
87
src/components/graph/widgets/promptTemplateDom.test.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
122
src/components/graph/widgets/promptTemplateDom.ts
Normal file
122
src/components/graph/widgets/promptTemplateDom.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
229
src/extensions/core/promptNode.ts
Normal file
229
src/extensions/core/promptNode.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
171
src/platform/prompts/promptTemplate.test.ts
Normal file
171
src/platform/prompts/promptTemplate.test.ts
Normal 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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
116
src/platform/prompts/promptTemplate.ts
Normal file
116
src/platform/prompts/promptTemplate.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user