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 } }