refactor streaming accumulation and chat history UI

This commit is contained in:
CJACK
2026-05-02 20:15:38 +08:00
parent 20d71f528a
commit c8f7b6b371
13 changed files with 1223 additions and 1037 deletions

View File

@@ -1,603 +1,14 @@
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Copy, Download, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
import { Loader2, RefreshCcw, Trash2 } 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 = 'Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.'
const LEGACY_CURRENT_INPUT_FILE_PROMPTS = new Set([
'The current request and prior conversation context have already been provided. Answer the latest user request directly.',
])
function isCurrentInputFilePrompt(value) {
const text = String(value || '').trim()
return text === CURRENT_INPUT_FILE_PROMPT || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text)
}
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'
&& isCurrentInputFilePrompt(liveMessages[0]?.content)
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>
</>
)
}
import { ChatHistoryListPane, ConfirmClearDialog, DesktopDetailPane, MobileDetailModal } from './ChatHistoryPanels'
import {
DISABLED_LIMIT,
LIMIT_OPTIONS,
VIEW_MODE_KEY,
} from './chatHistoryUtils'
export default function ChatHistoryContainer({ authFetch, onMessage }) {
const { t, lang } = useI18n()
@@ -968,273 +379,52 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
)}
<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>
)}
<ChatHistoryListPane
items={items}
selectedItem={selectedItem}
deletingId={deletingId}
t={t}
lang={lang}
onSelectItem={handleSelectItem}
onDeleteItem={handleDeleteItem}
/>
{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>
<DesktopDetailPane
selectedSummary={selectedSummary}
selectedItem={selectedItem}
t={t}
lang={lang}
viewMode={viewMode}
setViewMode={setViewMode}
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
onMessage={onMessage}
/>
</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>
<MobileDetailModal
open={isMobileView && mobileDetailOpen}
visible={mobileDetailVisible}
origin={mobileOrigin}
selectedItem={selectedItem}
t={t}
lang={lang}
viewMode={viewMode}
setViewMode={setViewMode}
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
onClose={closeMobileDetail}
/>
<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>
)}
<ConfirmClearDialog
open={confirmClearOpen}
t={t}
onCancel={() => setConfirmClearOpen(false)}
onConfirm={async () => {
setConfirmClearOpen(false)
await handleClear()
}}
/>
</div>
)
}

View File

@@ -0,0 +1,284 @@
import { ArrowDown, Bot, ChevronDown, Clock3, Copy, Download, Sparkles, UserRound } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
MESSAGE_COLLAPSE_AT,
buildListModeMessages,
copyTextWithFallback,
downloadTextFile,
formatElapsed,
} from './chatHistoryUtils'
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 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 PromptTextActions({ text, filename, copyTitle, downloadTitle, t, onMessage, buttonClassName }) {
const handleCopy = async () => {
try {
await copyTextWithFallback(text)
onMessage?.('success', t('chatHistory.copySuccess'))
} catch {
onMessage?.('error', t('chatHistory.copyFailed'))
}
}
const handleDownload = () => {
try {
downloadTextFile(filename, text)
onMessage?.('success', t('chatHistory.downloadSuccess'))
} catch {
onMessage?.('error', t('chatHistory.downloadFailed'))
}
}
return (
<div className="flex items-center gap-2">
<button type="button" onClick={handleCopy} className={buttonClassName} title={copyTitle}>
<Copy className="w-4 h-4" />
</button>
<button type="button" onClick={handleDownload} className={buttonClassName} title={downloadTitle}>
<Download className="w-4 h-4" />
</button>
</div>
)
}
function MergedPromptView({ item, t, onMessage }) {
const merged = item?.final_prompt || ''
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>
<PromptTextActions
text={merged}
filename={`Merged_${item?.id || 'prompt'}.txt`}
copyTitle={t('chatHistory.copyMerged')}
downloadTitle={t('chatHistory.downloadMerged')}
t={t}
onMessage={onMessage}
buttonClassName="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
/>
</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
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>
<PromptTextActions
text={historyText}
filename={`History_${item?.id || 'history'}.txt`}
copyTitle={t('chatHistory.copyHistory')}
downloadTitle={t('chatHistory.downloadHistory')}
t={t}
onMessage={onMessage}
buttonClassName="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"
/>
</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 MetaGrid({ selectedItem, t }) {
return (
<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>
)
}
export default 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>
<MetaGrid selectedItem={selectedItem} t={t} />
<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>
</>
)
}

View File

@@ -0,0 +1,250 @@
import { ArrowUp, Loader2, MessageSquareText, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import DetailConversation from './ChatHistoryDetail'
import { ListModeIcon, MergeModeIcon } from './HistoryModeIcons'
import { formatDateTime, previewText, statusTone } from './chatHistoryUtils'
function ViewModeToggle({ t, viewMode, setViewMode, mobile = false }) {
const size = mobile ? 'h-9 w-10' : 'h-9 w-12'
return (
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
<button
type="button"
onClick={() => setViewMode('list')}
className={clsx(
size,
'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(
size,
'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>
)
}
export function ChatHistoryListPane({ items, selectedItem, deletingId, t, lang, onSelectItem, onDeleteItem }) {
return (
<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) => onSelectItem(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()
onDeleteItem(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>
)
}
export function DesktopDetailPane({ selectedSummary, selectedItem, t, lang, viewMode, setViewMode, detailScrollRef, assistantStartRef, onMessage }) {
return (
<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">
<ViewModeToggle t={t} viewMode={viewMode} setViewMode={setViewMode} />
<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>
)
}
export function MobileDetailModal({ open, visible, origin, selectedItem, t, lang, viewMode, setViewMode, detailScrollRef, assistantStartRef, onClose }) {
if (!open || !selectedItem) return null
return (
<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',
visible ? 'opacity-100' : 'opacity-0'
)}
onClick={onClose}
>
<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',
visible ? 'scale-100' : 'scale-90'
)}
style={{ transformOrigin: `${origin.x}% ${origin.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">
<ViewModeToggle t={t} viewMode={viewMode} setViewMode={setViewMode} mobile />
<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={onClose}
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>
)
}
export function ConfirmClearDialog({ open, t, onCancel, onConfirm }) {
if (!open) return null
return (
<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={onCancel} 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={onCancel} 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={onConfirm} 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>
)
}

View File

@@ -0,0 +1,15 @@
export 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>
)
}
export 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>
)
}

View File

@@ -0,0 +1,250 @@
export const LIMIT_OPTIONS = [0, 10, 20, 50]
export const DISABLED_LIMIT = 0
export const MESSAGE_COLLAPSE_AT = 700
export 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 = 'Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.'
const LEGACY_CURRENT_INPUT_FILE_PROMPTS = new Set([
'The current request and prior conversation context have already been provided. Answer the latest user request directly.',
])
function isCurrentInputFilePrompt(value) {
const text = String(value || '').trim()
return text === CURRENT_INPUT_FILE_PROMPT || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text)
}
export 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 '-'
}
}
export 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`
}
export function previewText(item) {
return item?.preview || item?.content || item?.reasoning_content || item?.error || item?.user_input || ''
}
export 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'
}
}
export 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')
}
}
export 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
}
export 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
}
export 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'
&& isCurrentInputFilePrompt(liveMessages[0]?.content)
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 }
}