diff --git a/frontend/src/components/conversation/ConversationEmbeddingPanel.css b/frontend/src/components/conversation/ConversationEmbeddingPanel.css new file mode 100644 index 0000000000..20714394f3 --- /dev/null +++ b/frontend/src/components/conversation/ConversationEmbeddingPanel.css @@ -0,0 +1,360 @@ +/** + * ConversationEmbeddingPanel.css + * + * Styles for the draggable, resizable embedding projector panel. + * Based on InstitutionInfoPanel.css and RdfNodeDetailsPanel.css patterns. + */ + +/* ============================================================================ + Main Panel Container + ============================================================================ */ + +.conversation-embedding-panel { + position: fixed; + top: 0; + left: 0; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 150; + transition: box-shadow 0.2s ease; + will-change: transform; +} + +.conversation-embedding-panel--dragging, +.conversation-embedding-panel--resizing { + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18); + cursor: grabbing !important; +} + +.conversation-embedding-panel--minimized { + height: 48px !important; +} + +.conversation-embedding-panel--simple { + /* Width/height handled inline by component */ +} + +/* ============================================================================ + Resize Handles + ============================================================================ */ + +.conversation-embedding-panel__resize-handle { + position: absolute; + background: transparent; + z-index: 10; +} + +/* North and South edges */ +.conversation-embedding-panel__resize-handle--n, +.conversation-embedding-panel__resize-handle--s { + left: 12px; + right: 12px; + height: 8px; + cursor: ns-resize; +} + +.conversation-embedding-panel__resize-handle--n { + top: -4px; +} + +.conversation-embedding-panel__resize-handle--s { + bottom: -4px; +} + +/* East and West edges */ +.conversation-embedding-panel__resize-handle--e, +.conversation-embedding-panel__resize-handle--w { + top: 12px; + bottom: 12px; + width: 8px; + cursor: ew-resize; +} + +.conversation-embedding-panel__resize-handle--e { + right: -4px; +} + +.conversation-embedding-panel__resize-handle--w { + left: -4px; +} + +/* Corner handles */ +.conversation-embedding-panel__resize-handle--ne, +.conversation-embedding-panel__resize-handle--nw, +.conversation-embedding-panel__resize-handle--se, +.conversation-embedding-panel__resize-handle--sw { + width: 16px; + height: 16px; +} + +.conversation-embedding-panel__resize-handle--ne { + top: -4px; + right: -4px; + cursor: nesw-resize; +} + +.conversation-embedding-panel__resize-handle--nw { + top: -4px; + left: -4px; + cursor: nwse-resize; +} + +.conversation-embedding-panel__resize-handle--se { + bottom: -4px; + right: -4px; + cursor: nwse-resize; +} + +.conversation-embedding-panel__resize-handle--sw { + bottom: -4px; + left: -4px; + cursor: nesw-resize; +} + +/* Visual indicator for SE corner (most commonly used for resize) */ +.conversation-embedding-panel__resize-handle--se::after { + content: ''; + position: absolute; + right: 6px; + bottom: 6px; + width: 12px; + height: 12px; + border-right: 2px solid rgba(0, 0, 0, 0.15); + border-bottom: 2px solid rgba(0, 0, 0, 0.15); + border-radius: 0 0 3px 0; + pointer-events: none; +} + +.conversation-embedding-panel__resize-handle--se:hover::after { + border-color: rgba(10, 61, 250, 0.4); +} + +/* ============================================================================ + Header (Drag Handle) + ============================================================================ */ + +.conversation-embedding-panel__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: none; + background: linear-gradient(135deg, #0a3dfa 0%, #1e50ff 100%); + flex-shrink: 0; + user-select: none; +} + +.conversation-embedding-panel--minimized .conversation-embedding-panel__header { + border-bottom: none; +} + +.conversation-embedding-panel__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9375rem; + font-weight: 600; + color: white; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-embedding-panel__title svg { + color: rgba(255, 255, 255, 0.9); + flex-shrink: 0; +} + +/* ============================================================================ + Controls - Larger, clearer buttons + ============================================================================ */ + +.conversation-embedding-panel__controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.conversation-embedding-panel__sources-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: white; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + margin-right: 4px; +} + +.conversation-embedding-panel__control-btn, +.conversation-embedding-panel__close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + color: white; + cursor: pointer; + transition: all 0.15s ease; +} + +.conversation-embedding-panel__control-btn svg, +.conversation-embedding-panel__close-btn svg { + width: 18px; + height: 18px; +} + +.conversation-embedding-panel__control-btn:hover { + background: rgba(255, 255, 255, 0.35); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.05); +} + +.conversation-embedding-panel__control-btn--active { + background: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.6); +} + +.conversation-embedding-panel__close-btn:hover { + background: rgba(220, 38, 38, 0.9); + border-color: rgba(220, 38, 38, 1); + transform: scale(1.05); +} + +/* ============================================================================ + Content + ============================================================================ */ + +.conversation-embedding-panel__content { + flex: 1; + overflow: hidden; + position: relative; +} + +.conversation-embedding-panel__loading, +.conversation-embedding-panel__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + height: 100%; + color: #666; + font-size: 0.875rem; +} + +.conversation-embedding-panel__loading-icon { + animation: spin 1s linear infinite; + color: var(--primary-blue, #0a3dfa); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.conversation-embedding-panel__empty svg { + color: #999; +} + +.conversation-embedding-panel__empty p { + margin: 0; + text-align: center; +} + +/* ============================================================================ + Dark Theme + ============================================================================ */ + +[data-theme="dark"] .conversation-embedding-panel { + background: var(--bg-primary-dark, #1a1a2e); + border-color: var(--border-color-dark, #333); +} + +[data-theme="dark"] .conversation-embedding-panel__header { + background: linear-gradient(135deg, #1a4fff 0%, #3366ff 100%); +} + +[data-theme="dark"] .conversation-embedding-panel__title { + color: white; +} + +[data-theme="dark"] .conversation-embedding-panel__control-btn, +[data-theme="dark"] .conversation-embedding-panel__close-btn { + color: white; + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); +} + +[data-theme="dark"] .conversation-embedding-panel__control-btn:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.4); +} + +[data-theme="dark"] .conversation-embedding-panel__close-btn:hover { + background: rgba(220, 38, 38, 0.9); + border-color: rgba(220, 38, 38, 1); +} + +[data-theme="dark"] .conversation-embedding-panel__loading, +[data-theme="dark"] .conversation-embedding-panel__empty { + color: #999; +} + +[data-theme="dark"] .conversation-embedding-panel__resize-handle--se::after { + border-color: rgba(255, 255, 255, 0.2); +} + +[data-theme="dark"] .conversation-embedding-panel__resize-handle--se:hover::after { + border-color: rgba(100, 150, 255, 0.5); +} + +/* ============================================================================ + Responsive Adjustments + ============================================================================ */ + +@media (max-width: 768px) { + .conversation-embedding-panel { + width: calc(100vw - 32px) !important; + max-width: 500px; + } + + .conversation-embedding-panel__sources-badge { + display: none; + } + + .conversation-embedding-panel__resize-handle { + display: none; + } +} + +@media (max-width: 480px) { + .conversation-embedding-panel { + width: calc(100vw - 16px) !important; + left: 8px !important; + right: 8px !important; + transform: translateY(var(--panel-y, 0)) !important; + } + + .conversation-embedding-panel__title { + font-size: 0.875rem; + } + + .conversation-embedding-panel__control-btn, + .conversation-embedding-panel__close-btn { + width: 28px; + height: 28px; + } +} diff --git a/frontend/src/components/conversation/ConversationEmbeddingPanel.tsx b/frontend/src/components/conversation/ConversationEmbeddingPanel.tsx new file mode 100644 index 0000000000..68fdca59fc --- /dev/null +++ b/frontend/src/components/conversation/ConversationEmbeddingPanel.tsx @@ -0,0 +1,474 @@ +/** + * ConversationEmbeddingPanel.tsx + * + * A draggable, floating panel that displays the Embedding Projector visualization. + * Shows embeddings retrieved during RAG queries for the current conversation. + * + * Features: + * - Draggable by header (matches InstitutionInfoPanel pattern) + * - Close button (X) + * - Pin button (keeps panel open during navigation) + * - Minimize button (collapses to header only) + * - Escape key to close + * - Stays within viewport bounds + * - Highlights RAG source embeddings + * + * Based on InstitutionInfoPanel.tsx drag pattern. + */ + +import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; +import { + X, + Minimize2, + Maximize2, + Lock, + Unlock, + Layers, + Loader2, + Info, +} from 'lucide-react'; +import { EmbeddingProjector, type EmbeddingPoint } from '../database/EmbeddingProjector'; +import { isTargetInsideAny } from '../../utils/dom'; +import './ConversationEmbeddingPanel.css'; + +// Panel dimensions - larger default for better readability +const PANEL_WIDTH = 700; +const PANEL_HEIGHT = 550; +const PANEL_HEIGHT_MINIMIZED = 48; +const PANEL_HEIGHT_SIMPLE = 450; +const PANEL_WIDTH_SIMPLE = 550; +const PANEL_MARGIN = 16; +const MIN_VISIBLE_WIDTH = 100; +const MIN_VISIBLE_HEIGHT = 100; +// Resize constraints +const MIN_WIDTH = 400; +const MIN_HEIGHT = 300; +const MAX_WIDTH = 1200; +const MAX_HEIGHT = 900; + +// Resize direction type +type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | null; + +interface Position { + x: number; + y: number; +} + +interface Size { + width: number; + height: number; +} + +export interface ConversationEmbeddingPanelProps { + /** Embedding points to display */ + points: EmbeddingPoint[]; + /** Whether embeddings are currently loading */ + isLoading?: boolean; + /** Indices of points to highlight (RAG sources) */ + highlightedIndices?: number[]; + /** Called when user closes the panel */ + onClose: () => void; + /** Panel title */ + title?: string; + /** Translation function - accepts any string key */ + t: (key: string) => string; + /** Current language */ + language: 'nl' | 'en'; + /** Use simple/compact mode */ + simpleMode?: boolean; + /** Whether panel is pinned */ + isPinned?: boolean; + /** Called when pin state changes */ + onTogglePin?: () => void; + /** Called when a context point is selected */ + onContextSelect?: (point: EmbeddingPoint) => void; + /** Initial position (if not specified, defaults to bottom-right) */ + initialPosition?: Position; +} + +/** + * Draggable embedding projector panel for conversation page. + */ +const ConversationEmbeddingPanelComponent: React.FC = ({ + points, + isLoading = false, + highlightedIndices = [], + onClose, + title, + t, + language, + simpleMode = false, + isPinned = false, + onTogglePin, + onContextSelect, + initialPosition, +}) => { + const panelRef = useRef(null); + + // Draggable state + const [position, setPosition] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null); + + // Resize state + const [size, setSize] = useState(null); + const [isResizing, setIsResizing] = useState(false); + const [resizeDirection, setResizeDirection] = useState(null); + const resizeStartRef = useRef<{ + x: number; + y: number; + width: number; + height: number; + posX: number; + posY: number; + } | null>(null); + + // Minimize state + const [isMinimized, setIsMinimized] = useState(false); + + // Track if user has manually positioned this panel + const hasUserPositioned = useRef(false); + + // Calculate default panel dimensions based on mode + const defaultPanelWidth = simpleMode ? PANEL_WIDTH_SIMPLE : PANEL_WIDTH; + const defaultPanelHeight = simpleMode ? PANEL_HEIGHT_SIMPLE : PANEL_HEIGHT; + + // Use user-resized dimensions or defaults + const panelWidth = size?.width ?? defaultPanelWidth; + const panelHeight = isMinimized ? PANEL_HEIGHT_MINIMIZED : (size?.height ?? defaultPanelHeight); + + // Calculate initial position (bottom-right by default) and size + useEffect(() => { + // If position already exists (from dragging or previous render), keep it + if (position && hasUserPositioned.current) return; + + // If we already have a position, don't recalculate + if (position) return; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Set initial size if not set + if (!size) { + setSize({ width: defaultPanelWidth, height: defaultPanelHeight }); + } + + let x: number; + let y: number; + + if (initialPosition) { + x = initialPosition.x; + y = initialPosition.y; + } else { + // Default to bottom-right corner + x = viewportWidth - defaultPanelWidth - PANEL_MARGIN; + y = viewportHeight - defaultPanelHeight - PANEL_MARGIN; + } + + // Clamp to viewport bounds + x = Math.max(PANEL_MARGIN, Math.min(x, viewportWidth - defaultPanelWidth - PANEL_MARGIN)); + y = Math.max(PANEL_MARGIN, Math.min(y, viewportHeight - defaultPanelHeight - PANEL_MARGIN)); + + setPosition({ x, y }); + }, [initialPosition, position, defaultPanelWidth, defaultPanelHeight, size]); + + // Drag handlers + const handleMouseDown = useCallback((e: React.MouseEvent) => { + // Don't start drag if clicking on buttons or links + if (isTargetInsideAny(e.target, ['button', 'a', 'input', 'select'])) { + return; + } + + e.preventDefault(); + setIsDragging(true); + + if (panelRef.current && position) { + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + posX: position.x, + posY: position.y, + }; + } + }, [position]); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDragging || !dragStartRef.current) return; + + const deltaX = e.clientX - dragStartRef.current.x; + const deltaY = e.clientY - dragStartRef.current.y; + + const newX = dragStartRef.current.posX + deltaX; + const newY = dragStartRef.current.posY + deltaY; + + // Clamp to viewport bounds (allow some overflow for edge docking) + const maxX = window.innerWidth - MIN_VISIBLE_WIDTH; + const maxY = window.innerHeight - MIN_VISIBLE_HEIGHT; + + setPosition({ + x: Math.max(-panelWidth + MIN_VISIBLE_WIDTH, Math.min(newX, maxX)), + y: Math.max(0, Math.min(newY, maxY)), + }); + }, [isDragging, panelWidth]); + + const handleMouseUp = useCallback(() => { + // Mark that user has positioned this panel + if (isDragging) { + hasUserPositioned.current = true; + } + setIsDragging(false); + dragStartRef.current = null; + }, [isDragging]); + + // Global mouse event listeners for dragging + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + } else { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + // Resize handlers + const handleResizeMouseDown = useCallback((direction: ResizeDirection) => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeDirection(direction); + + if (position && size) { + resizeStartRef.current = { + x: e.clientX, + y: e.clientY, + width: size.width, + height: size.height, + posX: position.x, + posY: position.y, + }; + } + }, [position, size]); + + const handleResizeMouseMove = useCallback((e: MouseEvent) => { + if (!isResizing || !resizeStartRef.current || !resizeDirection) return; + + const deltaX = e.clientX - resizeStartRef.current.x; + const deltaY = e.clientY - resizeStartRef.current.y; + + let newWidth = resizeStartRef.current.width; + let newHeight = resizeStartRef.current.height; + let newX = resizeStartRef.current.posX; + let newY = resizeStartRef.current.posY; + + // Handle horizontal resize + if (resizeDirection.includes('e')) { + newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, resizeStartRef.current.width + deltaX)); + } + if (resizeDirection.includes('w')) { + const potentialWidth = resizeStartRef.current.width - deltaX; + if (potentialWidth >= MIN_WIDTH && potentialWidth <= MAX_WIDTH) { + newWidth = potentialWidth; + newX = resizeStartRef.current.posX + deltaX; + } + } + + // Handle vertical resize + if (resizeDirection.includes('s')) { + newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, resizeStartRef.current.height + deltaY)); + } + if (resizeDirection.includes('n')) { + const potentialHeight = resizeStartRef.current.height - deltaY; + if (potentialHeight >= MIN_HEIGHT && potentialHeight <= MAX_HEIGHT) { + newHeight = potentialHeight; + newY = resizeStartRef.current.posY + deltaY; + } + } + + setSize({ width: newWidth, height: newHeight }); + + // Update position if resizing from north or west + if (resizeDirection.includes('w') || resizeDirection.includes('n')) { + setPosition({ x: newX, y: newY }); + } + }, [isResizing, resizeDirection]); + + const handleResizeMouseUp = useCallback(() => { + setIsResizing(false); + setResizeDirection(null); + resizeStartRef.current = null; + }, []); + + // Global mouse event listeners for resizing + useEffect(() => { + if (isResizing) { + window.addEventListener('mousemove', handleResizeMouseMove); + window.addEventListener('mouseup', handleResizeMouseUp); + document.body.style.userSelect = 'none'; + + // Set cursor based on direction + const cursorMap: Record = { + n: 'ns-resize', + s: 'ns-resize', + e: 'ew-resize', + w: 'ew-resize', + ne: 'nesw-resize', + sw: 'nesw-resize', + nw: 'nwse-resize', + se: 'nwse-resize', + }; + document.body.style.cursor = resizeDirection ? cursorMap[resizeDirection] : ''; + } + + return () => { + window.removeEventListener('mousemove', handleResizeMouseMove); + window.removeEventListener('mouseup', handleResizeMouseUp); + if (!isDragging) { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }; + }, [isResizing, resizeDirection, handleResizeMouseMove, handleResizeMouseUp, isDragging]); + + // Close on Escape key (only if not pinned) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPinned) { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, isPinned]); + + // Toggle minimize + const handleToggleMinimize = useCallback(() => { + setIsMinimized(prev => !prev); + }, []); + + // Don't render if no position yet + if (!position) { + return null; + } + + const displayTitle = title || t('embeddingProjector'); + + return ( +
+ {/* Resize handles (hidden when minimized) */} + {!isMinimized && ( + <> +
+
+
+
+
+
+
+
+ + )} + + {/* Draggable Header */} +
+

+ + {displayTitle} +

+ +
+ {/* Sources badge */} + {highlightedIndices.length > 0 && ( + + {highlightedIndices.length} {t('sourcesUsed')} + + )} + + {/* Pin button */} + {onTogglePin && ( + + )} + + {/* Minimize button */} + + + {/* Close button */} + +
+
+ + {/* Content (hidden when minimized) */} + {!isMinimized && ( +
+ {isLoading ? ( +
+ + {t('loadingEmbeddings')} +
+ ) : points.length > 0 ? ( + + ) : ( +
+ +

{language === 'nl' ? 'Geen embeddings beschikbaar' : 'No embeddings available'}

+
+ )} +
+ )} +
+ ); +}; + +// Memoize to prevent unnecessary re-renders +export const ConversationEmbeddingPanel = memo(ConversationEmbeddingPanelComponent); + +export default ConversationEmbeddingPanel; diff --git a/frontend/src/components/conversation/index.ts b/frontend/src/components/conversation/index.ts index 2693373fda..3d6771233b 100644 --- a/frontend/src/components/conversation/index.ts +++ b/frontend/src/components/conversation/index.ts @@ -24,3 +24,7 @@ export type { ConversationNetworkGraphProps } from './ConversationNetworkGraph'; export { ConversationSocialNetworkGraph, convertRetrievedResultsToGraph } from './ConversationSocialNetworkGraph'; export type { ConversationSocialNetworkGraphProps } from './ConversationSocialNetworkGraph'; + +// Draggable Embedding Projector panel +export { ConversationEmbeddingPanel } from './ConversationEmbeddingPanel'; +export type { ConversationEmbeddingPanelProps } from './ConversationEmbeddingPanel';