ds2api / webui /src /features /chatHistory /chatHistoryUtils.js
huggeu's picture
Upload 532 files
8d3471e verified
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.',
])
const HISTORY_TRANSCRIPT_TITLE = '# DS2API_HISTORY.txt'
const HISTORY_TRANSCRIPT_ENTRY_RE = /^===\s+\d+\.\s+([A-Z][A-Z_ -]*)\s+===\s*$/gm
function isCurrentInputFilePrompt(value) {
const text = String(value || '').trim()
return text === CURRENT_INPUT_FILE_PROMPT || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text)
}
function normalizeHistoryRole(role) {
const normalized = String(role || '').trim().toLowerCase()
if (normalized === 'function') return 'tool'
if (normalized === 'developer') return 'system'
if (normalized === 'system' || normalized === 'user' || normalized === 'assistant' || normalized === 'tool') {
return normalized
}
return normalized || 'system'
}
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 parseTranscriptHistoryMessages(historyText) {
const rawText = String(historyText || '')
const titleIndex = rawText.indexOf(HISTORY_TRANSCRIPT_TITLE)
const transcript = titleIndex >= 0 ? rawText.slice(titleIndex) : rawText
const matches = [...transcript.matchAll(HISTORY_TRANSCRIPT_ENTRY_RE)]
if (!matches.length) return null
const parsed = []
for (let i = 0; i < matches.length; i += 1) {
const match = matches[i]
const next = matches[i + 1]
const role = normalizeHistoryRole(match[1])
const start = (match.index || 0) + match[0].length
const end = next ? next.index : transcript.length
const content = transcript.slice(start, end).replace(/^\r?\n/, '').trim()
if (!content) continue
parsed.push({ role, content })
}
return parsed.length ? parsed : null
}
export function parseHistoryMessages(historyText) {
return parseStrictHistoryMessages(historyText) || parseTranscriptHistoryMessages(historyText)
}
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 = parseHistoryMessages(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 }
}