Files
ds2api/webui/src/features/chatHistory/ChatHistoryContainer.jsx

1233 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div>
<div
className="overflow-hidden transition-[max-height] duration-300 ease-out"
style={{ maxHeight }}
>
<div ref={contentRef} className="whitespace-pre-wrap break-words">
{visibleText}
</div>
</div>
{shouldCollapse && (
<button
type="button"
onClick={() => setExpanded(prev => !prev)}
className={clsx('mt-3 inline-flex items-center gap-2 text-xs font-medium transition-colors', buttonClassName)}
>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform duration-300', expanded && 'rotate-180')} />
{expanded ? collapseLabel : expandLabel}
</button>
)}
</div>
)
}
function ListModeIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 0h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm0 8h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1z" />
</svg>
)
}
function MergeModeIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1" />
</svg>
)
}
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 (
<div className="space-y-5 max-w-4xl mx-auto">
{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 (
<div key={`${role}-${index}`} className={clsx('flex gap-4', isUser && 'flex-row-reverse justify-start')}>
<div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
isUser
? 'bg-secondary'
: (isAssistant ? 'bg-muted' : 'bg-background')
)}>
{isUser
? <UserRound className="w-4 h-4 text-muted-foreground" />
: <Bot className="w-4 h-4 text-foreground" />}
</div>
<div className="max-w-[88%] lg:max-w-[78%] text-left">
<div className={clsx('text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-2 px-1', isUser && 'text-right')}>
{label}
</div>
<div className={clsx(
'rounded-2xl px-5 py-3 text-sm leading-relaxed shadow-sm border whitespace-pre-wrap break-words',
isUser
? 'bg-primary text-primary-foreground rounded-tr-sm border-primary/30'
: (isAssistant
? 'bg-secondary/60 text-foreground rounded-tl-sm border-border'
: 'bg-background text-foreground rounded-tl-sm border-border')
)}>
<div className="whitespace-pre-wrap break-words">
{message.content || t('chatHistory.emptyUserInput')}
</div>
</div>
</div>
</div>
)
})}
</div>
)
}
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 (
<div
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
style={{
backgroundColor: 'rgb(231, 176, 8)',
borderColor: 'rgba(231, 176, 8, 0.45)',
}}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300]">
{t('chatHistory.mergedInput')}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
title={t('chatHistory.copyMerged')}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleDownload}
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
title={t('chatHistory.downloadMerged')}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
<ExpandableText
text={merged || t('chatHistory.emptyMergedPrompt')}
expandLabel={t('chatHistory.expand')}
collapseLabel={t('chatHistory.collapse')}
buttonClassName="text-[#2f2200] hover:text-black"
/>
</div>
</div>
)
}
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 (
<div className="max-w-4xl mx-auto rounded-2xl border border-border bg-background px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground text-left">
HISTORY
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.copyHistory')}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleDownload}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.downloadHistory')}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words font-mono">
<ExpandableText
text={historyText}
threshold={Math.floor(MESSAGE_COLLAPSE_AT / 4)}
expandLabel={t('chatHistory.expand')}
collapseLabel={t('chatHistory.collapse')}
buttonClassName="text-foreground hover:text-muted-foreground"
/>
</div>
</div>
)
}
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 && <HistoryTextView item={selectedItem} t={t} onMessage={onMessage} />}
{viewMode === 'list'
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
: <MergedPromptView item={selectedItem} t={t} onMessage={onMessage} />}
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
<div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
selectedItem.status === 'error' ? 'bg-destructive/10 border-destructive/20' : 'bg-muted'
)}>
<Bot className={clsx('w-4 h-4', selectedItem.status === 'error' ? 'text-destructive' : 'text-foreground')} />
</div>
<div className="space-y-4 flex-1 min-w-0">
{(selectedItem.reasoning_content || '').trim() && (
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Sparkles className="w-3.5 h-3.5" />
<span className="font-medium">{t('chatHistory.reasoningTrace')}</span>
</div>
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[12px] md:text-[13px] max-h-64 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50 break-words">
{selectedItem.reasoning_content}
</div>
</div>
)}
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words">
{selectedItem.status === 'error'
? <span className="text-destructive font-medium">{selectedItem.error || t('chatHistory.failedOutput')}</span>
: (selectedItem.content || t('chatHistory.emptyAssistantOutput'))}
</div>
</div>
</div>
<div className="max-w-4xl mx-auto rounded-xl border border-border bg-background/70 p-4 space-y-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">{t('chatHistory.metaTitle')}</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaAccount')}</div>
<div className="text-sm font-medium text-foreground">{selectedItem.account_id || t('chatHistory.metaUnknown')}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaElapsed')}</div>
<div className="text-sm font-medium text-foreground flex items-center gap-2">
<Clock3 className="w-3.5 h-3.5 text-muted-foreground" />
{formatElapsed(selectedItem.elapsed_ms, t)}
</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaModel')}</div>
<div className="text-sm font-medium text-foreground break-all">{selectedItem.model || t('chatHistory.metaUnknown')}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStatusCode')}</div>
<div className="text-sm font-medium text-foreground">{selectedItem.status_code || '-'}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStream')}</div>
<div className="text-sm font-medium text-foreground">{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaCaller')}</div>
<div className="text-sm font-medium text-foreground break-all">{selectedItem.caller_id || t('chatHistory.metaUnknown')}</div>
</div>
</div>
</div>
<button
type="button"
onClick={() => detailScrollRef.current?.scrollTo({ top: detailScrollRef.current?.scrollHeight || 0, behavior: 'smooth' })}
className={clsx('h-12 w-12 rounded-full border border-border bg-card/95 backdrop-blur shadow-lg text-muted-foreground hover:text-foreground hover:bg-secondary/90 flex items-center justify-center', bottomButtonClassName)}
title={t('chatHistory.backToBottom')}
>
<ArrowDown className="w-5 h-5" />
</button>
</>
)
}
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 (
<div className="h-[calc(100vh-140px)] rounded-2xl border border-border bg-card shadow-sm flex items-center justify-center">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t('chatHistory.loading')}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card shadow-sm p-4 lg:p-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="text-sm font-semibold text-foreground">{t('chatHistory.retentionTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">{t('chatHistory.retentionDesc')}</div>
</div>
<div className="flex flex-wrap gap-2 items-center">
{LIMIT_OPTIONS.map(option => (
<button
key={option}
type="button"
disabled={savingLimit}
onClick={() => handleLimitChange(option)}
className={clsx(
'h-9 px-3 rounded-lg border text-sm transition-colors',
option === limit
? (option === DISABLED_LIMIT
? 'border-destructive bg-destructive text-destructive-foreground'
: 'border-primary bg-primary text-primary-foreground')
: 'border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70'
)}
>
{option === DISABLED_LIMIT ? t('chatHistory.off') : option}
</button>
))}
<button
type="button"
onClick={() => handleRefresh({ manual: true })}
disabled={refreshing}
className={clsx(
'h-9 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center',
isMobileView ? 'w-9 justify-center px-0' : 'gap-2 px-3'
)}
>
{refreshing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCcw className="w-4 h-4" />}
{!isMobileView && t('chatHistory.refresh')}
</button>
<button
type="button"
onClick={() => setConfirmClearOpen(true)}
disabled={clearing || !items.length}
className="h-10 w-10 rounded-xl border border-border bg-[#111214] text-muted-foreground hover:text-destructive hover:bg-[#181a1d] disabled:opacity-50 flex items-center justify-center"
title={t('chatHistory.clearAll')}
>
{clearing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
</div>
{detail && (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 text-destructive px-4 py-3 text-sm">
{detail}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-[340px,minmax(0,1fr)] gap-6 h-[calc(100vh-240px)] min-h-[520px]">
<div className="rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div className="text-sm font-semibold">{t('chatHistory.listTitle')}</div>
<div className="text-xs text-muted-foreground">{items.length}</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{!items.length && (
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex flex-col items-center justify-center gap-2 text-center px-6">
<MessageSquareText className="w-8 h-8 text-muted-foreground/50" />
<div className="text-sm font-medium text-foreground">{t('chatHistory.emptyTitle')}</div>
<div className="text-xs text-muted-foreground leading-6">{t('chatHistory.emptyDesc')}</div>
</div>
)}
{items.map(item => (
<button
key={item.id}
type="button"
onClick={(event) => handleSelectItem(item.id, event)}
className={clsx(
'w-full text-left rounded-xl border px-4 py-3 transition-colors',
selectedItem?.id === item.id
? 'border-primary/40 bg-primary/5'
: 'border-border hover:bg-secondary/40'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{item.user_input || t('chatHistory.untitled')}
</div>
<div className="text-[11px] text-muted-foreground mt-1 truncate">
{item.model || '-'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={clsx('px-2 py-0.5 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(item.status))}>
{t(`chatHistory.status.${item.status || 'streaming'}`)}
</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
handleDeleteItem(item.id)
}}
disabled={deletingId === item.id}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{deletingId === item.id ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<div className="text-xs text-muted-foreground mt-3 line-clamp-2 whitespace-pre-wrap break-words">
{previewText(item) || t('chatHistory.noPreview')}
</div>
<div className="text-[11px] text-muted-foreground/80 mt-3">
{formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)}
</div>
</button>
))}
</div>
</div>
<div className="hidden lg:flex rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex-col relative">
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')}
</div>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
<button
type="button"
onClick={() => setViewMode('list')}
className={clsx(
'h-9 w-12 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'list'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeList')}
>
<ListModeIcon />
</button>
<button
type="button"
onClick={() => setViewMode('merged')}
className={clsx(
'h-9 w-12 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'merged'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeMerged')}
>
<MergeModeIcon />
</button>
</div>
<button
type="button"
onClick={() => detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.backToTop')}
>
<ArrowUp className="w-4 h-4" />
</button>
{selectedSummary && (
<span className={clsx('px-2.5 py-1 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(selectedSummary.status))}>
{t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)}
</span>
)}
</div>
</div>
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 lg:p-6 space-y-6">
{!selectedItem && (
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex items-center justify-center text-sm text-muted-foreground">
{t('chatHistory.selectPrompt')}
</div>
)}
{selectedItem && (
<DetailConversation
selectedItem={selectedItem}
t={t}
viewMode={viewMode}
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
bottomButtonClassName="absolute right-5 bottom-5"
onMessage={onMessage}
/>
)}
</div>
</div>
</div>
{isMobileView && mobileDetailOpen && selectedItem && (
<div
className={clsx(
'fixed inset-0 z-50 flex items-center justify-center px-3 py-4 bg-background/65 backdrop-blur-sm transition-opacity duration-200',
mobileDetailVisible ? 'opacity-100' : 'opacity-0'
)}
onClick={closeMobileDetail}
>
<div
onClick={(event) => 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}%` }}
>
<div className="px-5 py-4 border-b border-border flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
<button
type="button"
onClick={() => setViewMode('list')}
className={clsx(
'h-9 w-10 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'list'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeList')}
>
<ListModeIcon />
</button>
<button
type="button"
onClick={() => setViewMode('merged')}
className={clsx(
'h-9 w-10 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'merged'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeMerged')}
>
<MergeModeIcon />
</button>
</div>
<button
type="button"
onClick={() => detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="h-9 w-9 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.backToTop')}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={closeMobileDetail}
className="h-9 w-9 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('actions.cancel')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 space-y-6">
<DetailConversation
selectedItem={selectedItem}
t={t}
viewMode={viewMode}
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
bottomButtonClassName="fixed right-5 bottom-5"
/>
</div>
</div>
</div>
)}
{confirmClearOpen && (
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center px-4">
<div className="w-full max-w-sm rounded-2xl border border-border bg-card shadow-2xl p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-2xl bg-[#111214] text-muted-foreground flex items-center justify-center">
<Trash2 className="w-5 h-5" />
</div>
<div>
<div className="text-base font-semibold text-foreground">{t('chatHistory.confirmClearTitle')}</div>
<div className="text-sm text-muted-foreground mt-1">{t('chatHistory.confirmClearDesc')}</div>
</div>
</div>
<button
type="button"
onClick={() => setConfirmClearOpen(false)}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/70"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmClearOpen(false)}
className="h-10 px-4 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/60"
>
{t('actions.cancel')}
</button>
<button
type="button"
onClick={async () => {
setConfirmClearOpen(false)
await handleClear()
}}
className="h-10 px-4 rounded-lg border border-destructive/20 bg-destructive/10 text-destructive hover:bg-destructive/15 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{t('chatHistory.confirmClearAction')}
</button>
</div>
</div>
</div>
)}
</div>
)
}