Spaces:
Running
Running
Download src/components/OutputField.tsx from PYTHAI/bankML-UIF: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/PYTHAI/bankML-UIF/resolve/main/src/components/OutputField.tsx
- Command line
-
hf download hf://spaces/PYTHAI/bankML-UIF/src/components/OutputField.tsx
-
curl -L -o OutputField.tsx https://huggingface.co/spaces/PYTHAI/bankML-UIF/resolve/main/src/components/OutputField.tsx
10.7 kB
| import React, { useEffect, useRef, useState } from 'react'; | |
| import type { OutputField as OutputFieldData } from '../types'; | |
| import { KEYBOARD_HINT, useDragResize } from '../hooks/useDragResize'; | |
| import { useUIF } from './UIFContext'; | |
| import { ResizeCorners } from './ResizeCorners'; | |
| interface ViewProps { | |
| field: OutputFieldData; | |
| active: boolean; | |
| flash: number; | |
| popped?: boolean; | |
| onHeaderPointerDown?: (e: React.PointerEvent) => void; | |
| /** Keyboard move/resize for the header; makes the header focusable. */ | |
| onHeaderKeyDown?: (e: React.KeyboardEvent) => void; | |
| onRename?: (title: string) => void; | |
| /** Send a chat message into this output. Shows a reply box when set. */ | |
| onSubmit?: (text: string) => void; | |
| /** Keep a reply in .memory. Shows a β button on replies when set. */ | |
| onRemember?: (text: string) => void; | |
| onPopOut?: () => void; | |
| /** Read replies aloud: shows a π toggle when set. */ | |
| onSpeak?: () => void; | |
| onHome?: () => void; | |
| /** Fill the screen or shrink back: a β€’ button, and a double-click on the header (the title still renames). */ | |
| onMaximize?: () => void; | |
| onClose: () => void; | |
| } | |
| /** Header and message list, shared by the in-page panel and the pop-out window. */ | |
| export function OutputView({ | |
| field, | |
| active, | |
| flash, | |
| popped, | |
| onHeaderPointerDown, | |
| onHeaderKeyDown, | |
| onRename, | |
| onSubmit, | |
| onRemember, | |
| onPopOut, | |
| onSpeak, | |
| onHome, | |
| onMaximize, | |
| onClose, | |
| }: ViewProps) { | |
| const [kept, setKept] = useState<Record<string, boolean>>({}); | |
| const listRef = useRef<HTMLDivElement>(null); | |
| const [editing, setEditing] = useState(false); | |
| const [reply, setReply] = useState(''); | |
| const busy = field.messages.some(m => m.pending); | |
| const finishRename = (title: string | null) => { | |
| setEditing(false); | |
| if (title !== null && title.trim() && title.trim() !== field.title) onRename?.(title); | |
| }; | |
| const submit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!reply.trim() || busy) return; | |
| onSubmit?.(reply); | |
| setReply(''); | |
| }; | |
| const last = field.messages[field.messages.length - 1]; | |
| useEffect(() => { | |
| const el = listRef.current; | |
| if (el) el.scrollTop = el.scrollHeight; | |
| }, [field.messages.length, last?.text]); | |
| return ( | |
| <> | |
| {flash > 0 && <div key={flash} className="uif-flash" style={{ '--uif-color': field.color } as React.CSSProperties} />} | |
| <div | |
| className={`uif-output-header ${busy ? 'is-busy' : ''}`} | |
| onPointerDown={editing ? undefined : onHeaderPointerDown} | |
| onKeyDown={editing ? undefined : onHeaderKeyDown} | |
| onDoubleClick={e => { | |
| // the title's own double-click renames; anywhere else on the header maximizes or shrinks back | |
| if (!editing && onMaximize && !(e.target as HTMLElement).closest('.uif-output-title, .uif-output-buttons')) onMaximize(); | |
| }} | |
| tabIndex={onHeaderKeyDown ? 0 : undefined} | |
| role={onHeaderKeyDown ? 'group' : undefined} | |
| aria-label={onHeaderKeyDown ? `${field.title} output. ${KEYBOARD_HINT}` : undefined} | |
| > | |
| <span className="uif-output-dot" style={{ background: field.color }} /> | |
| <span className="uif-output-avatar" aria-hidden="true" /> | |
| {editing ? ( | |
| <input | |
| className="uif-output-rename" | |
| defaultValue={field.title} | |
| aria-label="Output name" | |
| autoFocus | |
| onFocus={e => e.currentTarget.select()} | |
| onPointerDown={e => e.stopPropagation()} | |
| onKeyDown={e => { | |
| e.stopPropagation(); | |
| if (e.key === 'Enter') finishRename(e.currentTarget.value); | |
| if (e.key === 'Escape') finishRename(null); | |
| }} | |
| onBlur={e => finishRename(e.currentTarget.value)} | |
| /> | |
| ) : ( | |
| <span | |
| className="uif-output-title" | |
| title={onRename ? 'Double-click to rename' : undefined} | |
| onDoubleClick={onRename ? () => setEditing(true) : undefined} | |
| > | |
| {field.title} | |
| </span> | |
| )} | |
| {active && <span className="uif-output-badge">active</span>} | |
| <span className="uif-output-buttons" onPointerDown={e => e.stopPropagation()}> | |
| {onSpeak && ( | |
| <button | |
| type="button" | |
| className={field.speak ? 'is-on' : ''} | |
| title={field.speak ? 'Stop reading replies aloud' : 'Read replies aloud'} | |
| aria-pressed={!!field.speak} | |
| onClick={onSpeak} | |
| > | |
| {field.speak ? 'π' : 'π'} | |
| </button> | |
| )} | |
| {onPopOut && ( | |
| <button type="button" title="Pop out to a window" onClick={onPopOut}>β§</button> | |
| )} | |
| {onHome && ( | |
| <button type="button" title={popped ? 'Call home' : 'Back beside the field'} onClick={onHome}>β</button> | |
| )} | |
| {onMaximize && ( | |
| <button type="button" title={field.maximized ? 'Shrink back (or double-click the header, or Esc)' : 'Fill the screen (or double-click the header)'} | |
| aria-pressed={!!field.maximized} onClick={onMaximize}>{field.maximized ? '‑' : '‒'}</button> | |
| )} | |
| <button type="button" title="Close" onClick={onClose}>β</button> | |
| </span> | |
| </div> | |
| <div className="uif-output-body" ref={listRef}> | |
| {field.messages.length === 0 && <div className="uif-output-empty">No messages yet.</div>} | |
| {field.messages.map(m => { | |
| if (m.role === 'system' && !m.text && !m.pending) return null; // a status line the engine cleared | |
| const keep = onRemember && m.role === 'assistant' && !m.pending && !m.undelivered && m.text; | |
| const msg = ( | |
| <div key={m.id} className={['uif-msg', `uif-msg-${m.role}`, m.kind && `is-${m.kind}`, m.tone && `is-${m.tone}`, m.undelivered && 'is-undelivered'].filter(Boolean).join(' ')}> | |
| {m.text} | |
| {m.pending && <span className="uif-caret">β</span>} | |
| {m.media && ( | |
| <span className="uif-msg-media"> | |
| {m.media.kind === 'video' | |
| ? <video src={m.media.url} controls playsInline loop className="uif-media" aria-label={m.text || 'video'} /> | |
| : <img src={m.media.url} alt={m.text || 'image'} className="uif-media" />} | |
| <a className="uif-media-save" href={m.media.url} download={m.media.name || (m.media.kind === 'video' ? 'video.mp4' : 'image.png')}>β€ save</a> | |
| </span> | |
| )} | |
| {m.silent && field.speak && ( | |
| <span className="uif-msg-silent" title="Not read aloud: the engine did not vouch for this reply" aria-label="not read aloud"> | |
| {' '}π | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| if (!keep) return msg; | |
| // The keep button sits beside the reply, not inside it, so copying the reply copies only the reply. | |
| return ( | |
| <div key={m.id} className="uif-msg-row"> | |
| {msg} | |
| <button | |
| type="button" | |
| className={`uif-msg-keep ${kept[m.id] ? 'is-kept' : ''}`} | |
| title={kept[m.id] ? 'Kept in .memory' : 'Keep this reply in .memory'} | |
| aria-label="Remember this reply" | |
| disabled={kept[m.id]} | |
| onClick={() => { | |
| onRemember(m.text); | |
| setKept(k => ({ ...k, [m.id]: true })); | |
| }} | |
| > | |
| β | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {onSubmit && ( | |
| <form className="uif-output-reply" onSubmit={submit}> | |
| <input | |
| value={reply} | |
| onChange={e => setReply(e.target.value)} | |
| placeholder={`Reply in ${field.title}β¦`} | |
| aria-label={`Message ${field.title}`} | |
| className="uif-output-reply-input" | |
| /> | |
| <button type="submit" disabled={!reply.trim() || busy} aria-label="Send"> | |
| β€ | |
| </button> | |
| </form> | |
| )} | |
| </> | |
| ); | |
| } | |
| /** A floating, draggable output panel inside the hub page. */ | |
| export function OutputField({ field }: { field: OutputFieldData }) { | |
| const { outputs, setRect, flashes, raise, zIndexOf, sendTo, context } = useUIF(); | |
| const { position, size, setPosition, setSize, startDrag, startResize, onHandleKeyDown, isDragging } = useDragResize({ | |
| initialPosition: { x: field.rect.x, y: field.rect.y }, | |
| initialSize: { width: field.rect.width, height: field.rect.height }, | |
| minWidth: 220, | |
| minHeight: 120, | |
| onCommit: rect => setRect(field.id, rect), | |
| }); | |
| // Follow rect changes made elsewhere (call home restacks the panels). | |
| useEffect(() => { | |
| setPosition({ x: field.rect.x, y: field.rect.y }); | |
| setSize({ width: field.rect.width, height: field.rect.height }); | |
| }, [field.rect, setPosition, setSize]); | |
| const active = outputs.activeId === field.id; | |
| const max = !!field.maximized; | |
| // Esc shrinks the window that fills the screen back to where it was | |
| useEffect(() => { | |
| if (!max) return; | |
| const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && outputs.activeId === field.id) outputs.maximize(field.id, false); }; | |
| document.addEventListener('keydown', onKey); | |
| return () => document.removeEventListener('keydown', onKey); | |
| }, [max, field.id, outputs]); | |
| return ( | |
| <div | |
| className={`uif-output ${active ? 'is-active' : ''} ${isDragging ? 'is-dragging' : ''} ${max ? 'is-maximized' : ''}`} | |
| data-uif-output={field.id} | |
| style={ | |
| { | |
| left: max ? 0 : position.x, | |
| top: max ? 0 : position.y, | |
| width: max ? '100vw' : size.width, | |
| height: max ? '100vh' : size.height, | |
| zIndex: zIndexOf(field.id), | |
| '--uif-color': field.color, | |
| } as React.CSSProperties | |
| } | |
| onPointerDownCapture={() => { | |
| outputs.setActive(field.id); | |
| raise(field.id); | |
| }} | |
| > | |
| <OutputView | |
| field={field} | |
| active={active} | |
| flash={flashes[field.id] ?? 0} | |
| onHeaderPointerDown={max ? undefined : startDrag} | |
| onHeaderKeyDown={onHandleKeyDown} | |
| onMaximize={() => outputs.maximize(field.id)} | |
| onRename={title => outputs.rename(field.id, title)} | |
| onSubmit={text => sendTo(text, field.id)} | |
| onRemember={text => void context.remember(text, 'response')} | |
| onPopOut={() => outputs.popOut(field.id)} | |
| onSpeak={() => outputs.setSpeak(field.id, !field.speak)} | |
| onClose={() => outputs.close(field.id)} | |
| /> | |
| {!max && <ResizeCorners onStart={startResize} />} | |
| </div> | |
| ); | |
| } | |