ds2api / webui /src /features /chatHistory /chatHistoryUtils.js
204848
Initial commit: ds2api with HF Spaces support
feccc69
Raw
History Blame Contribute Delete
8.85 kB
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 = ''
const SYSTEM_MARKER = 'System: '
const USER_MARKER = 'User: '
const ASSISTANT_MARKER = 'Assistant: '
const TOOL_MARKER = 'Tool: '
const END_INSTRUCTIONS_MARKER = '\n'
const END_SENTENCE_MARKER = '\n'
const END_TOOL_RESULTS_MARKER = '\n'
const CURRENT_INPUT_FILE_PROMPT_RE = /(已附上下文文件|上下文已存入|请从文件|使用附件).+\.txt/iu
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 = '# context_context.txt'
const HISTORY_TRANSCRIPT_ENTRY_RE = /^===\s+\d+\s+===\s*$/gm
const HISTORY_TRANSCRIPT_ROLE_RE = /^\[?r\s*=\s*(\d+)\]?\s*$/i
function isCurrentInputFilePrompt(value) {
const text = String(value || '').trim()
return CURRENT_INPUT_FILE_PROMPT_RE.test(text) || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text)
}
function normalizeHistoryRole(role) {
if (typeof role === 'number' && Number.isFinite(role)) {
switch (role) {
case 0:
return 'system'
case 1:
return 'user'
case 2:
return 'assistant'
case 3:
return 'tool'
default:
return 'user'
}
}
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 transcript = rawText.slice(rawText.indexOf(BEGIN_SENTENCE_MARKER))
const markers = [
{ role: 'system', marker: SYSTEM_MARKER },
{ role: 'user', marker: USER_MARKER },
{ role: 'assistant', marker: ASSISTANT_MARKER },
{ role: 'tool', marker: TOOL_MARKER },
]
const findNextMarker = (start) => {
let best = null
for (const item of markers) {
const idx = transcript.indexOf(item.marker, start)
if (idx < 0) continue
if (!best || idx < best.index) {
best = { ...item, index: idx }
}
}
return best
}
const first = findNextMarker(0)
if (!first) return null
const parsed = []
let cursor = first.index
let trailingAssistantPromptOnly = false
while (cursor < transcript.length) {
const current = markers.find((item) => transcript.startsWith(item.marker, cursor))
if (!current) {
if (transcript.slice(cursor).trim() === '') break
return null
}
cursor += current.marker.length
const next = findNextMarker(cursor)
const content = (next ? transcript.slice(cursor, next.index) : transcript.slice(cursor)).trim()
if (current.role === 'assistant' && !next && content === '') {
trailingAssistantPromptOnly = true
break
}
if (content) {
parsed.push({ role: current.role, content })
}
cursor = next ? next.index : transcript.length
}
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 start = (match.index || 0) + match[0].length
const end = next ? next.index : transcript.length
const rawContent = transcript.slice(start, end).replace(/^\r?\n/, '').trim()
if (!rawContent) continue
const lines = rawContent.split(/\r?\n/)
let role = 'user'
let contentLines = lines
if (lines.length > 0) {
const roleMatch = lines[0].trim().match(HISTORY_TRANSCRIPT_ROLE_RE)
if (roleMatch) {
role = normalizeHistoryRole(Number(roleMatch[1]))
contentLines = lines.slice(1)
}
}
const content = contentLines.join('\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 }
}