From 9a404e75fc9115db40f657062491947700526ec0 Mon Sep 17 00:00:00 2001 From: ouqiting Date: Thu, 23 Apr 2026 14:47:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BF=9D=E5=AD=98=E5=B9=B6=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20HISTORY=20=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/adapter/openai/chat_history.go | 2 + internal/adapter/openai/chat_history_test.go | 53 +++++++++++++++++++ internal/adapter/openai/history_split.go | 1 + internal/adapter/openai/history_split_test.go | 31 +++++++++++ internal/chathistory/store.go | 3 ++ internal/util/standard_request.go | 1 + .../chatHistory/ChatHistoryContainer.jsx | 24 +++++++++ 7 files changed, 115 insertions(+) diff --git a/internal/adapter/openai/chat_history.go b/internal/adapter/openai/chat_history.go index e71ede5..41b4c54 100644 --- a/internal/adapter/openai/chat_history.go +++ b/internal/adapter/openai/chat_history.go @@ -44,6 +44,7 @@ func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.Request Stream: stdReq.Stream, UserInput: extractSingleUserInput(stdReq.Messages), Messages: extractAllMessages(stdReq.Messages), + HistoryText: stdReq.HistoryText, FinalPrompt: stdReq.FinalPrompt, }) startParams := chathistory.StartParams{ @@ -53,6 +54,7 @@ func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.Request Stream: stdReq.Stream, UserInput: extractSingleUserInput(stdReq.Messages), Messages: extractAllMessages(stdReq.Messages), + HistoryText: stdReq.HistoryText, FinalPrompt: stdReq.FinalPrompt, } session := &chatHistorySession{ diff --git a/internal/adapter/openai/chat_history_test.go b/internal/adapter/openai/chat_history_test.go index 5e5d6a0..7787e98 100644 --- a/internal/adapter/openai/chat_history_test.go +++ b/internal/adapter/openai/chat_history_test.go @@ -271,3 +271,56 @@ func TestChatCompletionsSkipsHistoryWhenDisabled(t *testing.T) { t.Fatalf("expected disabled history to stay empty, got %#v", snapshot.Items) } } + +func TestChatCompletionsHistorySplitPersistsHistoryText(t *testing.T) { + historyStore := newTestChatHistoryStore(t) + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + ChatHistory: historyStore, + } + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"system","content":"system instructions"},{"role":"user","content":"first user turn"},{"role":"assistant","content":"","reasoning_content":"hidden reasoning","tool_calls":[{"name":"search","arguments":{"query":"docs"}}]},{"role":"tool","name":"search","tool_call_id":"call-1","content":"tool result"},{"role":"user","content":"latest user turn"}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected one history item, got %d", len(snapshot.Items)) + } + full, err := historyStore.Get(snapshot.Items[0].ID) + if err != nil { + t.Fatalf("expected detail item, got %v", err) + } + if full.HistoryText == "" { + t.Fatalf("expected history text to be persisted") + } + if !strings.Contains(full.HistoryText, "first user turn") || !strings.Contains(full.HistoryText, "tool result") { + t.Fatalf("expected earlier turns in history text, got %q", full.HistoryText) + } + if strings.Contains(full.HistoryText, "latest user turn") { + t.Fatalf("expected latest turn to stay out of persisted history text, got %q", full.HistoryText) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected history upload to happen, got %d", len(ds.uploadCalls)) + } + if full.HistoryText != string(ds.uploadCalls[0].Data) { + t.Fatalf("expected persisted history text to match uploaded HISTORY.txt contents") + } +} diff --git a/internal/adapter/openai/history_split.go b/internal/adapter/openai/history_split.go index 4364728..1cd1491 100644 --- a/internal/adapter/openai/history_split.go +++ b/internal/adapter/openai/history_split.go @@ -51,6 +51,7 @@ func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, st } stdReq.Messages = promptMessages + stdReq.HistoryText = historyText stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, reasoningContent, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking) return stdReq, nil diff --git a/internal/adapter/openai/history_split_test.go b/internal/adapter/openai/history_split_test.go index 9ec9025..7a90049 100644 --- a/internal/adapter/openai/history_split_test.go +++ b/internal/adapter/openai/history_split_test.go @@ -195,6 +195,37 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) { } } +func TestApplyHistorySplitCarriesHistoryText(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + historySplitEnabled: true, + historySplitTurns: 1, + }, + DS: ds, + } + req := map[string]any{ + "model": "deepseek-chat", + "messages": historySplitTestMessages(), + } + stdReq, err := normalizeOpenAIChatRequest(h.Store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + + out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq) + if err != nil { + t.Fatalf("apply history split failed: %v", err) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + if out.HistoryText != string(ds.uploadCalls[0].Data) { + t.Fatalf("expected history text to be preserved on normalized request") + } +} + func TestChatCompletionsHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) { ds := &inlineUploadDSStub{} h := &Handler{ diff --git a/internal/chathistory/store.go b/internal/chathistory/store.go index fcb7fdd..faa1818 100644 --- a/internal/chathistory/store.go +++ b/internal/chathistory/store.go @@ -46,6 +46,7 @@ type Entry struct { Stream bool `json:"stream"` UserInput string `json:"user_input,omitempty"` Messages []Message `json:"messages,omitempty"` + HistoryText string `json:"history_text,omitempty"` FinalPrompt string `json:"final_prompt,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` Content string `json:"content,omitempty"` @@ -94,6 +95,7 @@ type StartParams struct { Stream bool UserInput string Messages []Message + HistoryText string FinalPrompt string } @@ -244,6 +246,7 @@ func (s *Store) Start(params StartParams) (Entry, error) { Stream: params.Stream, UserInput: strings.TrimSpace(params.UserInput), Messages: cloneMessages(params.Messages), + HistoryText: params.HistoryText, FinalPrompt: strings.TrimSpace(params.FinalPrompt), } s.details[entry.ID] = entry diff --git a/internal/util/standard_request.go b/internal/util/standard_request.go index be3eeaa..b809dfd 100644 --- a/internal/util/standard_request.go +++ b/internal/util/standard_request.go @@ -8,6 +8,7 @@ type StandardRequest struct { ResolvedModel string ResponseModel string Messages []any + HistoryText string ToolsRaw any FinalPrompt string ToolNames []string diff --git a/webui/src/features/chatHistory/ChatHistoryContainer.jsx b/webui/src/features/chatHistory/ChatHistoryContainer.jsx index c9951c0..17d9692 100644 --- a/webui/src/features/chatHistory/ChatHistoryContainer.jsx +++ b/webui/src/features/chatHistory/ChatHistoryContainer.jsx @@ -181,11 +181,35 @@ function MergedPromptView({ item, t }) { ) } +function HistoryTextView({ item, t }) { + const historyText = (item?.history_text || '').trim() + if (!historyText) return null + + return ( +
+
+ HISTORY +
+
+ +
+
+ ) +} + function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) { if (!selectedItem) return null return ( <> + + {viewMode === 'list' ? : }