nodes-ui-flow / src /components /InteractivePanel.jsx
markitzeroo
Deploy updated nodes UI flow
1dd9186
import { useCallback, useEffect, useRef, useState } from 'react';
const PANEL_HEIGHT_STORAGE_KEY = 'nodes_ui_flow_interactive_panel_height';
const DEFAULT_PANEL_HEIGHT = 320;
const MIN_PANEL_HEIGHT = 250;
const TOOLBAR_FALLBACK_HEIGHT = 72;
const MAX_PANEL_TOP_GAP = 8;
function getMaxPanelHeight() {
if (typeof window === 'undefined') {
return DEFAULT_PANEL_HEIGHT;
}
const toolbarHeight = document.querySelector('.toolbar')?.getBoundingClientRect().height || TOOLBAR_FALLBACK_HEIGHT;
return Math.max(MIN_PANEL_HEIGHT, window.innerHeight - toolbarHeight - MAX_PANEL_TOP_GAP);
}
function readInitialPanelHeight() {
if (typeof window === 'undefined') {
return DEFAULT_PANEL_HEIGHT;
}
const savedHeight = Number(window.localStorage.getItem(PANEL_HEIGHT_STORAGE_KEY));
if (!Number.isFinite(savedHeight)) {
return DEFAULT_PANEL_HEIGHT;
}
return Math.min(Math.max(savedHeight, MIN_PANEL_HEIGHT), getMaxPanelHeight());
}
export default function InteractivePanel({
isDemo,
pendingQuestion,
transcript,
inputValue,
isBusy,
llmProvider,
llmAutoAnswer,
writeTranscriptLogs,
llmRolePrompt,
onInputChange,
onLlmAutoAnswerChange,
onWriteTranscriptLogsChange,
onLlmRolePromptChange,
onSubmitAnswer,
onSimulateAnswer,
onSaveSessionLog,
}) {
const [panelHeight, setPanelHeight] = useState(readInitialPanelHeight);
const canAnswer = Boolean(pendingQuestion) && !isBusy && inputValue.trim().length > 0;
const canSimulate = Boolean(pendingQuestion) && !isBusy && llmRolePrompt.trim().length > 0;
const providerLabel = {
deepinfra: 'DeepInfra',
ollama: 'Ollama',
}[llmProvider] || llmProvider;
const transcriptRef = useRef(null);
const resizeRef = useRef(null);
const clampPanelHeight = useCallback((height) => (
Math.min(Math.max(Math.round(height), MIN_PANEL_HEIGHT), getMaxPanelHeight())
), []);
useEffect(() => {
if (transcriptRef.current) {
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
}
}, [transcript]);
useEffect(() => {
if (isDemo) {
return;
}
window.localStorage.setItem(PANEL_HEIGHT_STORAGE_KEY, String(panelHeight));
}, [isDemo, panelHeight]);
useEffect(() => {
if (isDemo) {
return undefined;
}
const handleResize = () => {
setPanelHeight((currentHeight) => clampPanelHeight(currentHeight));
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [clampPanelHeight, isDemo]);
useEffect(() => () => {
document.body.classList.remove('is-resizing-interactive-panel');
}, []);
const stopResize = useCallback((event) => {
const resizeState = resizeRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) {
return;
}
resizeRef.current = null;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
document.body.classList.remove('is-resizing-interactive-panel');
}, []);
const handleResizeStart = useCallback((event) => {
if (isDemo) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
resizeRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
startHeight: panelHeight,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
document.body.classList.add('is-resizing-interactive-panel');
}, [isDemo, panelHeight]);
const handleResizeMove = useCallback((event) => {
if (isDemo) {
return;
}
const resizeState = resizeRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) {
return;
}
const nextHeight = resizeState.startHeight + resizeState.startY - event.clientY;
setPanelHeight(clampPanelHeight(nextHeight));
}, [clampPanelHeight, isDemo]);
const handleResizeKeyDown = useCallback((event) => {
if (isDemo) {
return;
}
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return;
}
event.preventDefault();
const step = event.shiftKey ? 80 : 24;
setPanelHeight((currentHeight) => (
clampPanelHeight(currentHeight + (event.key === 'ArrowUp' ? step : -step))
));
}, [clampPanelHeight, isDemo]);
return (
<section
className={`interactive-panel ${isDemo ? 'interactive-panel--demo' : ''}`}
style={isDemo ? undefined : { '--interactive-panel-height': `${panelHeight}px` }}
>
{!isDemo ? (
<div
className="interactive-panel__resize-handle"
role="separator"
aria-label="Изменить высоту интерактивной панели"
aria-orientation="horizontal"
tabIndex={0}
onKeyDown={handleResizeKeyDown}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={stopResize}
onPointerCancel={stopResize}
/>
) : null}
<div className="interactive-panel__main">
<div className="interactive-panel__header">
<div>
<div className="interactive-panel__eyebrow">Интерактивный прогон</div>
<div className="interactive-panel__title">
{pendingQuestion ? pendingQuestion.question : 'Запустите сценарий, чтобы дойти до следующего вопроса.'}
</div>
</div>
<div className={`interactive-panel__state ${pendingQuestion ? 'is-waiting' : ''}`}>
{isBusy ? 'идет' : pendingQuestion ? 'ждем ответ' : 'готово'}
</div>
</div>
<div className="interactive-panel__transcript" ref={transcriptRef}>
{transcript.length === 0 ? (
<div className="interactive-panel__empty">Диалог появится здесь во время интерактивного прогона.</div>
) : (
transcript.map((message) => (
<div key={message.id} className={`interactive-message interactive-message--${message.role}`}>
<span>{message.role === 'assistant' ? 'Ассистент' : 'Пользователь'}</span>
<p>{message.text}</p>
</div>
))
)}
</div>
<form className="interactive-panel__answer" onSubmit={onSubmitAnswer}>
<input
className="interactive-panel__input"
type="text"
value={inputValue}
placeholder={pendingQuestion ? 'Введите ответ пользователя...' : 'Запустите сценарий до вопроса'}
disabled={!pendingQuestion || isBusy}
onChange={(event) => onInputChange(event.target.value)}
/>
<button type="submit" className="toolbar__button toolbar__button--primary" disabled={!canAnswer}>
Ответить
</button>
</form>
</div>
<aside className="interactive-panel__side">
<button
type="button"
className="toolbar__button toolbar__button--primary interactive-panel__save-log"
onClick={onSaveSessionLog}
>
Сохранить лог сессии
</button>
<div className="interactive-panel__tools">
<button type="button" className="toolbar__button" disabled={!canSimulate} onClick={onSimulateAnswer}>
Ответ LLM · {providerLabel}
</button>
</div>
<div className="interactive-panel__tools">
<label className="field-row">
<input
type="checkbox"
checked={llmAutoAnswer}
disabled={!llmRolePrompt.trim()}
onChange={(event) => onLlmAutoAnswerChange(event.target.checked)}
/>
<span>Автоответ LLM</span>
</label>
<label className="field-row">
<input
type="checkbox"
checked={writeTranscriptLogs}
onChange={(event) => onWriteTranscriptLogsChange(event.target.checked)}
/>
<span>Писать txt-лог</span>
</label>
</div>
<textarea
className="interactive-panel__role"
value={llmRolePrompt}
disabled={isBusy}
placeholder="Роль тестового пользователя..."
onChange={(event) => onLlmRolePromptChange(event.target.value)}
/>
</aside>
</section>
);
}