Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useState } from 'react'; | |
| import { Handle, Position } from '@xyflow/react'; | |
| import { useWorkflow } from '../context/WorkflowContext.jsx'; | |
| function getStatusLabel(status) { | |
| switch (status) { | |
| case 'running': | |
| return 'Running'; | |
| case 'waiting': | |
| return 'Waiting'; | |
| case 'skipped': | |
| return 'Skipped'; | |
| case 'success': | |
| return 'Ready'; | |
| case 'error': | |
| return 'Error'; | |
| default: | |
| return ''; | |
| } | |
| } | |
| const HANDLE_COLORS_BY_ID = { | |
| answer: '#38bdf8', | |
| question: '#a78bfa', | |
| turn: '#f59e0b', | |
| dialog: '#22c55e', | |
| 'dialog-in': '#22c55e', | |
| }; | |
| const HANDLE_COLORS_BY_DATA_TYPE = { | |
| string: '#38bdf8', | |
| object: '#f59e0b', | |
| '*': '#94a3b8', | |
| }; | |
| function getHandleColor(handle) { | |
| return HANDLE_COLORS_BY_ID[handle?.id] || HANDLE_COLORS_BY_DATA_TYPE[handle?.dataType] || '#94a3b8'; | |
| } | |
| export default function NodeShell({ | |
| nodeId, | |
| title, | |
| accent, | |
| selected, | |
| status, | |
| inputs = [], | |
| outputs = [], | |
| children, | |
| footer, | |
| className = '', | |
| bodyClassName = '', | |
| renderInputAddon, | |
| }) { | |
| const { patchNodeData } = useWorkflow(); | |
| const rows = Math.max(inputs.length, outputs.length); | |
| const [isEditingTitle, setIsEditingTitle] = useState(false); | |
| const [draftTitle, setDraftTitle] = useState(title || ''); | |
| const titleInputRef = useRef(null); | |
| useEffect(() => { | |
| if (!isEditingTitle) { | |
| setDraftTitle(title || ''); | |
| } | |
| }, [isEditingTitle, title]); | |
| useEffect(() => { | |
| if (isEditingTitle && titleInputRef.current) { | |
| titleInputRef.current.focus(); | |
| titleInputRef.current.select(); | |
| } | |
| }, [isEditingTitle]); | |
| const finishTitleEditing = () => { | |
| const nextTitle = draftTitle.trim() || title || 'Untitled'; | |
| if (nodeId && nextTitle !== title) { | |
| patchNodeData(nodeId, { title: nextTitle }); | |
| } | |
| setDraftTitle(nextTitle); | |
| setIsEditingTitle(false); | |
| }; | |
| return ( | |
| <div | |
| className={[ | |
| 'node-shell', | |
| selected ? 'is-selected' : '', | |
| status ? `status-${status}` : '', | |
| className, | |
| ] | |
| .filter(Boolean) | |
| .join(' ')} | |
| style={{ '--node-accent': accent }} | |
| > | |
| <div className="node-shell__header drag-handle"> | |
| {isEditingTitle ? ( | |
| <input | |
| ref={titleInputRef} | |
| className="nodrag node-shell__title-input" | |
| value={draftTitle} | |
| onChange={(event) => setDraftTitle(event.target.value)} | |
| onBlur={finishTitleEditing} | |
| onKeyDown={(event) => { | |
| if (event.key === 'Enter') { | |
| event.preventDefault(); | |
| finishTitleEditing(); | |
| } | |
| if (event.key === 'Escape') { | |
| event.preventDefault(); | |
| setDraftTitle(title || ''); | |
| setIsEditingTitle(false); | |
| } | |
| }} | |
| /> | |
| ) : ( | |
| <button | |
| type="button" | |
| className="nodrag node-shell__title node-shell__title-button" | |
| title="Нажмите, чтобы переименовать" | |
| onClick={() => { | |
| if (nodeId) { | |
| setIsEditingTitle(true); | |
| } | |
| }} | |
| > | |
| {title} | |
| </button> | |
| )} | |
| {status && status !== 'idle' ? ( | |
| <div className={`node-shell__status-pill status-${status || 'idle'}`}> | |
| {getStatusLabel(status)} | |
| </div> | |
| ) : null} | |
| </div> | |
| {rows > 0 ? ( | |
| <div className="node-shell__ports"> | |
| {Array.from({ length: rows }).map((_, index) => { | |
| const input = inputs[index]; | |
| const output = outputs[index]; | |
| return ( | |
| <div className="node-shell__port-row" key={`${input?.id || 'empty'}-${output?.id || 'empty'}`}> | |
| <div className="node-shell__port node-shell__port--input"> | |
| {input ? ( | |
| <> | |
| <Handle | |
| type="target" | |
| position={Position.Left} | |
| id={input.id} | |
| className="node-shell__handle" | |
| style={{ top: '50%', '--handle-color': getHandleColor(input) }} | |
| /> | |
| <div className="node-shell__port-content node-shell__port-content--input"> | |
| <span>{input.label}</span> | |
| {renderInputAddon ? renderInputAddon(input, index) : null} | |
| </div> | |
| </> | |
| ) : ( | |
| <span className="node-shell__port-placeholder" /> | |
| )} | |
| </div> | |
| <div className="node-shell__port node-shell__port--output"> | |
| {output ? ( | |
| <> | |
| <Handle | |
| type="source" | |
| position={Position.Right} | |
| id={output.id} | |
| className="node-shell__handle" | |
| style={{ top: '50%', '--handle-color': getHandleColor(output) }} | |
| /> | |
| <div className="node-shell__port-content node-shell__port-content--output"> | |
| <span>{output.label}</span> | |
| </div> | |
| </> | |
| ) : ( | |
| <span className="node-shell__port-placeholder" /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ) : null} | |
| <div className={`node-shell__body ${rows === 0 ? 'node-shell__body--flush' : ''} ${bodyClassName}`.trim()}> | |
| {children} | |
| </div> | |
| {footer ? <div className="node-shell__footer">{footer}</div> : null} | |
| </div> | |
| ); | |
| } | |