Merge pull request #350 from ouqiting/fix_chat_histroy

feat: parse split context files in list view
This commit is contained in:
CJACK.
2026-04-29 01:34:10 +08:00
committed by GitHub
6 changed files with 227 additions and 26 deletions

View File

@@ -307,15 +307,15 @@ func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
if err != nil {
t.Fatalf("expected detail item, got %v", err)
}
if full.HistoryText != "" {
t.Fatalf("expected current input file flow to leave history text empty, got %q", full.HistoryText)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
}
if ds.uploadCalls[0].Filename != "IGNORE.txt" {
t.Fatalf("expected IGNORE.txt upload, got %q", ds.uploadCalls[0].Filename)
}
if full.HistoryText != string(ds.uploadCalls[0].Data) {
t.Fatalf("expected uploaded current input file to be persisted in history text")
}
if len(full.Messages) != 1 {
t.Fatalf("expected neutral prompt to be the only persisted message, got %#v", full.Messages)
}

View File

@@ -58,6 +58,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
}
stdReq.Messages = messages
stdReq.HistoryText = fileText
stdReq.CurrentInputFileApplied = true
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)

View File

@@ -352,7 +352,7 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
}
}
func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
func TestApplyCurrentInputFileCarriesHistoryText(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
@@ -377,8 +377,8 @@ func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
if out.HistoryText != "" {
t.Fatalf("expected current input file flow to leave history text empty, got %q", out.HistoryText)
if out.HistoryText != string(ds.uploadCalls[0].Data) {
t.Fatalf("expected current input file flow to preserve uploaded text in history, got %q", out.HistoryText)
}
}

View File

@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Copy, Download, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -9,9 +9,14 @@ const DISABLED_LIMIT = 0
const MESSAGE_COLLAPSE_AT = 700
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 = 'The current request and prior conversation context have already been provided. Answer the latest user request directly.'
function formatDateTime(value, lang) {
if (!value) return '-'
@@ -109,6 +114,54 @@ function MergeModeIcon() {
)
}
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')
}
}
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])) {
@@ -131,7 +184,9 @@ function parseStrictHistoryMessages(historyText) {
while (cursor < transcript.length) {
if (expectedRole === null) {
if (transcript.startsWith(USER_MARKER, cursor)) {
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'
@@ -142,13 +197,32 @@ function parseStrictHistoryMessages(historyText) {
}
}
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') return null
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)
if (nextAssistant < 0) return null
if (nextSentenceEnd >= 0 && nextSentenceEnd < nextAssistant) {
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({
@@ -161,21 +235,26 @@ function parseStrictHistoryMessages(historyText) {
}
parsed.push({
role: 'user',
content: transcript.slice(cursor, nextAssistant),
content: transcript.slice(cursor, nextRoleIndex),
})
const assistantStart = nextAssistant + ASSISTANT_MARKER.length
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 = nextAssistant
cursor = nextRoleIndex
expectedRole = 'assistant'
continue
}
if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
if (expectedRole !== 'assistant') return null
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
@@ -184,11 +263,28 @@ function parseStrictHistoryMessages(historyText) {
content: transcript.slice(cursor, nextSentenceEnd),
})
cursor = nextSentenceEnd + END_SENTENCE_MARKER.length
expectedRole = 'user'
expectedRole = 'user_or_tool'
continue
}
if (parsed.length && expectedRole === 'user') break
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
}
@@ -214,6 +310,14 @@ function buildListModeMessages(item, t) {
return { messages: liveMessages, historyMerged: false }
}
const placeholderOnly = liveMessages.length === 1
&& String(liveMessages[0]?.role || '').trim().toLowerCase() === 'user'
&& String(liveMessages[0]?.content || '').trim() === CURRENT_INPUT_FILE_PROMPT
if (placeholderOnly) {
return { messages: historyMessages, historyMerged: true }
}
const insertAt = liveMessages.findIndex(message => {
const role = String(message?.role || '').trim().toLowerCase()
return role !== 'system' && role !== 'developer'
@@ -275,8 +379,28 @@ function RequestMessages({ item, t, messages }) {
)
}
function MergedPromptView({ item, t }) {
function MergedPromptView({ item, t, onMessage }) {
const merged = item?.final_prompt || ''
const mergedFilename = `Merged_${item?.id || 'prompt'}.txt`
const handleCopy = async () => {
try {
await copyTextWithFallback(merged)
onMessage?.('success', t('chatHistory.copySuccess'))
} catch {
onMessage?.('error', t('chatHistory.copyFailed'))
}
}
const handleDownload = () => {
try {
downloadTextFile(mergedFilename, merged)
onMessage?.('success', t('chatHistory.downloadSuccess'))
} catch {
onMessage?.('error', t('chatHistory.downloadFailed'))
}
}
return (
<div
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
@@ -285,8 +409,28 @@ function MergedPromptView({ item, t }) {
borderColor: 'rgba(231, 176, 8, 0.45)',
}}
>
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300] mb-3">
{t('chatHistory.mergedInput')}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300]">
{t('chatHistory.mergedInput')}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
title={t('chatHistory.copyMerged')}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleDownload}
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
title={t('chatHistory.downloadMerged')}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
<ExpandableText
@@ -300,14 +444,53 @@ function MergedPromptView({ item, t }) {
)
}
function HistoryTextView({ item, t }) {
function HistoryTextView({ item, t, onMessage }) {
const historyText = (item?.history_text || '').trim()
if (!historyText) return null
const historyFilename = `History_${item?.id || 'history'}.txt`
const handleCopy = async () => {
try {
await copyTextWithFallback(historyText)
onMessage?.('success', t('chatHistory.copySuccess'))
} catch {
onMessage?.('error', t('chatHistory.copyFailed'))
}
}
const handleDownload = () => {
try {
downloadTextFile(historyFilename, historyText)
onMessage?.('success', t('chatHistory.downloadSuccess'))
} catch {
onMessage?.('error', t('chatHistory.downloadFailed'))
}
}
return (
<div className="max-w-4xl mx-auto rounded-2xl border border-border bg-background px-5 py-4">
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-3 text-left">
HISTORY
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground text-left">
HISTORY
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.copyHistory')}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleDownload}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.downloadHistory')}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words font-mono">
<ExpandableText
@@ -322,18 +505,18 @@ function HistoryTextView({ item, t }) {
)
}
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) {
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) {
if (!selectedItem) return null
const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
const showHistoryAtTop = viewMode !== 'list' || !listModeState?.historyMerged
return (
<>
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} />}
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} onMessage={onMessage} />}
{viewMode === 'list'
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
: <MergedPromptView item={selectedItem} t={t} />}
: <MergedPromptView item={selectedItem} t={t} onMessage={onMessage} />}
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
<div className={clsx(
@@ -908,6 +1091,7 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
bottomButtonClassName="absolute right-5 bottom-5"
onMessage={onMessage}
/>
)}
</div>

View File

@@ -284,6 +284,14 @@
"selectPrompt": "Select a record on the left to view details.",
"mergedInput": "Final message sent to DeepSeek",
"emptyMergedPrompt": "No merged prompt is available.",
"copyHistory": "Copy HISTORY",
"downloadHistory": "Download HISTORY",
"copyMerged": "Copy merged prompt",
"downloadMerged": "Download merged prompt",
"copySuccess": "Copied successfully.",
"copyFailed": "Copy failed.",
"downloadSuccess": "Downloaded successfully.",
"downloadFailed": "Download failed.",
"expand": "Expand",
"collapse": "Collapse",
"reasoningTrace": "Reasoning Trace",

View File

@@ -284,6 +284,14 @@
"selectPrompt": "从左侧选择一条记录查看详情。",
"mergedInput": "最终发送给 DeepSeek 的完整消息",
"emptyMergedPrompt": "没有可展示的完整消息。",
"copyHistory": "复制 HISTORY",
"downloadHistory": "下载 HISTORY",
"copyMerged": "复制完整消息",
"downloadMerged": "下载完整消息",
"copySuccess": "复制成功",
"copyFailed": "复制失败",
"downloadSuccess": "下载成功",
"downloadFailed": "下载失败",
"expand": "展开全部",
"collapse": "收起",
"reasoningTrace": "思维链过程",