Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |