File size: 11,342 Bytes
8d3471e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
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 }
}