import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Copy, Download, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { useI18n } from '../../i18n' const LIMIT_OPTIONS = [0, 10, 20, 50] const DISABLED_LIMIT = 0 const MESSAGE_COLLAPSE_AT = 700 const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode' const BEGIN_SENTENCE_MARKER = '<|begin▁of▁sentence|>' const SYSTEM_MARKER = '<|System|>' const USER_MARKER = '<|User|>' const ASSISTANT_MARKER = '<|Assistant|>' const TOOL_MARKER = '<|Tool|>' const END_INSTRUCTIONS_MARKER = '<|end▁of▁instructions|>' const END_SENTENCE_MARKER = '<|end▁of▁sentence|>' const END_TOOL_RESULTS_MARKER = '<|end▁of▁toolresults|>' const CURRENT_INPUT_FILE_PROMPT = 'The current request and prior conversation context have already been provided. Answer the latest user request directly.' function formatDateTime(value, lang) { if (!value) return '-' try { return new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }).format(new Date(value)) } catch { return '-' } } function formatElapsed(ms, t) { if (!ms) return t('chatHistory.metaUnknown') if (ms < 1000) return `${ms}ms` return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s` } function previewText(item) { return item?.preview || item?.content || item?.reasoning_content || item?.error || item?.user_input || '' } function statusTone(status) { switch (status) { case 'success': return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600' case 'error': return 'border-destructive/20 bg-destructive/10 text-destructive' case 'stopped': return 'border-amber-500/20 bg-amber-500/10 text-amber-600' default: return 'border-border bg-secondary/60 text-muted-foreground' } } function ExpandableText({ text = '', threshold = MESSAGE_COLLAPSE_AT, expandLabel, collapseLabel, buttonClassName = 'text-white hover:text-white/80' }) { const shouldCollapse = text.length > threshold const [expanded, setExpanded] = useState(false) const contentRef = useRef(null) const [maxHeight, setMaxHeight] = useState('none') useEffect(() => { setExpanded(false) }, [text]) const visibleText = shouldCollapse && !expanded ? `${text.slice(0, threshold)}...` : text useEffect(() => { if (!contentRef.current) return setMaxHeight(`${contentRef.current.scrollHeight}px`) }, [expanded, visibleText]) return (
{visibleText}
{shouldCollapse && ( )}
) } function ListModeIcon() { return ( ) } function MergeModeIcon() { return ( ) } function downloadTextFile(filename, text) { const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) } function fallbackCopyText(text) { const textArea = document.createElement('textarea') textArea.value = text textArea.setAttribute('readonly', '') textArea.style.position = 'fixed' textArea.style.top = '-9999px' textArea.style.left = '-9999px' document.body.appendChild(textArea) textArea.focus() textArea.select() let copied = false try { copied = document.execCommand('copy') } finally { document.body.removeChild(textArea) } if (!copied) { throw new Error('copy failed') } } async function copyTextWithFallback(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text) return } } catch { // Fall through to execCommand fallback. } fallbackCopyText(text) } function skipWhitespace(text, start) { let cursor = start while (cursor < text.length && /\s/.test(text[cursor])) { cursor += 1 } return cursor } function parseStrictHistoryMessages(historyText) { const rawText = String(historyText || '') const beginIndex = rawText.indexOf(BEGIN_SENTENCE_MARKER) if (beginIndex < 0) return null const transcript = rawText.slice(beginIndex) let cursor = BEGIN_SENTENCE_MARKER.length const parsed = [] let expectedRole = null let trailingAssistantPromptOnly = false while (cursor < transcript.length) { if (expectedRole === null) { if (transcript.startsWith(SYSTEM_MARKER, cursor)) { expectedRole = 'system' } else if (transcript.startsWith(USER_MARKER, cursor)) { expectedRole = 'user' } else if (transcript.startsWith(ASSISTANT_MARKER, cursor)) { expectedRole = 'assistant' } else if (transcript.slice(cursor).trim() === '') { break } else { return null } } if (transcript.startsWith(SYSTEM_MARKER, cursor)) { if (expectedRole !== 'system') return null cursor += SYSTEM_MARKER.length const nextInstructionsEnd = transcript.indexOf(END_INSTRUCTIONS_MARKER, cursor) if (nextInstructionsEnd < 0) return null parsed.push({ role: 'system', content: transcript.slice(cursor, nextInstructionsEnd), }) cursor = nextInstructionsEnd + END_INSTRUCTIONS_MARKER.length expectedRole = 'user' continue } if (transcript.startsWith(USER_MARKER, cursor)) { if (expectedRole !== 'user' && expectedRole !== 'user_or_tool' && expectedRole !== 'assistant_or_user') return null cursor += USER_MARKER.length const nextAssistant = transcript.indexOf(ASSISTANT_MARKER, cursor) const nextTool = transcript.indexOf(TOOL_MARKER, cursor) const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor) let nextRoleIndex = nextAssistant if (nextRoleIndex < 0 || (nextTool >= 0 && nextTool < nextRoleIndex)) { nextRoleIndex = nextTool } if (nextRoleIndex < 0) return null if (nextSentenceEnd >= 0 && nextSentenceEnd < nextRoleIndex) { const assistantStart = skipWhitespace(transcript, nextSentenceEnd + END_SENTENCE_MARKER.length) if (!transcript.startsWith(ASSISTANT_MARKER, assistantStart)) return null parsed.push({ role: 'user', content: transcript.slice(cursor, nextSentenceEnd), }) cursor = assistantStart expectedRole = 'assistant' continue } parsed.push({ role: 'user', content: transcript.slice(cursor, nextRoleIndex), }) if (transcript.startsWith(TOOL_MARKER, nextRoleIndex)) { cursor = nextRoleIndex expectedRole = 'tool' continue } const assistantStart = nextRoleIndex + ASSISTANT_MARKER.length if (transcript.indexOf(END_SENTENCE_MARKER, assistantStart) < 0) { trailingAssistantPromptOnly = true cursor = assistantStart break } cursor = nextRoleIndex expectedRole = 'assistant' continue } if (transcript.startsWith(ASSISTANT_MARKER, cursor)) { if (expectedRole !== 'assistant' && expectedRole !== 'assistant_or_user') return null cursor += ASSISTANT_MARKER.length const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor) if (nextSentenceEnd < 0) return null parsed.push({ role: 'assistant', content: transcript.slice(cursor, nextSentenceEnd), }) cursor = nextSentenceEnd + END_SENTENCE_MARKER.length expectedRole = 'user_or_tool' continue } if (transcript.startsWith(TOOL_MARKER, cursor)) { if (expectedRole !== 'tool' && expectedRole !== 'user' && expectedRole !== 'user_or_tool') return null cursor += TOOL_MARKER.length const nextToolResultsEnd = transcript.indexOf(END_TOOL_RESULTS_MARKER, cursor) if (nextToolResultsEnd < 0) return null parsed.push({ role: 'tool', content: transcript.slice(cursor, nextToolResultsEnd), }) cursor = nextToolResultsEnd + END_TOOL_RESULTS_MARKER.length expectedRole = 'assistant_or_user' continue } if ( parsed.length && (expectedRole === 'user' || expectedRole === 'user_or_tool' || expectedRole === 'assistant_or_user') ) break if (transcript.slice(cursor).trim() === '') break return null } if (!parsed.length) { return null } if (!trailingAssistantPromptOnly && parsed[parsed.length - 1]?.role !== 'assistant') { return null } return parsed } function buildListModeMessages(item, t) { const liveMessages = Array.isArray(item?.messages) && item.messages.length > 0 ? item.messages : [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }] const historyMessages = parseStrictHistoryMessages(item?.history_text) if (!historyMessages?.length) { return { messages: liveMessages, historyMerged: false } } const placeholderOnly = liveMessages.length === 1 && String(liveMessages[0]?.role || '').trim().toLowerCase() === 'user' && String(liveMessages[0]?.content || '').trim() === CURRENT_INPUT_FILE_PROMPT if (placeholderOnly) { return { messages: historyMessages, historyMerged: true } } const insertAt = liveMessages.findIndex(message => { const role = String(message?.role || '').trim().toLowerCase() return role !== 'system' && role !== 'developer' }) const mergedMessages = [...liveMessages] mergedMessages.splice(insertAt < 0 ? mergedMessages.length : insertAt, 0, ...historyMessages) return { messages: mergedMessages, historyMerged: true } } function RequestMessages({ item, t, messages }) { const requestMessages = Array.isArray(messages) && messages.length > 0 ? messages : [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }] return (
{requestMessages.map((message, index) => { const role = message.role || 'user' const isUser = role === 'user' const isAssistant = role === 'assistant' const isTool = role === 'tool' const label = isUser ? t('chatHistory.role.user') : (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system'))) return (
{isUser ? : }
{label}
{message.content || t('chatHistory.emptyUserInput')}
) })}
) } function MergedPromptView({ item, t, onMessage }) { const merged = item?.final_prompt || '' const mergedFilename = `Merged_${item?.id || 'prompt'}.txt` const handleCopy = async () => { try { await copyTextWithFallback(merged) onMessage?.('success', t('chatHistory.copySuccess')) } catch { onMessage?.('error', t('chatHistory.copyFailed')) } } const handleDownload = () => { try { downloadTextFile(mergedFilename, merged) onMessage?.('success', t('chatHistory.downloadSuccess')) } catch { onMessage?.('error', t('chatHistory.downloadFailed')) } } return (
{t('chatHistory.mergedInput')}
) } function HistoryTextView({ item, t, onMessage }) { const historyText = (item?.history_text || '').trim() if (!historyText) return null const historyFilename = `History_${item?.id || 'history'}.txt` const handleCopy = async () => { try { await copyTextWithFallback(historyText) onMessage?.('success', t('chatHistory.copySuccess')) } catch { onMessage?.('error', t('chatHistory.copyFailed')) } } const handleDownload = () => { try { downloadTextFile(historyFilename, historyText) onMessage?.('success', t('chatHistory.downloadSuccess')) } catch { onMessage?.('error', t('chatHistory.downloadFailed')) } } return (
HISTORY
) } function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) { if (!selectedItem) return null const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null const showHistoryAtTop = viewMode !== 'list' || !listModeState?.historyMerged return ( <> {showHistoryAtTop && } {viewMode === 'list' ? : }
{(selectedItem.reasoning_content || '').trim() && (
{t('chatHistory.reasoningTrace')}
{selectedItem.reasoning_content}
)}
{selectedItem.status === 'error' ? {selectedItem.error || t('chatHistory.failedOutput')} : (selectedItem.content || t('chatHistory.emptyAssistantOutput'))}
{t('chatHistory.metaTitle')}
{t('chatHistory.metaAccount')}
{selectedItem.account_id || t('chatHistory.metaUnknown')}
{t('chatHistory.metaElapsed')}
{formatElapsed(selectedItem.elapsed_ms, t)}
{t('chatHistory.metaModel')}
{selectedItem.model || t('chatHistory.metaUnknown')}
{t('chatHistory.metaStatusCode')}
{selectedItem.status_code || '-'}
{t('chatHistory.metaStream')}
{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}
{t('chatHistory.metaCaller')}
{selectedItem.caller_id || t('chatHistory.metaUnknown')}
) } 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 }) }, 5000) return () => window.clearInterval(timer) }, [autoRefreshReady, limit]) useEffect(() => { if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined const timer = window.setInterval(() => { loadDetail(selectedId, { announceError: false }) }, 1000) 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}
)}
{t('chatHistory.listTitle')}
{items.length}
{!items.length && (
{t('chatHistory.emptyTitle')}
{t('chatHistory.emptyDesc')}
)} {items.map(item => (
{previewText(item) || t('chatHistory.noPreview')}
{formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)}
))}
{t('chatHistory.detailTitle')}
{selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')}
{selectedSummary && ( {t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)} )}
{!selectedItem && (
{t('chatHistory.selectPrompt')}
)} {selectedItem && ( )}
{isMobileView && mobileDetailOpen && selectedItem && (
event.stopPropagation()} className={clsx( 'w-full h-full rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col transition-transform duration-200 ease-out', mobileDetailVisible ? 'scale-100' : 'scale-90' )} style={{ transformOrigin: `${mobileOrigin.x}% ${mobileOrigin.y}%` }} >
{t('chatHistory.detailTitle')}
{formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)}
)} {confirmClearOpen && (
{t('chatHistory.confirmClearTitle')}
{t('chatHistory.confirmClearDesc')}
)} ) }