/** * TinyPivot React - AI Data Analyst Component * Split-panel layout: 1/4 chat, 3/4 data preview * Each query step shows data visually with expandable SQL */ import type { AIAnalystConfig, AIConversationUpdateEvent, AIDataLoadedEvent, AIErrorEvent, AIMessage, AIQueryExecutedEvent, AITableSchema, ResolvedTheme, } from '@smallwebco/tinypivot-core' import { stripSQLFromContent } from '@smallwebco/tinypivot-core' import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useAIAnalyst } from '../hooks/useAIAnalyst' interface AIAnalystProps { config: AIAnalystConfig theme?: ResolvedTheme onDataLoaded?: (payload: AIDataLoadedEvent) => void onConversationUpdate?: (payload: AIConversationUpdateEvent) => void onQueryExecuted?: (payload: AIQueryExecutedEvent) => void onError?: (payload: AIErrorEvent) => void onViewResults?: (payload: { data: Record[], query: string }) => void } export interface AIAnalystHandle { loadFullData: () => Promise[] | null> selectedDataSource: string | undefined } export const AIAnalyst = forwardRef(({ config, theme = 'light', onDataLoaded, onConversationUpdate, onQueryExecuted, onError, onViewResults, }, ref) => { const { messages, hasMessages, isLoading, isLoadingTables, schemas, selectedDataSource, selectedDataSourceInfo, lastLoadedData, dataSources, selectDataSource, sendMessage, clearConversation, loadFullData, } = useAIAnalyst({ config, onDataLoaded, onConversationUpdate, onQueryExecuted, onError, }) const isDarkTheme = theme === 'dark' || (typeof theme === 'string' && theme.endsWith('-dark')) // Expose methods to parent via ref useImperativeHandle(ref, () => ({ loadFullData, selectedDataSource, }), [loadFullData, selectedDataSource]) const [inputText, setInputText] = useState('') const [searchQuery, setSearchQuery] = useState('') const [selectedMessageId, setSelectedMessageId] = useState(null) const [showSqlPanel, setShowSqlPanel] = useState(false) const messagesContainerRef = useRef(null) const textareaRef = useRef(null) // Filter data sources by search const filteredDataSources = useMemo(() => { if (!searchQuery.trim()) return dataSources const q = searchQuery.toLowerCase() return dataSources.filter((ds: { name: string, description?: string, table: string }) => ds.name.toLowerCase().includes(q) || ds.description?.toLowerCase().includes(q) || ds.table.toLowerCase().includes(q), ) }, [dataSources, searchQuery]) // Get schema for selected data source const currentSchema: AITableSchema | undefined = useMemo(() => { if (!selectedDataSource) return undefined return schemas.get(selectedDataSource) }, [selectedDataSource, schemas]) // Get data for the selected message (or latest) const previewData = useMemo(() => { if (selectedMessageId) { const msg = messages.find((m: AIMessage) => m.id === selectedMessageId) if (msg?.metadata?.data) { return msg.metadata.data.slice(0, 100) } } if (!lastLoadedData) return [] return lastLoadedData.slice(0, 100) }, [selectedMessageId, messages, lastLoadedData]) // Get full data for the selected message const fullPreviewData = useMemo(() => { if (selectedMessageId) { const msg = messages.find((m: AIMessage) => m.id === selectedMessageId) if (msg?.metadata?.data) { return msg.metadata.data } } return lastLoadedData || [] }, [selectedMessageId, messages, lastLoadedData]) // Get column keys from preview data const previewColumns = useMemo(() => { if (previewData.length > 0) { return Object.keys(previewData[0]) } if (currentSchema) { return currentSchema.columns.map((c: { name: string }) => c.name) } return [] }, [previewData, currentSchema]) // Get the selected message's query const selectedQuery = useMemo(() => { if (selectedMessageId) { const msg = messages.find((m: AIMessage) => m.id === selectedMessageId) return msg?.metadata?.query || '' } for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].metadata?.data) { return messages[i].metadata?.query || '' } } return '' }, [selectedMessageId, messages]) // Scroll to bottom when messages change and auto-select latest with data useEffect(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight } const latestWithData = [...messages].reverse().find((m: AIMessage) => m.metadata?.data) if (latestWithData) { setSelectedMessageId(latestWithData.id) } }, [messages]) // Auto-resize textarea const autoResizeTextarea = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto' textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px` } }, []) useEffect(() => { autoResizeTextarea() }, [inputText, autoResizeTextarea]) const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault() if (!inputText.trim() || isLoading) return sendMessage(inputText) setInputText('') if (textareaRef.current) { textareaRef.current.style.height = 'auto' } }, [inputText, isLoading, sendMessage]) const handleKeydown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (!inputText.trim() || isLoading) return sendMessage(inputText) setInputText('') if (textareaRef.current) { textareaRef.current.style.height = 'auto' } } }, [inputText, isLoading, sendMessage]) const handleViewResults = useCallback(() => { if (fullPreviewData.length > 0) { onViewResults?.({ data: fullPreviewData, query: selectedQuery }) } }, [fullPreviewData, selectedQuery, onViewResults]) const selectMessage = useCallback((messageId: string) => { const msg = messages.find((m: AIMessage) => m.id === messageId) if (msg?.metadata?.data) { setSelectedMessageId(messageId) } }, [messages]) const copyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text) }, []) const handleClearConversation = useCallback(() => { clearConversation() setSearchQuery('') setSelectedMessageId(null) setShowSqlPanel(false) }, [clearConversation]) const handleChangeDataSource = useCallback(() => { clearConversation() setSearchQuery('') setSelectedMessageId(null) setShowSqlPanel(false) }, [clearConversation]) const toggleSqlPanel = useCallback(() => { setShowSqlPanel(prev => !prev) }, []) const getColumnTypeIcon = (type: string): string => { const t = type.toLowerCase() if (t.includes('int') || t.includes('float') || t.includes('decimal') || t.includes('number')) return '#' if (t.includes('date') || t.includes('time')) return 'D' if (t.includes('bool')) return '?' return 'T' } const formatCellValue = (value: unknown): string => { if (value === null || value === undefined) return '' if (typeof value === 'number') { if (Math.abs(value) >= 1000) { return value.toLocaleString('en-US', { maximumFractionDigits: 2 }) } return String(value) } return String(value) } const getMessageContent = (message: AIMessage): string => { // Strip SQL blocks and clean up markdown formatting return stripSQLFromContent(message.content) .replace(/\*\*/g, '') .replace(/`([^`]+)`/g, '$1') .trim() } const hasQueryResult = (message: AIMessage): boolean => { return !!message.metadata?.data && message.metadata.data.length > 0 } // Render data source picker (full screen) if (!selectedDataSource) { return (

AI Data Analyst

Select a data source to start exploring with AI

{dataSources.length === 0 && !isLoadingTables ? (

No data sources configured.

View Documentation
) : ( <>
setSearchQuery(e.target.value)} placeholder="Search data sources..." className="vpg-ai-search-input" />
{filteredDataSources.map((ds: { id: string, name: string, description?: string }) => ( ))}
{filteredDataSources.length === 0 && (
No data sources match " {searchQuery} "
)} )}
) } // Render split layout return (
{/* Left Panel: Chat */}
{/* Chat Header */}
{selectedDataSourceInfo?.name}
{hasMessages && ( )}
{/* Messages */}
{/* Welcome message */} {!hasMessages && (

Ask questions about your data

)} {/* Message list */} {messages.map((message: AIMessage) => { if (message.role === 'user') { return (
{message.content}
) } if (hasQueryResult(message)) { return (
selectMessage(message.id)} > {/* Header with result badge and SQL toggle */}
{message.metadata?.rowCount?.toLocaleString()} {' '} rows
{/* SQL toggle button - toggles right pane SQL panel */} {message.metadata?.query && ( )}
{/* Full message content (insight from AI) */}
{getMessageContent(message)}
) } // Regular assistant message return (
{getMessageContent(message)}
{message.metadata?.error && (
Error
)}
) })} {/* Loading indicator */} {isLoading && (
)}
{/* Input Area */}