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