'use client' import { useState, useEffect, useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { isAxiosError } from 'axios' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { sourcesApi } from '@/lib/api/sources' import { insightsApi, SourceInsightResponse } from '@/lib/api/insights' import { transformationsApi } from '@/lib/api/transformations' import { embeddingApi } from '@/lib/api/embedding' import { SourceDetailResponse } from '@/lib/types/api' import { Transformation } from '@/lib/types/transformations' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { InlineEdit } from '@/components/common/InlineEdit' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Label } from '@/components/ui/label' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Link as LinkIcon, Upload, AlignLeft, ExternalLink, Download, Copy, CheckCircle, Youtube, MoreVertical, Trash2, Sparkles, Plus, Lightbulb, Database, AlertCircle, MessageSquare, } from 'lucide-react' import { formatDistanceToNow } from 'date-fns' import { getDateLocale } from '@/lib/utils/date-locale' import { toast } from 'sonner' import { useTranslation } from '@/lib/hooks/use-translation' import { SourceInsightDialog } from '@/components/source/SourceInsightDialog' import { NotebookAssociations } from '@/components/source/NotebookAssociations' interface SourceDetailContentProps { sourceId: string showChatButton?: boolean onChatClick?: () => void onClose?: () => void } export function SourceDetailContent({ sourceId, showChatButton = false, onChatClick, onClose }: SourceDetailContentProps) { const { t, language } = useTranslation() const queryClient = useQueryClient() const [source, setSource] = useState(null) const [insights, setInsights] = useState([]) const [transformations, setTransformations] = useState([]) const [selectedTransformation, setSelectedTransformation] = useState('') const [loading, setLoading] = useState(true) const [loadingInsights, setLoadingInsights] = useState(false) const [creatingInsight, setCreatingInsight] = useState(false) const [error, setError] = useState(null) const [copied, setCopied] = useState(false) const [isEmbedding, setIsEmbedding] = useState(false) const [isDownloadingFile, setIsDownloadingFile] = useState(false) const [fileAvailable, setFileAvailable] = useState(null) const [selectedInsight, setSelectedInsight] = useState(null) const [insightToDelete, setInsightToDelete] = useState(null) const [deletingInsight, setDeletingInsight] = useState(false) const fetchSource = useCallback(async () => { try { setLoading(true) const data = await sourcesApi.get(sourceId) setSource(data) if (typeof data.file_available === 'boolean') { setFileAvailable(data.file_available) } else if (!data.asset?.file_path) { setFileAvailable(null) } else { setFileAvailable(null) } } catch (err) { console.error('Failed to fetch source:', err) setError(t('sources.loadFailed')) } finally { setLoading(false) } }, [sourceId, t]) const fetchInsights = useCallback(async () => { try { setLoadingInsights(true) const data = await insightsApi.listForSource(sourceId) setInsights(data) } catch (err) { console.error('Failed to fetch insights:', err) } finally { setLoadingInsights(false) } }, [sourceId]) const fetchTransformations = useCallback(async () => { try { const data = await transformationsApi.list() setTransformations(data) } catch (err) { console.error('Failed to fetch transformations:', err) } }, []) useEffect(() => { if (sourceId) { void fetchSource() void fetchInsights() void fetchTransformations() } }, [fetchInsights, fetchSource, fetchTransformations, sourceId]) const createInsight = async () => { if (!selectedTransformation) { toast.error(t('sources.selectTransformation')) return } try { setCreatingInsight(true) const response = await insightsApi.create(sourceId, { transformation_id: selectedTransformation }) // Show toast for async operation toast.success(t('sources.insightGenerationStarted')) setSelectedTransformation('') // Poll for command completion if we have a command_id if (response.command_id) { // Poll in background (don't block UI) insightsApi.waitForCommand(response.command_id, { maxAttempts: 120, // Up to 4 minutes (120 * 2s) intervalMs: 2000 }).then(success => { if (success) { void fetchInsights() // Invalidate sources queries so notebook page refreshes with updated insights_count queryClient.invalidateQueries({ queryKey: ['sources'] }) } }).catch(err => { console.error('Error waiting for insight command:', err) }) } else { // Fallback: refresh after delay if no command_id setTimeout(() => { void fetchInsights() // Also invalidate sources queries queryClient.invalidateQueries({ queryKey: ['sources'] }) }, 5000) } } catch (err) { console.error('Failed to create insight:', err) toast.error(t('common.error')) } finally { setCreatingInsight(false) } } const handleDeleteInsight = async (e?: React.MouseEvent) => { e?.preventDefault() if (!insightToDelete) return try { setDeletingInsight(true) await insightsApi.delete(insightToDelete) toast.success(t('common.success')) setInsightToDelete(null) await fetchInsights() } catch (err) { console.error('Failed to delete insight:', err) toast.error(t('common.error')) } finally { setDeletingInsight(false) } } const handleUpdateTitle = async (title: string) => { if (!source || title === source.title) return try { await sourcesApi.update(sourceId, { title }) toast.success(t('common.success')) setSource({ ...source, title }) } catch (err) { console.error('Failed to update source title:', err) toast.error(t('common.error')) await fetchSource() } } const handleEmbedContent = async () => { if (!source) return try { setIsEmbedding(true) const response = await embeddingApi.embedContent(sourceId, 'source') toast.success(response.message || t('common.success')) await fetchSource() } catch (err) { console.error('Failed to embed content:', err) toast.error(t('common.error')) } finally { setIsEmbedding(false) } } const extractFilename = (pathOrUrl: string | undefined, fallback: string) => { if (!pathOrUrl) { return fallback } const segments = pathOrUrl.split(/[/\\]/) return segments.pop() || fallback } const parseContentDisposition = (header?: string | null) => { if (!header) { return null } const match = header.match(/filename\*?=([^;]+)/i) if (!match) { return null } const value = match[1].trim() if (value.toLowerCase().startsWith("utf-8''")) { return decodeURIComponent(value.slice(7)) } return value.replace(/^["']|["']$/g, '') } const handleDownloadFile = async () => { if (!source?.asset?.file_path || isDownloadingFile || fileAvailable === false) { return } try { setIsDownloadingFile(true) const response = await sourcesApi.downloadFile(source.id) const filenameFromHeader = parseContentDisposition( response.headers?.['content-disposition'] as string | undefined ) const fallbackName = extractFilename(source.asset.file_path, `source-${source.id}`) const filename = filenameFromHeader || fallbackName const blobUrl = window.URL.createObjectURL(response.data) const link = document.createElement('a') link.href = blobUrl link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(blobUrl) setFileAvailable(true) toast.success(t('common.success')) } catch (err) { console.error('Failed to download file:', err) if (isAxiosError(err) && err.response?.status === 404) { setFileAvailable(false) toast.error(t('sources.fileUnavailable')) } else { toast.error(t('common.error')) } } finally { setIsDownloadingFile(false) } } const getSourceIcon = () => { if (!source) return null if (source.asset?.url) return if (source.asset?.file_path) return return } const getSourceType = () => { if (!source) return 'unknown' if (source.asset?.url) return 'link' if (source.asset?.file_path) return 'file' return 'text' } const handleCopyUrl = useCallback(() => { if (source?.asset?.url) { navigator.clipboard.writeText(source.asset.url) setCopied(true) toast.success(t('sources.urlCopied')) setTimeout(() => setCopied(false), 2000) } }, [source, t]) const handleOpenExternal = useCallback(() => { if (source?.asset?.url) { window.open(source.asset.url, '_blank') } }, [source]) const getYouTubeVideoId = (url: string): string | null => { const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, /youtube\.com\/watch\?.*v=([^&\n?#]+)/ ] for (const pattern of patterns) { const match = url.match(pattern) if (match) return match[1] } return null } const isYouTubeUrl = useMemo(() => { if (!source?.asset?.url) return false return !!(getYouTubeVideoId(source.asset.url)) }, [source?.asset?.url]) const youTubeVideoId = useMemo(() => { if (!source?.asset?.url) return null return getYouTubeVideoId(source.asset.url) }, [source?.asset?.url]) const handleDelete = async () => { if (!source) return if (confirm(t('sources.deleteSourceConfirm') || t('common.confirm'))) { try { await sourcesApi.delete(source.id) toast.success(t('common.success')) onClose?.() } catch (error) { console.error('Failed to delete source:', error) toast.error(t('common.error')) } } } if (loading) { return (
) } if (error || !source) { return (

{error || t('sources.notFound')}

) } return (
{/* Header */}

{t('sources.id')}: {source.id}

{getSourceIcon()} {getSourceType()} {/* Chat with source button - only in modal */} {showChatButton && onChatClick && ( )} {source.asset?.file_path && ( <> {fileAvailable === false ? t('sources.fileUnavailable') : isDownloadingFile ? t('sources.preparing') : t('sources.downloadFile')} )} {isEmbedding ? t('sources.embedding') : source.embedded ? t('sources.alreadyEmbedded') : t('sources.embedContent')} {t('sources.deleteSource')}
{/* Tabs Content */}
{t('sources.content')} {t('common.insights')} {insights.length > 0 && `(${insights.length})`} {t('sources.details')} {isYouTubeUrl && } {t('sources.content')} {source.asset?.url && !isYouTubeUrl && ( {source.asset.url} )} {isYouTubeUrl && youTubeVideoId && (