Initial commit of Prompt Verify custom node
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Chris
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Prompt Verify
|
||||
|
||||
Prompt Verify is an interactive ComfyUI node that pauses a string flow and lets you edit the text in the browser before it continues. It is useful for quick manual tweaks to prompts, captions, or any string data without interrupting the pipeline.
|
||||
|
||||
## Key features
|
||||
- Pauses a string and opens an inline editor in the UI for live edits.
|
||||
- Submit edits with Shift+Enter, Or click on Submit button.
|
||||
- Configurable timeout (default 60s, max 2400s). If the timeout elapses, the current text is sent automatically.
|
||||
- Works with the built-in front-end; client and server communicate via prompt_verify_request / prompt_verify_response.
|
||||
|
||||
## How to use
|
||||
1. Connect a STRING input to the node's `text` socket. (i.e. florence2 Captioner)
|
||||
2. (Optional) Provide initial text in the `editor` field to prefill the editor.
|
||||
3. Set `timeout` to control how long the node waits for user edits (seconds).
|
||||
4. When the node executes, the UI will show an editor. Make your changes and press Shift+Enter or click on the Submit button to submit.
|
||||
5. If you don't submit before the timeout, the current text will be used and the node continues.
|
||||
|
||||
## Notes
|
||||
- The node returns a single STRING output — the edited (or timed-out) text.
|
||||
- Default timeout is 60s; maximum allowed timeout is 2400s.
|
||||
|
||||
Quick, and convenient text editing inside your ComfyUI flow.
|
||||
|
||||
MIT License
|
||||
Developed by: ialhabbal
|
||||
@@ -0,0 +1,9 @@
|
||||
from .prompt_verify import PromptVerify
|
||||
|
||||
VERSION = "0.0.1"
|
||||
|
||||
# map display name -> class object
|
||||
NODE_CLASS_MAPPINGS = {"Prompt Verify": PromptVerify}
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
__all__ = ["VERSION", "NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,525 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// Migrate legacy localStorage keys used by earlier versions to the new keys.
|
||||
// This migration is destructive: it copies legacy values to the new keys,
|
||||
// sets a one-time marker `prompt_verify_migrated`, and removes the legacy
|
||||
// keys so they don't linger in localStorage.
|
||||
function migrateLocalStorage() {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
// one-time migration marker
|
||||
if (localStorage.getItem('prompt_verify_migrated') === '1') return
|
||||
|
||||
const legacyPrefixes = [
|
||||
'prompt_verify_float_pos_',
|
||||
'prompt_verify_float_size_',
|
||||
]
|
||||
|
||||
const keysToDelete = []
|
||||
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
try {
|
||||
for (const p of legacyPrefixes) {
|
||||
if (key.startsWith(p)) {
|
||||
const nodeId = key.split('_').pop()
|
||||
const newKey = p.includes('pos') ? `prompt_verify_float_pos_${nodeId}` : `prompt_verify_float_size_${nodeId}`
|
||||
|
||||
if (!localStorage.getItem(newKey)) {
|
||||
const v = localStorage.getItem(key)
|
||||
if (v) localStorage.setItem(newKey, v)
|
||||
}
|
||||
|
||||
keysToDelete.push(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore individual key errors */ }
|
||||
}
|
||||
|
||||
try { localStorage.setItem('prompt_verify_migrated', '1') } catch(e) {}
|
||||
|
||||
for (const k of keysToDelete) {
|
||||
try { localStorage.removeItem(k) } catch(e) {}
|
||||
}
|
||||
} catch(e) { /* ignore when localStorage is unavailable */ }
|
||||
}
|
||||
|
||||
// small toast helper retained for compatibility but suppressed by default.
|
||||
// This no-op preserves the function and call sites so behavior is unchanged
|
||||
// except that toasts won't appear. To restore the original toast behavior,
|
||||
// revert this function to the previous implementation.
|
||||
function showToast(msg, timeout=4000) {
|
||||
try {
|
||||
// Intentionally no visual toast. Keep a debug log so developers
|
||||
// can still see that the event would have fired.
|
||||
console.debug('prompt_verify: toast suppressed (message):', msg)
|
||||
} catch(e) { /* swallow errors to avoid breaking node behavior */ }
|
||||
}
|
||||
|
||||
function send_message(node_id, message) {
|
||||
const body = new FormData();
|
||||
body.append('message',message);
|
||||
body.append('node_id',node_id);
|
||||
api.fetchApi("/prompt_verify_response", { method: "POST", body, });
|
||||
}
|
||||
|
||||
function prompt_verify_request(msg) {
|
||||
console.debug('prompt_verify: received prompt_verify_request', msg.detail)
|
||||
const nodeId = msg.detail.node_id
|
||||
const timeup = !!msg.detail.timeup
|
||||
// create or find floating editor for this node id
|
||||
const floatId = `prompt_verify_float_${nodeId}`
|
||||
let panel = document.getElementById(floatId)
|
||||
if (timeup) {
|
||||
// if timeup, auto-submit current value then remove panel
|
||||
if (panel) {
|
||||
const ta = panel.querySelector('textarea')
|
||||
const val = ta ? ta.value : ''
|
||||
send_message(nodeId, val)
|
||||
panel.remove()
|
||||
} else {
|
||||
// no panel; still send empty message
|
||||
send_message(nodeId, '')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
function positionPanelNearNode(panel, nodeId) {
|
||||
try {
|
||||
const node = app.graph._nodes_by_id[nodeId]
|
||||
console.debug('prompt_verify: trying to anchor panel', {nodeId, nodeExists: !!node, hasElement: !!(node && node.element)})
|
||||
if (!node || !node.element) return false
|
||||
const rect = node.element.getBoundingClientRect()
|
||||
const panelW = panel.offsetWidth || 480
|
||||
const panelH = panel.offsetHeight || 200
|
||||
// preferred position: below the node (use fixed positioning relative to viewport)
|
||||
let left = rect.left
|
||||
let top = rect.top + rect.height + 8
|
||||
// if overflowing bottom, place above node
|
||||
if (top + panelH > window.innerHeight) {
|
||||
top = rect.top - panelH - 8
|
||||
}
|
||||
// clamp horizontally
|
||||
if (left + panelW > window.innerWidth) {
|
||||
left = Math.max(8, window.innerWidth - panelW - 8)
|
||||
}
|
||||
panel.style.position = 'fixed'
|
||||
panel.style.left = `${Math.max(8, left)}px`
|
||||
panel.style.top = `${Math.max(8, top)}px`
|
||||
panel.style.right = 'auto'
|
||||
panel.style.bottom = 'auto'
|
||||
panel.dataset.anchored = '1'
|
||||
console.debug('prompt_verify: anchored panel', {left: panel.style.left, top: panel.style.top, panelW, panelH, rect})
|
||||
return true
|
||||
} catch(e) { return false }
|
||||
}
|
||||
|
||||
// helpers to persist user-moved panel position per node (non-destructive)
|
||||
function getSavedPanelPos(nodeId) {
|
||||
try {
|
||||
const raw = localStorage.getItem(`prompt_verify_float_pos_${nodeId}`)
|
||||
if (!raw) return null
|
||||
const obj = JSON.parse(raw)
|
||||
if (typeof obj.left === 'number' && typeof obj.top === 'number') return obj
|
||||
} catch(e) { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
function savePanelPos(nodeId, left, top) {
|
||||
try { localStorage.setItem(`prompt_verify_float_pos_${nodeId}`, JSON.stringify({left, top})) } catch(e){}
|
||||
}
|
||||
// per-node persisted size (non-destructive)
|
||||
function getSavedPanelSize(nodeId) {
|
||||
try {
|
||||
const raw = localStorage.getItem(`prompt_verify_float_size_${nodeId}`)
|
||||
if (!raw) return null
|
||||
const obj = JSON.parse(raw)
|
||||
if (typeof obj.w === 'number' && typeof obj.h === 'number') return obj
|
||||
} catch(e) { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
function savePanelSize(nodeId, w, h) {
|
||||
try { localStorage.setItem(`prompt_verify_float_size_${nodeId}`, JSON.stringify({w, h})) } catch(e){}
|
||||
}
|
||||
function makePanelDraggable(panel, nodeId) {
|
||||
try {
|
||||
// add a small drag handle to avoid interfering with textarea selection
|
||||
const handle = document.createElement('div')
|
||||
handle.title = 'Drag to move'
|
||||
// slightly larger handle for easier grabbing; keep visual subtlety
|
||||
handle.style.cssText = 'position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:6px;background:rgba(255,255,255,0.06);color:#ddd;display:flex;align-items:center;justify-content:center;cursor:move;font-size:14px;user-select:none;'
|
||||
handle.textContent = '≡'
|
||||
handle.setAttribute('role','button')
|
||||
handle.setAttribute('aria-label','Move panel')
|
||||
handle.tabIndex = 0
|
||||
panel.style.position = panel.style.position || 'fixed'
|
||||
panel.style.boxSizing = 'border-box'
|
||||
panel.appendChild(handle)
|
||||
|
||||
let startX=0, startY=0, startLeft=0, startTop=0, dragging=false
|
||||
const onPointerMove = (ev) => {
|
||||
if (!dragging) return
|
||||
const clientX = ev.clientX != null ? ev.clientX : (ev.touches && ev.touches[0] && ev.touches[0].clientX)
|
||||
const clientY = ev.clientY != null ? ev.clientY : (ev.touches && ev.touches[0] && ev.touches[0].clientY)
|
||||
if (clientX == null || clientY == null) return
|
||||
const dx = clientX - startX
|
||||
const dy = clientY - startY
|
||||
const left = Math.max(8, startLeft + dx)
|
||||
const top = Math.max(8, startTop + dy)
|
||||
panel.style.left = `${left}px`
|
||||
panel.style.top = `${top}px`
|
||||
panel.style.right = 'auto'
|
||||
panel.style.bottom = 'auto'
|
||||
}
|
||||
const onPointerUp = (ev) => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
// save position
|
||||
const left = parseInt(panel.style.left,10) || panel.getBoundingClientRect().left
|
||||
const top = parseInt(panel.style.top,10) || panel.getBoundingClientRect().top
|
||||
savePanelPos(nodeId, left, top)
|
||||
// if anchored, remove anchoring so user position persists
|
||||
try { if (panel._onrv) { window.removeEventListener('scroll', panel._onrv); window.removeEventListener('resize', panel._onrv); panel._onrv = null } } catch(e){}
|
||||
panel.dataset.anchored = '0'
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerdown', (ev)=>{
|
||||
ev.preventDefault()
|
||||
dragging = true
|
||||
startX = ev.clientX != null ? ev.clientX : (ev.touches && ev.touches[0] && ev.touches[0].clientX)
|
||||
startY = ev.clientY != null ? ev.clientY : (ev.touches && ev.touches[0] && ev.touches[0].clientY)
|
||||
const rect = panel.getBoundingClientRect()
|
||||
startLeft = rect.left
|
||||
startTop = rect.top
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
} catch(e) { console.debug('prompt_verify: makePanelDraggable failed', e) }
|
||||
}
|
||||
|
||||
function makePanelResizable(panel, nodeId, textarea) {
|
||||
try {
|
||||
const grip = document.createElement('div')
|
||||
grip.title = 'Resize'
|
||||
grip.style.cssText = 'position:absolute;right:6px;bottom:6px;width:18px;height:18px;border-radius:4px;background:rgba(255,255,255,0.04);cursor:se-resize;display:flex;align-items:center;justify-content:center;color:#ddd;font-size:12px;'
|
||||
grip.textContent = '◢'
|
||||
panel.appendChild(grip)
|
||||
|
||||
let startX=0, startY=0, startW=0, startH=0, resizing=false
|
||||
const minW = 320, minH = 120
|
||||
const onPointerMove = (ev) => {
|
||||
if (!resizing) return
|
||||
const clientX = ev.clientX != null ? ev.clientX : (ev.touches && ev.touches[0] && ev.touches[0].clientX)
|
||||
const clientY = ev.clientY != null ? ev.clientY : (ev.touches && ev.touches[0] && ev.touches[0].clientY)
|
||||
if (clientX == null || clientY == null) return
|
||||
let newW = Math.max(minW, startW + (clientX - startX))
|
||||
let newH = Math.max(minH, startH + (clientY - startY))
|
||||
// clamp to viewport
|
||||
newW = Math.min(newW, Math.max(200, window.innerWidth - 16))
|
||||
newH = Math.min(newH, Math.max(100, window.innerHeight - 16))
|
||||
panel.style.width = `${newW}px`
|
||||
panel.style.height = `${newH}px`
|
||||
panel.style.right = 'auto'
|
||||
panel.style.bottom = 'auto'
|
||||
// update textarea to fill available space (approx)
|
||||
try {
|
||||
if (textarea) {
|
||||
const padX = 24 // approximate horizontal padding/margins
|
||||
const padY = 80 // space for buttons and padding
|
||||
textarea.style.width = `${Math.max(100, newW - padX)}px`
|
||||
textarea.style.height = `${Math.max(60, newH - padY)}px`
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
const onPointerUp = (ev) => {
|
||||
if (!resizing) return
|
||||
resizing = false
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
// persist
|
||||
const rect = panel.getBoundingClientRect()
|
||||
savePanelSize(nodeId, rect.width, rect.height)
|
||||
// disable anchoring now that user explicitly resized
|
||||
try { if (panel._onrv) { window.removeEventListener('scroll', panel._onrv); window.removeEventListener('resize', panel._onrv); panel._onrv = null } } catch(e){}
|
||||
panel.dataset.anchored = '0'
|
||||
}
|
||||
|
||||
grip.addEventListener('pointerdown', (ev)=>{
|
||||
ev.preventDefault()
|
||||
resizing = true
|
||||
startX = ev.clientX != null ? ev.clientX : (ev.touches && ev.touches[0] && ev.touches[0].clientX)
|
||||
startY = ev.clientY != null ? ev.clientY : (ev.touches && ev.touches[0] && ev.touches[0].clientY)
|
||||
const rect = panel.getBoundingClientRect()
|
||||
startW = rect.width
|
||||
startH = rect.height
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
|
||||
// when textarea exists, disable its native resize to avoid double-resize UI
|
||||
if (textarea) {
|
||||
try { textarea.style.resize = 'none' } catch(e){}
|
||||
}
|
||||
} catch(e) { console.debug('prompt_verify: makePanelResizable failed', e) }
|
||||
}
|
||||
|
||||
if (!panel) {
|
||||
panel = document.createElement('div')
|
||||
panel.id = floatId
|
||||
panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:999999;background:#111;padding:12px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.5);color:#fff;max-width:520px;'
|
||||
const ta = document.createElement('textarea')
|
||||
ta.style.cssText = 'width:480px;height:160px;padding:8px;border-radius:6px;border:1px solid #444;background:#0f0f0f;color:#fff;resize:vertical;'
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.textContent = 'Submit'
|
||||
btn.style.cssText = 'margin-top:8px;margin-left:6px;padding:6px 10px;border-radius:6px;background:#28a745;color:white;border:none;cursor:pointer;font-weight:600;'
|
||||
const close = document.createElement('button')
|
||||
close.type = 'button'
|
||||
close.textContent = 'Close'
|
||||
close.style.cssText = 'margin-top:8px;margin-left:8px;padding:6px 10px;border-radius:6px;background:#6c757d;color:white;border:none;cursor:pointer;'
|
||||
panel.appendChild(ta)
|
||||
panel.appendChild(document.createElement('br'))
|
||||
panel.appendChild(btn)
|
||||
panel.appendChild(close)
|
||||
document.body.appendChild(panel)
|
||||
// if user previously moved this node's panel, restore that position (non-destructive)
|
||||
try {
|
||||
const saved = getSavedPanelPos(nodeId)
|
||||
if (saved) {
|
||||
panel.style.position = 'fixed'
|
||||
panel.style.left = `${Math.max(8, saved.left)}px`
|
||||
panel.style.top = `${Math.max(8, saved.top)}px`
|
||||
panel.style.right = 'auto'
|
||||
panel.style.bottom = 'auto'
|
||||
panel.dataset.anchored = '0'
|
||||
console.debug('prompt_verify: restored saved panel pos', {nodeId, saved})
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
|
||||
// make panel draggable so user can move it; movement will clear anchoring
|
||||
try { makePanelDraggable(panel, nodeId) } catch(e){ }
|
||||
|
||||
// restore saved size if present
|
||||
try {
|
||||
const savedSize = getSavedPanelSize(nodeId)
|
||||
if (savedSize) {
|
||||
panel.style.width = `${Math.max(200, savedSize.w)}px`
|
||||
panel.style.height = `${Math.max(120, savedSize.h)}px`
|
||||
console.debug('prompt_verify: restored saved panel size', {nodeId, savedSize})
|
||||
}
|
||||
} catch(e){}
|
||||
|
||||
// wire up resizer (pass textarea so it can be resized accordingly)
|
||||
try { makePanelResizable(panel, nodeId, ta) } catch(e){}
|
||||
|
||||
// try to position near node; if success, keep fixed placement (unless user had saved pos)
|
||||
const anchored = (!panel.dataset.anchored || panel.dataset.anchored!=='0') && positionPanelNearNode(panel, nodeId)
|
||||
if (anchored) {
|
||||
// re-position on scroll/resize so it stays with node viewport
|
||||
const onrv = () => positionPanelNearNode(panel, nodeId)
|
||||
window.addEventListener('scroll', onrv, {passive:true})
|
||||
window.addEventListener('resize', onrv)
|
||||
// store to allow cleanup if panel removed
|
||||
panel._onrv = onrv
|
||||
} else {
|
||||
console.debug('prompt_verify: anchor failed, using bottom-right fallback')
|
||||
}
|
||||
|
||||
btn.addEventListener('click', ()=>{
|
||||
send_message(nodeId, ta.value)
|
||||
try { panel.remove() } catch(e){}
|
||||
})
|
||||
close.addEventListener('click', ()=>{ try { panel.remove() } catch(e){} })
|
||||
}
|
||||
// set text into textarea and focus; if anchored, re-run positioning after render
|
||||
const ta = panel.querySelector('textarea')
|
||||
if (ta) { ta.value = msg.detail.message || ''; ta.focus(); }
|
||||
if (panel.dataset.anchored === '1') positionPanelNearNode(panel, nodeId)
|
||||
}
|
||||
|
||||
function registerWithApp(app) {
|
||||
try { console.debug('prompt_verify: registering extension with app') } catch(e){}
|
||||
app.registerExtension({
|
||||
name: "prompt_verify",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeType?.comfyClass==="Prompt Verify") {
|
||||
// helper to find the multiline editor widget more robustly
|
||||
function findEditorWidget(node) {
|
||||
if (!node || !node.widgets) return null
|
||||
// heuristics: prefer widget flagged multiline or a textarea element
|
||||
for (const w of node.widgets) {
|
||||
try {
|
||||
if (!w) continue
|
||||
// direct multiline flag
|
||||
if (w.multiline) return w
|
||||
// element looks like textarea
|
||||
if (w.element && w.element.tagName && w.element.tagName.toLowerCase() === 'textarea') return w
|
||||
// named "editor" or labelled "editor"
|
||||
if ((w.name && w.name.toLowerCase() === 'editor') || (w.label && w.label.toLowerCase() === 'editor')) return w
|
||||
// common fallback: any widget with a value and an element
|
||||
if (w.element && ('value' in w)) return w
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// fallback to the 3rd widget (legacy behavior)
|
||||
return node.widgets[2] || null
|
||||
}
|
||||
|
||||
nodeType.prototype.receive_prompt_verify_request = function(msg) {
|
||||
console.debug(`prompt_verify: node ${this.id} receive_prompt_verify_request`, {msg, widgets: this.widgets})
|
||||
const w = findEditorWidget(this)
|
||||
if (!w) return console.warn('Prompt Verify: editor widget not found', {nodeId: this.id, widgets: this.widgets})
|
||||
try { w.value = msg } catch (e) { console.error('prompt_verify: failed to set widget value', e) }
|
||||
if (w.element) {
|
||||
try { w.element.disabled = false } catch(e){}
|
||||
}
|
||||
// enable submit button if present
|
||||
try {
|
||||
if (this._prompt_verify_submit_button) this._prompt_verify_submit_button.disabled = false
|
||||
} catch(e){}
|
||||
}
|
||||
nodeType.prototype.receive_prompt_verify_timeup = function() {
|
||||
const w = findEditorWidget(this)
|
||||
if (!w) return console.warn('Prompt Verify: editor widget not found')
|
||||
send_message(this.id, w.value)
|
||||
try {
|
||||
if (this._prompt_verify_submit_button) this._prompt_verify_submit_button.disabled = true
|
||||
} catch(e){}
|
||||
}
|
||||
nodeType.prototype.handle_key = function(e) {
|
||||
if (e.key == 'Enter' && e.shiftKey) {
|
||||
const w = findEditorWidget(this)
|
||||
if (!w) return
|
||||
send_message(this.id, w.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node.receive_prompt_verify_request) {
|
||||
// try to find the editor widget for this instance
|
||||
let editor = null
|
||||
try {
|
||||
for (const w of node.widgets) {
|
||||
if (w && (w.multiline || (w.element && w.element.tagName && w.element.tagName.toLowerCase() === 'textarea'))) { editor = w; break }
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
editor = editor || node.widgets[2]
|
||||
if (editor && editor.element) {
|
||||
try { editor.element.addEventListener('keydown', node.handle_key.bind(node)) } catch(e){}
|
||||
// create a Submit button next to the editor widget
|
||||
try {
|
||||
const createAndInsertButton = () => {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.textContent = 'Submit'
|
||||
btn.style.cssText = 'margin-left:6px; display:inline-block; vertical-align:middle;'
|
||||
btn.className = 'prompt-verify-submit'
|
||||
btn.disabled = true
|
||||
btn.addEventListener('click', function(e){
|
||||
try {
|
||||
send_message(node.id, editor.value)
|
||||
} catch(err) { console.error('prompt_verify: submit click failed', err) }
|
||||
try { btn.disabled = true } catch(e){}
|
||||
})
|
||||
// try insert after the editor element
|
||||
try {
|
||||
if (editor.element && editor.element.insertAdjacentElement) {
|
||||
editor.element.insertAdjacentElement('afterend', btn)
|
||||
} else if (editor.element && editor.element.parentElement) {
|
||||
editor.element.parentElement.appendChild(btn)
|
||||
} else if (node.element && node.element.appendChild) {
|
||||
node.element.appendChild(btn)
|
||||
} else {
|
||||
document.body.appendChild(btn)
|
||||
}
|
||||
} catch (ie) {
|
||||
// last resort append to body
|
||||
try { document.body.appendChild(btn) } catch(e){}
|
||||
}
|
||||
node._prompt_verify_submit_button = btn
|
||||
console.debug('prompt_verify: submit button created for node', node.id)
|
||||
showToast('Prompt Verify: Submit button added')
|
||||
}
|
||||
|
||||
if (!editor.element) {
|
||||
// sometimes element not yet present - retry a few times
|
||||
let attempts = 0
|
||||
const rid = setInterval(()=>{
|
||||
attempts++
|
||||
if (editor.element) {
|
||||
clearInterval(rid)
|
||||
createAndInsertButton()
|
||||
} else if (attempts>10) {
|
||||
clearInterval(rid)
|
||||
console.warn('prompt_verify: editor.element never appeared, submit button not created for node', node.id)
|
||||
}
|
||||
}, 100)
|
||||
} else {
|
||||
createAndInsertButton()
|
||||
}
|
||||
} catch(e) { console.error('prompt_verify: failed to create submit button', e) }
|
||||
}
|
||||
}
|
||||
// fallback: if no submit button was created by the widget-specific logic, add one to the node header
|
||||
try {
|
||||
if (!node._prompt_verify_submit_button) {
|
||||
const tryCreateFallback = () => {
|
||||
if (!node || node._prompt_verify_submit_button) return true
|
||||
if (!node.element) return false
|
||||
try {
|
||||
const fallbackBtn = document.createElement('button')
|
||||
fallbackBtn.type = 'button'
|
||||
fallbackBtn.textContent = 'Submit'
|
||||
fallbackBtn.style.cssText = 'margin-left:6px; display:inline-block; vertical-align:middle; background:#4caf50; color:white; border:none; padding:6px 10px; border-radius:6px; cursor:pointer; font-weight:600;'
|
||||
fallbackBtn.className = 'prompt-verify-submit prompt-verify-submit-fallback'
|
||||
fallbackBtn.disabled = true
|
||||
fallbackBtn.addEventListener('click', function(){
|
||||
// find editor widget at click time
|
||||
let editorWidget = null
|
||||
try {
|
||||
for (const w of node.widgets) {
|
||||
if (w && (w.multiline || (w.element && w.element.tagName && w.element.tagName.toLowerCase() === 'textarea'))) { editorWidget = w; break }
|
||||
}
|
||||
} catch(e) {}
|
||||
editorWidget = editorWidget || node.widgets[2]
|
||||
const val = (editorWidget && ('value' in editorWidget)) ? editorWidget.value : ''
|
||||
try { send_message(node.id, val) } catch(e){ console.error('prompt_verify: fallback submit failed', e) }
|
||||
try { fallbackBtn.disabled = true } catch(e){}
|
||||
})
|
||||
const header = node.element.querySelector('.node-header') || node.element
|
||||
header.appendChild(fallbackBtn)
|
||||
node._prompt_verify_submit_button = fallbackBtn
|
||||
console.debug('prompt_verify: fallback submit button created for node', node.id)
|
||||
showToast('Prompt Verify: Submit button added')
|
||||
return true
|
||||
} catch(e) {
|
||||
console.error('prompt_verify: failed to append fallback submit button', e)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = 0
|
||||
const fid = setInterval(()=>{
|
||||
attempts++
|
||||
const done = tryCreateFallback()
|
||||
if (done || attempts>20) clearInterval(fid)
|
||||
}, 200)
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
},
|
||||
setup() {
|
||||
// migrate any legacy localStorage keys first
|
||||
try { migrateLocalStorage() } catch(e) {}
|
||||
api.addEventListener("prompt_verify_request", prompt_verify_request);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Register immediately using imported `app`.
|
||||
try {
|
||||
registerWithApp(app)
|
||||
} catch (e) {
|
||||
console.error('prompt_verify: failed to register extension', e)
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import time
|
||||
|
||||
class PromptVerify:
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "func"
|
||||
CATEGORY = "text"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text" : ( "STRING", {"forceInput":True}),
|
||||
"timeout": ("INT", { "default": 60, "min": 1, "max": 2400, "step": 1, "tooltip":"Time in seconds to wait before passing the input text on (max 2400)"}),
|
||||
},
|
||||
"optional": {"editor": ("STRING", {"default":"", "multiline":True, "tooltip":"edit here, press 'shift-return' to submit"}),},
|
||||
"hidden": {"node_id":"UNIQUE_ID"},
|
||||
}
|
||||
|
||||
def func(self, text, timeout, node_id, editor=None):
|
||||
# Interactive behaviour: send a request to the front-end and wait for the
|
||||
# user to submit edited text. If the front-end doesn't respond before
|
||||
# `timeout` seconds, send a timeup signal and wait a short grace period.
|
||||
try:
|
||||
POBox.waiting[node_id] = self
|
||||
self.message = None
|
||||
try:
|
||||
print(f"prompt_verify: sending prompt_verify_request node_id={node_id} message={repr(text)[:200]} timeup=False")
|
||||
except Exception:
|
||||
pass
|
||||
PromptServer.instance.send_sync("prompt_verify_request", {"node_id": node_id, "message":text, "timeup":False})
|
||||
endat = time.monotonic() + timeout
|
||||
while time.monotonic() < endat and self.message is None:
|
||||
time.sleep(0.1)
|
||||
if self.message is None:
|
||||
try:
|
||||
print(f"prompt_verify: timeout reached, sending timeup for node_id={node_id}")
|
||||
except Exception:
|
||||
pass
|
||||
PromptServer.instance.send_sync("prompt_verify_request", {"node_id": node_id, "timeup":True})
|
||||
endat = time.monotonic() + 5
|
||||
while time.monotonic() < endat and self.message is None:
|
||||
time.sleep(0.1)
|
||||
return ( self.message or text, )
|
||||
finally:
|
||||
POBox.waiting.pop(node_id,None)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return float("NaN")
|
||||
|
||||
# Update POBox typing to refer to the renamed class
|
||||
class POBox:
|
||||
waiting:dict[int,PromptVerify] = {}
|
||||
@classmethod
|
||||
def send(cls, node_id, message):
|
||||
if (the_node := cls.waiting.get(node_id,None)):
|
||||
the_node.message = message
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
@routes.post('/prompt_verify_response')
|
||||
async def make_image_selection(request):
|
||||
post = await request.post()
|
||||
try:
|
||||
node_id = post['node_id']
|
||||
message = post.get('message','')
|
||||
except Exception:
|
||||
print('prompt_verify: malformed POST to /prompt_verify_response', post)
|
||||
return web.json_response({}, status=400)
|
||||
try:
|
||||
print(f"prompt_verify: /prompt_verify_response received node_id={node_id} message={repr(message)[:200]}")
|
||||
except Exception:
|
||||
pass
|
||||
POBox.send(node_id, message)
|
||||
return web.json_response({})
|
||||
Reference in New Issue
Block a user