@@ -1,4 +1,4 @@
import { ArrowDown , ArrowUp , Bot , ChevronDown , Clock3 , Loader2 , MessageSquareText , RefreshCcw , Sparkles , Trash2 , UserRound , X } from 'lucide-react'
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'
@@ -9,9 +9,14 @@ 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 '-'
@@ -109,6 +114,54 @@ function MergeModeIcon() {
)
}
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 ] ) ) {
@@ -131,7 +184,9 @@ function parseStrictHistoryMessages(historyText) {
while ( cursor < transcript . length ) {
if ( expectedRole === null ) {
if ( transcript . startsWith ( USER _MARKER , cursor ) ) {
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'
@@ -142,13 +197,32 @@ function parseStrictHistoryMessages(historyText) {
}
}
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' ) return null
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 )
if ( nextAssistant < 0 ) return null
if ( nextSentenceEnd >= 0 && nextSentenceEnd < nextAssistant ) {
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 ( {
@@ -161,21 +235,26 @@ function parseStrictHistoryMessages(historyText) {
}
parsed . push ( {
role : 'user' ,
content : transcript . slice ( cursor , nextAssistant ) ,
content : transcript . slice ( cursor , nextRoleIndex ) ,
} )
const assistantStart = nextAssistant + ASSISTANT _MARKER . length
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 = nextAssistant
cursor = nextRoleIndex
expectedRole = 'assistant'
continue
}
if ( transcript . startsWith ( ASSISTANT _MARKER , cursor ) ) {
if ( expectedRole !== 'assistant' ) return null
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
@@ -184,11 +263,28 @@ function parseStrictHistoryMessages(historyText) {
content : transcript . slice ( cursor , nextSentenceEnd ) ,
} )
cursor = nextSentenceEnd + END _SENTENCE _MARKER . length
expectedRole = 'user'
expectedRole = 'user_or_tool '
continue
}
if ( parsed . length && expectedRole === 'user' ) break
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
}
@@ -214,6 +310,14 @@ function buildListModeMessages(item, t) {
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'
@@ -275,8 +379,28 @@ function RequestMessages({ item, t, messages }) {
)
}
function MergedPromptView ( { item , t } ) {
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"
@@ -285,8 +409,28 @@ function MergedPromptView({ item, t }) {
borderColor : 'rgba(231, 176, 8, 0.45)' ,
} }
>
< div className = "text-[11px] uppercase tracking-[0.12em] text-[#5b4300] mb -3" >
{ t ( 'chatHistory.mergedInput' ) }
< 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
@@ -300,14 +444,53 @@ function MergedPromptView({ item, t }) {
)
}
function HistoryTextView ( { item , t } ) {
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 = "text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-3 text-left " >
HISTORY
< 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
@@ -322,18 +505,18 @@ function HistoryTextView({ item, t }) {
)
}
function DetailConversation ( { selectedItem , t , viewMode , detailScrollRef , assistantStartRef , bottomButtonClassName } ) {
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 } / > }
{ showHistoryAtTop && < HistoryTextView item = { selectedItem } t = { t } onMessage = { onMessage } / > }
{ viewMode === 'list'
? < RequestMessages item = { selectedItem } t = { t } messages = { listModeState ? . messages } / >
: < MergedPromptView item = { selectedItem } t = { t } / > }
: < MergedPromptView item = { selectedItem } t = { t } onMessage = { onMessage } / > }
< div ref = { assistantStartRef } className = "flex gap-4 max-w-4xl mx-auto" >
< div className = { clsx (
@@ -908,6 +1091,7 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
detailScrollRef = { detailScrollRef }
assistantStartRef = { assistantStartRef }
bottomButtonClassName = "absolute right-5 bottom-5"
onMessage = { onMessage }
/ >
) }
< / div >