import { Loader2, RefreshCcw, Trash2 } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { useI18n } from '../../i18n' import { ChatHistoryListPane, ConfirmClearDialog, DesktopDetailPane, MobileDetailModal } from './ChatHistoryPanels' import { DISABLED_LIMIT, LIMIT_OPTIONS, VIEW_MODE_KEY, } from './chatHistoryUtils' const LIST_REFRESH_MS = 1500 const STREAMING_DETAIL_REFRESH_MS = 750 export default function ChatHistoryContainer({ authFetch, onMessage }) { const { t, lang } = useI18n() const apiFetch = authFetch || fetch const [items, setItems] = useState([]) const [limit, setLimit] = useState(20) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [selectedId, setSelectedId] = useState('') const [selectedDetail, setSelectedDetail] = useState(null) const [savingLimit, setSavingLimit] = useState(false) const [clearing, setClearing] = useState(false) const [deletingId, setDeletingId] = useState('') const [detail, setDetail] = useState('') const [confirmClearOpen, setConfirmClearOpen] = useState(false) const [autoRefreshReady, setAutoRefreshReady] = useState(false) const [viewMode, setViewMode] = useState(() => { if (typeof localStorage === 'undefined') return 'list' const stored = localStorage.getItem(VIEW_MODE_KEY) return stored === 'merged' ? 'merged' : 'list' }) const [isMobileView, setIsMobileView] = useState(() => typeof window !== 'undefined' ? window.innerWidth < 1024 : false) const [mobileDetailOpen, setMobileDetailOpen] = useState(false) const [mobileDetailVisible, setMobileDetailVisible] = useState(false) const [mobileOrigin, setMobileOrigin] = useState({ x: 50, y: 50 }) const [pendingJumpToAssistant, setPendingJumpToAssistant] = useState(false) const inFlightRef = useRef(false) const detailInFlightRef = useRef(false) const listETagRef = useRef('') const detailETagRef = useRef('') const assistantStartRef = useRef(null) const detailScrollRef = useRef(null) const mobileCloseTimerRef = useRef(null) const selectedSummary = items.find(item => item.id === selectedId) || items[0] || null const selectedItem = selectedDetail && selectedDetail.id === selectedId ? selectedDetail : null const syncItems = (nextItems) => { setItems(nextItems) setSelectedId(prev => { if (!nextItems.length) return '' if (prev && nextItems.some(item => item.id === prev)) return prev return nextItems[0].id }) } const loadList = async ({ mode = 'silent', announceError = false } = {}) => { if (inFlightRef.current) return inFlightRef.current = true if (mode === 'manual') { setRefreshing(true) } else if (mode === 'initial') { setLoading(true) } if (announceError) { setDetail('') } try { const headers = {} if (listETagRef.current) { headers['If-None-Match'] = listETagRef.current } const res = await apiFetch('/admin/chat-history', { headers }) if (res.status === 304) { return } const data = await res.json() if (!res.ok) { throw new Error(data?.detail || t('chatHistory.loadFailed')) } listETagRef.current = res.headers.get('ETag') || '' setLimit(typeof data.limit === 'number' ? data.limit : 20) syncItems(Array.isArray(data.items) ? data.items : []) } catch (error) { setDetail(error.message || t('chatHistory.loadFailed')) if (announceError) { onMessage?.('error', error.message || t('chatHistory.loadFailed')) } } finally { if (mode === 'initial') { setLoading(false) } if (mode === 'manual') { setRefreshing(false) } inFlightRef.current = false } } const loadDetail = async (id, { announceError = false } = {}) => { if (!id || detailInFlightRef.current) return detailInFlightRef.current = true try { const headers = {} if (detailETagRef.current) { headers['If-None-Match'] = detailETagRef.current } const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { headers }) if (res.status === 304) { return } const data = await res.json() if (!res.ok) { throw new Error(data?.detail || t('chatHistory.loadFailed')) } detailETagRef.current = res.headers.get('ETag') || '' setSelectedDetail(data.item || null) } catch (error) { if (announceError) { onMessage?.('error', error.message || t('chatHistory.loadFailed')) } } finally { detailInFlightRef.current = false } } useEffect(() => { loadList({ mode: 'initial', announceError: true }).finally(() => { setAutoRefreshReady(true) }) }, []) useEffect(() => { if (!autoRefreshReady || limit === DISABLED_LIMIT) return undefined const timer = window.setInterval(() => { loadList({ mode: 'silent', announceError: false }) }, LIST_REFRESH_MS) return () => window.clearInterval(timer) }, [autoRefreshReady, limit]) useEffect(() => { if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined const timer = window.setInterval(() => { loadDetail(selectedId, { announceError: false }) }, STREAMING_DETAIL_REFRESH_MS) return () => window.clearInterval(timer) }, [autoRefreshReady, selectedId, selectedSummary?.status]) useEffect(() => { if (!selectedId) return undefined detailETagRef.current = '' setSelectedDetail(null) loadDetail(selectedId, { announceError: false }) }, [selectedId, mobileDetailOpen]) useEffect(() => { if (!pendingJumpToAssistant || !selectedItem || selectedItem.id !== selectedId) return undefined const frame = window.requestAnimationFrame(() => { assistantStartRef.current?.scrollIntoView({ behavior: 'auto', block: 'start' }) setPendingJumpToAssistant(false) }) return () => window.cancelAnimationFrame(frame) }, [pendingJumpToAssistant, selectedId, selectedItem?.id, selectedItem?.revision, mobileDetailOpen, viewMode]) useEffect(() => { if (typeof localStorage === 'undefined') return localStorage.setItem(VIEW_MODE_KEY, viewMode) }, [viewMode]) useEffect(() => { if (typeof window === 'undefined') return undefined const handleResize = () => setIsMobileView(window.innerWidth < 1024) handleResize() window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) useEffect(() => { if (!isMobileView) { setMobileDetailOpen(false) setMobileDetailVisible(false) } }, [isMobileView]) useEffect(() => { return () => { if (mobileCloseTimerRef.current) { window.clearTimeout(mobileCloseTimerRef.current) } } }, []) const handleRefresh = async ({ manual = true } = {}) => { await loadList({ mode: manual ? 'manual' : 'silent', announceError: manual }) if (selectedId) { detailETagRef.current = '' await loadDetail(selectedId, { announceError: manual }) } } const handleLimitChange = async (nextLimit) => { if (nextLimit === limit || savingLimit) return setSavingLimit(true) try { const res = await apiFetch('/admin/chat-history/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ limit: nextLimit }), }) const data = await res.json() if (!res.ok) { throw new Error(data?.detail || t('chatHistory.updateLimitFailed')) } const resolvedLimit = typeof data.limit === 'number' ? data.limit : nextLimit setLimit(resolvedLimit) listETagRef.current = '' syncItems(Array.isArray(data.items) ? data.items : []) onMessage?.( 'success', resolvedLimit === DISABLED_LIMIT ? t('chatHistory.disabledSuccess') : t('chatHistory.limitUpdated', { limit: resolvedLimit }) ) } catch (error) { onMessage?.('error', error.message || t('chatHistory.updateLimitFailed')) } finally { setSavingLimit(false) } } const handleDeleteItem = async (id) => { if (!id || deletingId) return setDeletingId(id) try { const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { method: 'DELETE' }) const data = await res.json() if (!res.ok) { throw new Error(data?.detail || t('chatHistory.deleteFailed')) } if (selectedId === id) { detailETagRef.current = '' setSelectedDetail(null) } syncItems(items.filter(item => item.id !== id)) onMessage?.('success', t('chatHistory.deleteSuccess')) } catch (error) { onMessage?.('error', error.message || t('chatHistory.deleteFailed')) } finally { setDeletingId('') } } const handleClear = async () => { if (clearing || !items.length) return setClearing(true) try { const res = await apiFetch('/admin/chat-history', { method: 'DELETE' }) const data = await res.json() if (!res.ok) { throw new Error(data?.detail || t('chatHistory.clearFailed')) } listETagRef.current = '' detailETagRef.current = '' setSelectedDetail(null) syncItems([]) onMessage?.('success', t('chatHistory.clearSuccess')) } catch (error) { onMessage?.('error', error.message || t('chatHistory.clearFailed')) } finally { setClearing(false) } } const openMobileDetail = (itemId, event) => { const x = typeof window !== 'undefined' && event?.clientX ? (event.clientX / window.innerWidth) * 100 : 50 const y = typeof window !== 'undefined' && event?.clientY ? (event.clientY / window.innerHeight) * 100 : 50 setMobileOrigin({ x, y }) setPendingJumpToAssistant(true) setSelectedId(itemId) setMobileDetailOpen(true) setMobileDetailVisible(false) window.requestAnimationFrame(() => { window.requestAnimationFrame(() => setMobileDetailVisible(true)) }) } const closeMobileDetail = () => { setMobileDetailVisible(false) if (mobileCloseTimerRef.current) { window.clearTimeout(mobileCloseTimerRef.current) } mobileCloseTimerRef.current = window.setTimeout(() => { setMobileDetailOpen(false) }, 180) } const handleSelectItem = (itemId, event) => { if (isMobileView) { openMobileDetail(itemId, event) return } if (itemId === selectedId) { detailETagRef.current = '' setSelectedDetail(null) loadDetail(itemId, { announceError: false }) return } setPendingJumpToAssistant(true) setSelectedId(itemId) } if (loading) { return (
{t('chatHistory.loading')}
) } return (
{t('chatHistory.retentionTitle')}
{t('chatHistory.retentionDesc')}
{LIMIT_OPTIONS.map(option => ( ))}
{detail && (
{detail}
)}
setConfirmClearOpen(false)} onConfirm={async () => { setConfirmClearOpen(false) await handleClear() }} />
) }