From 28d2b0410fbacc72235785f885cab882df904024 Mon Sep 17 00:00:00 2001 From: ouqiting Date: Wed, 29 Apr 2026 01:15:29 +0800 Subject: [PATCH] feat: parse split context files in list view --- .../httpapi/openai/chat/chat_history_test.go | 6 +- .../openai/history/current_input_file.go | 1 + internal/httpapi/openai/history_split_test.go | 6 +- .../chatHistory/ChatHistoryContainer.jsx | 209 ++++++++++++++++-- webui/src/locales/en.json | 8 + webui/src/locales/zh.json | 8 + 6 files changed, 216 insertions(+), 22 deletions(-) diff --git a/internal/httpapi/openai/chat/chat_history_test.go b/internal/httpapi/openai/chat/chat_history_test.go index ec28d8a..97bcced 100644 --- a/internal/httpapi/openai/chat/chat_history_test.go +++ b/internal/httpapi/openai/chat/chat_history_test.go @@ -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) } diff --git a/internal/httpapi/openai/history/current_input_file.go b/internal/httpapi/openai/history/current_input_file.go index 981a5ee..430f982 100644 --- a/internal/httpapi/openai/history/current_input_file.go +++ b/internal/httpapi/openai/history/current_input_file.go @@ -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) diff --git a/internal/httpapi/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go index aa76575..78c502f 100644 --- a/internal/httpapi/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -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) } } diff --git a/webui/src/features/chatHistory/ChatHistoryContainer.jsx b/webui/src/features/chatHistory/ChatHistoryContainer.jsx index fe28a2a..065af38 100644 --- a/webui/src/features/chatHistory/ChatHistoryContainer.jsx +++ b/webui/src/features/chatHistory/ChatHistoryContainer.jsx @@ -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 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,15 +235,20 @@ 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 } @@ -188,6 +267,16 @@ function parseStrictHistoryMessages(historyText) { continue } + if (transcript.startsWith(TOOL_MARKER, cursor)) { + if (expectedRole !== 'tool' && expectedRole !== 'user') return null + cursor += TOOL_MARKER.length + const nextToolResultsEnd = transcript.indexOf(END_TOOL_RESULTS_MARKER, cursor) + if (nextToolResultsEnd < 0) return null + cursor = nextToolResultsEnd + END_TOOL_RESULTS_MARKER.length + expectedRole = 'user' + continue + } + if (parsed.length && expectedRole === 'user') break if (transcript.slice(cursor).trim() === '') break return null @@ -214,6 +303,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 +372,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 (
-
- {t('chatHistory.mergedInput')} +
+
+ {t('chatHistory.mergedInput')} +
+
+ + +
{ + 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 (
-
- HISTORY +
+
+ HISTORY +
+
+ + +
- {showHistoryAtTop && } + {showHistoryAtTop && } {viewMode === 'list' ? - : } + : }
)}
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index a554725..d994b6c 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -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", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 239b3fc..4683828 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -284,6 +284,14 @@ "selectPrompt": "从左侧选择一条记录查看详情。", "mergedInput": "最终发送给 DeepSeek 的完整消息", "emptyMergedPrompt": "没有可展示的完整消息。", + "copyHistory": "复制 HISTORY", + "downloadHistory": "下载 HISTORY", + "copyMerged": "复制完整消息", + "downloadMerged": "下载完整消息", + "copySuccess": "复制成功", + "copyFailed": "复制失败", + "downloadSuccess": "下载成功", + "downloadFailed": "下载失败", "expand": "展开全部", "collapse": "收起", "reasoningTrace": "思维链过程",