Files
melMass-comfy_mtb/web/mtb_repl.js
T
Mel Massadian 821a031bfc feat: ✨ yet another repl node for comfy
Still unsure how to expose it
2025-08-02 02:59:33 +02:00

530 lines
15 KiB
JavaScript

/** Python REPL for the frontend (uses rich)*/
import { app } from '../../scripts/app.js'
import * as shared from './comfy_shared.js'
import * as mtb_ui from './mtb_ui.js'
class ComfyREPL extends LiteGraph.LGraphNode {
constructor() {
super()
this.shape = LiteGraph.BOX_SHAPE
this.isVirtualNode = true
this.category = 'mtb/repl'
this.title = '🐍 REPL (mtb)'
this.uuid = shared.makeUUID()
this.size = [600, 400]
// Create a container for our custom widgets
this.widget = this.addDOMWidget('HTML', 'html', this.createREPLWidget())
this.loadAceEditor()
// Store input and output for persistence
this.properties = {
inputCode: '',
outputHistory: '',
}
this.outputArea.innerHTML = this.properties.outputHistory
this.outputArea.scrollTop = this.outputArea.scrollHeight
// Debounced linting function
this.debouncedLint = shared.debounce(this.lintCode.bind(this), 500)
// Resizing state variables
this.isResizing = false
this.initialMouseY = 0
this.initialInputHeight = 0
this.initialOutputHeight = 0
}
loadAceEditor() {
if (window.MTB?.ace_loaded) {
return
}
let NEED_PATCH = false
if (window.ace) {
shared.infoLogger(
'A global ace was found in scope, to avoid issues with it we will patch it',
)
NEED_PATCH = true
// window._backupAce = window.ace
// window.ace = null
}
shared
.loadScript('/mtb_async/ace/ace.js')
.then((m) => {
shared.infoLogger('ACE was loaded', m)
// window.MTB_ACE = window.ace
window.MTB.ace_loaded = true
this.initAceEditor()
this.aceEditor.setValue(this.properties.inputCode, -1)
})
.catch((e) => {
shared.errorLogger(e)
})
.finally(() => {
if (NEED_PATCH) {
console.log('Patching back window object')
window.ace = window._backupAce
}
})
}
initAceEditor() {
if (!window.MTB.ace_loaded) {
console.error('ACE editor not loaded. Cannot set up editors.')
return
}
if (!this.inputDiv) {
console.error('Input div not found for Ace editor initialization.')
return
}
this.aceEditor = ace.edit(this.inputDiv)
this.aceEditor.setTheme('ace/theme/monokai') //"ace/theme/dracula", "ace/theme/github"
this.aceEditor.session.setMode('ace/mode/python')
this.aceEditor.setOptions({
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
fontSize: '14px',
fontFamily: 'monospace',
showPrintMargin: false,
wrap: true,
tabSize: 4,
useSoftTabs: true,
highlightActiveLine: true,
highlightSelectedWord: true,
cursorStyle: 'ace', // "ace" | "slim" | "smooth" | "wide"
behavioursEnabled: true,
displayIndentGuides: true,
fixedWidthGutter: true,
scrollPastEnd: 0.5,
})
// Custom keybinding for Ctrl+Enter
this.aceEditor.commands.addCommand({
name: 'runCode',
bindKey: { win: 'Ctrl-Enter', mac: 'Command-Enter' },
exec: () => this.executeCode(),
})
// Listen for changes to trigger linting
let lintDisabled = false
this.aceEditor.session.on('change', () => {
if (!lintDisabled) {
this.debouncedLint()
}
})
this.outputArea.scrollTop = this.outputArea.scrollHeight
}
addOutput(html) {
this.outputArea.innerHTML += html
this.properties.outputHistory += html
this.outputArea.scrollTop = this.outputArea.scrollHeight
}
createREPLWidget() {
const container = mtb_ui.makeElement('div', {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
boxSizing: 'border-box',
padding: '5px',
})
this.inputDiv = mtb_ui.makeElement(
'div',
{
width: 'calc(100% - 10px)',
height: '100px',
backgroundColor: '#333',
color: '#eee',
border: '1px solid #555',
borderRadius: '4px',
marginBottom: '5px',
boxSizing: 'border-box',
overflow: 'hidden',
},
container,
)
// Resizable Handle
this.handleDiv = mtb_ui.makeElement(
'div',
{
width: '100%',
height: '5px',
backgroundColor: '#666',
cursor: 'ns-resize',
marginBottom: '5px',
borderRadius: '2px',
},
container,
)
this.handleDiv.addEventListener('mousedown', this.startResizing.bind(this))
// Run Button
this.runButton = mtb_ui.makeElement(
'button',
{
width: '100%',
padding: '8px',
backgroundColor: '#555',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginBottom: '5px',
fontSize: '14px',
},
container,
)
this.runButton.textContent = 'Run Code (Ctrl+Enter)'
this.runButton.onclick = () => this.executeCode()
// Clear Button
this.clearButton = mtb_ui.makeElement(
'button',
{
width: '100%',
padding: '8px',
backgroundColor: '#555',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginBottom: '5px',
fontSize: '14px',
},
container,
)
this.clearButton.textContent = 'Clear Output'
this.clearButton.onclick = () => {
this.outputArea.innerHTML = ''
this.properties.outputHistory = ''
}
// Output Area
this.outputArea = mtb_ui.makeElement(
'div',
{
flexGrow: '1',
width: 'calc(100% - 10px)',
backgroundColor: '#222',
color: '#ddd',
border: '1px solid #555',
borderRadius: '4px',
padding: '5px',
fontFamily: 'monospace',
fontSize: '14px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
boxSizing: 'border-box',
},
container,
)
return container
}
// --- Resizing Logic ---
startResizing(e) {
if (!this.inputDiv) {
shared.infoLogger("The input div isn't ready", this)
shared.errorLogger("The input div isn't ready")
return
}
this.isResizing = true
this.initialMouseY = e.clientY
this.initialInputHeight = this.inputDiv.offsetHeight
this.initialOutputHeight = this.outputArea.offsetHeight
document.addEventListener('mousemove', this.doResize.bind(this))
document.addEventListener('mouseup', this.stopResizing.bind(this))
document.body.style.cursor = 'ns-resize' // Change cursor globally
}
doResize(e) {
if (!this.isResizing) return
const deltaY = e.clientY - this.initialMouseY
let new_input_height = this.initialInputHeight + deltaY
let new_output_height = this.initialOutputHeight - deltaY
const minInputHeight = 50 // Minimum height for Ace editor
const minOutputHeight = 50 // Minimum height for output area
// Clamp heights to minimums
if (new_input_height < minInputHeight) {
new_input_height = minInputHeight
new_output_height =
this.initialInputHeight + this.initialOutputHeight - minInputHeight
}
if (new_output_height < minOutputHeight) {
new_output_height = minOutputHeight
new_input_height =
this.initialInputHeight + this.initialOutputHeight - minOutputHeight
}
this.inputDiv.style.height = `${new_input_height}px`
this.outputArea.style.height = `${new_output_height}px`
// Update the stored ratio for persistence
const totalDynamicHeight =
this.inputDiv.offsetHeight + this.outputArea.offsetHeight
if (totalDynamicHeight > 0) {
this.properties.inputHeightRatio = new_input_height / totalDynamicHeight
}
this.aceEditor.resize() // Important for Ace to redraw
}
stopResizing() {
this.isResizing = false
document.removeEventListener('mousemove', this.doResize)
document.removeEventListener('mouseup', this.stopResizing)
document.body.style.cursor = '' // Restore default cursor
}
// --- End Resizing Logic ---
async executeCode() {
const code = this.aceEditor.getValue()
if (!code.trim()) {
return
}
const inputPrompt = `<div style="color:#888; margin-top: 10px;">>>> ${code}</div>`
this.addOutput(inputPrompt)
try {
const response = await fetch('/mtb/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: code, name: this.uuid }),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
console.debug('Received from backend', result)
const outputHtml = result.output_html || ''
const error = result.error
if (error) {
this.addOutput(
`<div style="color: #f00; font-weight: bold;">Error:</div>${outputHtml}`,
)
} else {
this.addOutput(outputHtml)
}
} catch (e) {
const errorMessage = `<div style="color: #f00;">Frontend Error: ${e.message}</div>`
this.addOutput(errorMessage)
console.error('ComfyREPL Frontend Error:', e)
} finally {
// Not clearing
// this.inputArea.value = '' // Clear input after execution
// this.properties.inputCode = '' // Clear persisted input
}
}
async lintCode() {
if (!this.aceEditor) {
return
}
const code = this.aceEditor.getValue()
if (!code.trim()) {
this.aceEditor.session.setAnnotations([]) // Clear annotations if empty
return
}
try {
const response = await fetch('/mtb/lint', {
// New linting endpoint
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: code, name: this.uuid }),
})
if (!response.ok) {
console.log(response)
throw new Error(
`HTTP error! status: ${response.status} ${response.statusText}`,
)
}
const result = await response.json()
// result.diagnostics should be an array of {row, column, text, type}
this.aceEditor.session.setAnnotations(result.diagnostics)
} catch (e) {
console.error('ComfyREPL Linting Error:', e)
this.aceEditor.session.setAnnotations([
{
row: 0,
column: 0,
text: `Linting failed: ${e.message}`,
type: 'error',
},
])
}
}
// Restore properties when loading a graph
onConfigure() {
if (this.properties.inputCode && this.aceEditor) {
this.aceEditor.setValue(this.properties.inputCode, -1)
}
// if (this.properties.inputCode) {
// this.inputArea.value = this.properties.inputCode
// }
if (this.properties.outputHistory) {
this.outputArea.innerHTML = this.properties.outputHistory
this.outputArea.scrollTop = this.outputArea.scrollHeight
}
if (this.properties.uuid) {
this.uuid = this.properties.uuid
}
this.debouncedLint()
this.onResize(this.size)
}
// Save properties when saving a graph
onSerialize(o) {
if (this.aceEditor) {
o.properties.inputCode = this.aceEditor.getValue() //this.inputArea.value
}
o.properties.outputHistory = this.outputArea.innerHTML
o.properties.uuid = this.uuid
o.properties.inputHeightRatio = this.properties.inputHeightRatio
}
onRemoved() {
// Clean up DOM elements when node is removed
if (this.widget?.element?.parentNode) {
this.widget.element.parentNode.removeChild(this.widget.element)
}
// Destroy Ace editor instance to prevent memory leaks
if (this.aceEditor) {
this.aceEditor.destroy()
this.aceEditor.container.remove() // Remove the Ace container div from DOM
}
// Clean up global event listeners if node is removed while resizing
document.removeEventListener('mousemove', this.doResize)
document.removeEventListener('mouseup', this.stopResizing)
document.body.style.cursor = ''
}
// LiteGraph method to handle node resizing
onResize(size) {
// Call parent method if it exists (important for LiteGraph's internal sizing)
if (super.onResize) {
super.onResize(size)
}
// Adjust container size
const container = this.widget.element
container.style.width = `${size[0] - 10}px` // Account for padding
container.style.height = `${size[1] - 10}px`
// Adjust input and output area widths
this.inputDiv.style.width = 'calc(100% - 10px)'
this.outputArea.style.width = 'calc(100% - 10px)'
//
// const old = () => {
// // Calculate remaining height for output area
// // Ace editor manages its own height within this.inputDiv, so we use offsetHeight
// const inputHeight = this.inputDiv.offsetHeight
// const runButtonHeight = this.runButton.offsetHeight
// const clearButtonHeight = this.clearButton.offsetHeight
// const totalFixedHeight =
// inputHeight + runButtonHeight + clearButtonHeight + 15 // 15 for margins/padding
//
// const remainingHeight = size[1] - 10 - totalFixedHeight
// this.outputArea.style.height = `${Math.max(50, remainingHeight)}px` // Min height 50px
// }
// Calculate dynamic heights
const containerHeight = size[1] - 10
const handleHeight = this.handleDiv.offsetHeight
const buttonHeights =
this.runButton.offsetHeight + this.clearButton.offsetHeight + 15 // Sum of button heights + margins
const dynamicContentHeight = containerHeight - buttonHeights - handleHeight
const minInputHeight = 50
const minOutputHeight = 50
let inputHeight = Math.max(
minInputHeight,
dynamicContentHeight * (this.properties.inputHeightRatio || 1.0),
)
let outputHeight = Math.max(
minOutputHeight,
dynamicContentHeight - inputHeight,
)
//
// // Re-distribute if one hits its minimum
// if (
// inputHeight === minInputHeight &&
// dynamicContentHeight - minInputHeight > minOutputHeight
// ) {
// outputHeight = dynamicContentHeight - minInputHeight
// } else if (
// outputHeight === minOutputHeight &&
// dynamicContentHeight - minOutputHeight > minInputHeight
// ) {
// inputHeight = dynamicContentHeight - minOutputHeight
// }
//
// // Final check to ensure total height matches available dynamic space
// const currentTotal = inputHeight + outputHeight
// if (currentTotal !== dynamicContentHeight) {
// // Adjust one of them if there's a small discrepancy due to rounding
// if (inputHeight > minInputHeight) {
// inputHeight += dynamicContentHeight - currentTotal
// } else if (outputHeight > minOutputHeight) {
// outputHeight += dynamicContentHeight - currentTotal
// }
// }
this.inputDiv.style.height = `${inputHeight}px`
this.outputArea.style.height = `${outputHeight}px`
// Update the ratio based on the actual heights set
if (dynamicContentHeight > 0) {
this.properties.inputHeightRatio = inputHeight / dynamicContentHeight
}
// Inform Ace editor about the resize so it can redraw its content
if (this.aceEditor) {
this.aceEditor.resize()
}
}
}
const repl = {
name: 'mtb.repl',
registerCustomNodes() {
LiteGraph.registerNodeType('Python REPL', ComfyREPL)
},
}
app.registerExtension(repl)