From c8f7b6b3718c5c8e381f42064339457b17ab90e9 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sat, 2 May 2026 20:15:38 +0800 Subject: [PATCH 1/3] refactor streaming accumulation and chat history UI --- .../openai/chat/chat_stream_runtime.go | 165 ++-- .../openai/chat/empty_retry_runtime.go | 8 +- internal/httpapi/openai/chat/handler_chat.go | 6 +- .../responses_stream_runtime_core.go | 111 +-- .../openai/shared/stream_accumulator.go | 104 ++ .../openai/shared/stream_accumulator_test.go | 97 ++ tests/node/chat-history-utils.test.js | 60 ++ tests/scripts/run-unit-node.sh | 2 +- .../chatHistory/ChatHistoryContainer.jsx | 908 +----------------- .../chatHistory/ChatHistoryDetail.jsx | 284 ++++++ .../chatHistory/ChatHistoryPanels.jsx | 250 +++++ .../features/chatHistory/HistoryModeIcons.jsx | 15 + .../features/chatHistory/chatHistoryUtils.js | 250 +++++ 13 files changed, 1223 insertions(+), 1037 deletions(-) create mode 100644 internal/httpapi/openai/shared/stream_accumulator.go create mode 100644 internal/httpapi/openai/shared/stream_accumulator_test.go create mode 100644 tests/node/chat-history-utils.test.js create mode 100644 webui/src/features/chatHistory/ChatHistoryDetail.jsx create mode 100644 webui/src/features/chatHistory/ChatHistoryPanels.jsx create mode 100644 webui/src/features/chatHistory/HistoryModeIcons.jsx create mode 100644 webui/src/features/chatHistory/chatHistoryUtils.js diff --git a/internal/httpapi/openai/chat/chat_stream_runtime.go b/internal/httpapi/openai/chat/chat_stream_runtime.go index a9270a1..d183346 100644 --- a/internal/httpapi/openai/chat/chat_stream_runtime.go +++ b/internal/httpapi/openai/chat/chat_stream_runtime.go @@ -6,6 +6,7 @@ import ( "strings" openaifmt "ds2api/internal/format/openai" + "ds2api/internal/httpapi/openai/shared" "ds2api/internal/sse" streamengine "ds2api/internal/stream" "ds2api/internal/toolstream" @@ -34,15 +35,11 @@ type chatStreamRuntime struct { toolCallsEmitted bool toolCallsDoneEmitted bool - toolSieve toolstream.State - streamToolCallIDs map[int]string - streamToolNames map[int]string - rawThinking strings.Builder - thinking strings.Builder - toolDetectionThinking strings.Builder - rawText strings.Builder - text strings.Builder - responseMessageID int + toolSieve toolstream.State + streamToolCallIDs map[int]string + streamToolNames map[int]string + accumulator shared.StreamAccumulator + responseMessageID int finalThinking string finalText string @@ -112,6 +109,11 @@ func newChatStreamRuntime( emitEarlyToolDeltas: emitEarlyToolDeltas, streamToolCallIDs: map[int]string{}, streamToolNames: map[int]string{}, + accumulator: shared.StreamAccumulator{ + ThinkingEnabled: thinkingEnabled, + SearchEnabled: searchEnabled, + StripReferenceMarkers: stripReferenceMarkers, + }, } } @@ -177,8 +179,8 @@ func (s *chatStreamRuntime) markContextCancelled() { s.finalErrorStatus = 499 s.finalErrorMessage = "request context cancelled" s.finalErrorCode = string(streamengine.StopReasonContextCancelled) - s.finalThinking = s.thinking.String() - s.finalText = cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) + s.finalThinking = s.accumulator.Thinking.String() + s.finalText = cleanVisibleOutput(s.accumulator.Text.String(), s.stripReferenceMarkers) s.finalFinishReason = string(streamengine.StopReasonContextCancelled) } @@ -191,12 +193,12 @@ func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) s.finalErrorStatus = 0 s.finalErrorMessage = "" s.finalErrorCode = "" - finalThinking := s.thinking.String() - finalToolDetectionThinking := s.toolDetectionThinking.String() - finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) + finalThinking := s.accumulator.Thinking.String() + finalToolDetectionThinking := s.accumulator.ToolDetectionThinking.String() + finalText := cleanVisibleOutput(s.accumulator.Text.String(), s.stripReferenceMarkers) s.finalThinking = finalThinking s.finalText = finalText - detected := detectAssistantToolCalls(s.rawText.String(), finalText, s.rawThinking.String(), finalToolDetectionThinking, s.toolNames) + detected := detectAssistantToolCalls(s.accumulator.RawText.String(), finalText, s.accumulator.RawThinking.String(), finalToolDetectionThinking, s.toolNames) if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted { finishReason = "tool_calls" s.sendDelta(map[string]any{ @@ -265,7 +267,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD s.responseMessageID = parsed.ResponseMessageID } if parsed.ContentFilter { - if strings.TrimSpace(s.text.String()) == "" { + if strings.TrimSpace(s.accumulator.Text.String()) == "" { return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")} } return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested} @@ -277,98 +279,65 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested} } - contentSeen := false batch := chatDeltaBatch{runtime: s} - for _, p := range parsed.ToolDetectionThinkingParts { - trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text) - if trimmed != "" { - s.toolDetectionThinking.WriteString(trimmed) - } - } - for _, p := range parsed.Parts { + accumulated := s.accumulator.Apply(parsed) + for _, p := range accumulated.Parts { if p.Type == "thinking" { - rawTrimmed := sse.TrimContinuationOverlap(s.rawThinking.String(), p.Text) - if rawTrimmed != "" { - s.rawThinking.WriteString(rawTrimmed) - contentSeen = true - } - if s.thinkingEnabled { - cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers) - if cleanedText == "" { - continue - } - trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText) - if trimmed == "" { - continue - } - s.thinking.WriteString(trimmed) - batch.append("reasoning_content", trimmed) - } + batch.append("reasoning_content", p.VisibleText) + continue + } + if p.RawText == "" { + continue + } + if p.CitationOnly { + continue + } + if !s.bufferToolContent { + batch.append("content", p.VisibleText) } else { - rawTrimmed := sse.TrimContinuationOverlap(s.rawText.String(), p.Text) - if rawTrimmed == "" { - continue - } - s.rawText.WriteString(rawTrimmed) - contentSeen = true - cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers) - if s.searchEnabled && sse.IsCitation(cleanedText) { - continue - } - trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText) - if trimmed != "" { - s.text.WriteString(trimmed) - } - if !s.bufferToolContent { - if trimmed == "" { + events := toolstream.ProcessChunk(&s.toolSieve, p.RawText, s.toolNames) + for _, evt := range events { + if len(evt.ToolCallDeltas) > 0 { + if !s.emitEarlyToolDeltas { + continue + } + filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.streamToolNames) + if len(filtered) == 0 { + continue + } + formatted := formatIncrementalStreamToolCallDeltas(filtered, s.streamToolCallIDs) + if len(formatted) == 0 { + continue + } + batch.flush() + tcDelta := map[string]any{ + "tool_calls": formatted, + } + s.toolCallsEmitted = true + s.sendDelta(tcDelta) continue } - batch.append("content", trimmed) - } else { - events := toolstream.ProcessChunk(&s.toolSieve, rawTrimmed, s.toolNames) - for _, evt := range events { - if len(evt.ToolCallDeltas) > 0 { - if !s.emitEarlyToolDeltas { - continue - } - filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.streamToolNames) - if len(filtered) == 0 { - continue - } - formatted := formatIncrementalStreamToolCallDeltas(filtered, s.streamToolCallIDs) - if len(formatted) == 0 { - continue - } - batch.flush() - tcDelta := map[string]any{ - "tool_calls": formatted, - } - s.toolCallsEmitted = true - s.sendDelta(tcDelta) + if len(evt.ToolCalls) > 0 { + batch.flush() + s.toolCallsEmitted = true + s.toolCallsDoneEmitted = true + tcDelta := map[string]any{ + "tool_calls": formatFinalStreamToolCallsWithStableIDs(evt.ToolCalls, s.streamToolCallIDs, s.toolsRaw), + } + s.sendDelta(tcDelta) + s.resetStreamToolCallState() + continue + } + if evt.Content != "" { + cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers) + if cleaned == "" || (s.searchEnabled && sse.IsCitation(cleaned)) { continue } - if len(evt.ToolCalls) > 0 { - batch.flush() - s.toolCallsEmitted = true - s.toolCallsDoneEmitted = true - tcDelta := map[string]any{ - "tool_calls": formatFinalStreamToolCallsWithStableIDs(evt.ToolCalls, s.streamToolCallIDs, s.toolsRaw), - } - s.sendDelta(tcDelta) - s.resetStreamToolCallState() - continue - } - if evt.Content != "" { - cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers) - if cleaned == "" || (s.searchEnabled && sse.IsCitation(cleaned)) { - continue - } - batch.append("content", cleaned) - } + batch.append("content", cleaned) } } } } batch.flush() - return streamengine.ParsedDecision{ContentSeen: contentSeen} + return streamengine.ParsedDecision{ContentSeen: accumulated.ContentSeen} } diff --git a/internal/httpapi/openai/chat/empty_retry_runtime.go b/internal/httpapi/openai/chat/empty_retry_runtime.go index 464dd2c..676309a 100644 --- a/internal/httpapi/openai/chat/empty_retry_runtime.go +++ b/internal/httpapi/openai/chat/empty_retry_runtime.go @@ -237,7 +237,7 @@ func (h *Handler) consumeChatStreamAttempt(r *http.Request, resp *http.Response, OnParsed: func(parsed sse.LineResult) streamengine.ParsedDecision { decision := streamRuntime.onParsed(parsed) if historySession != nil { - historySession.progress(streamRuntime.thinking.String(), streamRuntime.text.String()) + historySession.progress(streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String()) } return decision }, @@ -249,7 +249,7 @@ func (h *Handler) consumeChatStreamAttempt(r *http.Request, resp *http.Response, OnContextDone: func() { streamRuntime.markContextCancelled() if historySession != nil { - historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled)) + historySession.stopped(streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String(), string(streamengine.StopReasonContextCancelled)) } }, }) @@ -269,7 +269,7 @@ func recordChatStreamHistory(streamRuntime *chatStreamRuntime, historySession *c return } if streamRuntime.finalErrorMessage != "" { - historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.thinking.String(), streamRuntime.text.String()) + historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String()) return } historySession.success(http.StatusOK, streamRuntime.finalThinking, streamRuntime.finalText, streamRuntime.finalFinishReason, streamRuntime.finalUsage) @@ -278,7 +278,7 @@ func recordChatStreamHistory(streamRuntime *chatStreamRuntime, historySession *c func failChatStreamRetry(streamRuntime *chatStreamRuntime, historySession *chatHistorySession, status int, message, code string) { streamRuntime.sendFailedChunk(status, message, code) if historySession != nil { - historySession.error(status, message, code, streamRuntime.thinking.String(), streamRuntime.text.String()) + historySession.error(status, message, code, streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String()) } } diff --git a/internal/httpapi/openai/chat/handler_chat.go b/internal/httpapi/openai/chat/handler_chat.go index 6fa1d63..eb3d063 100644 --- a/internal/httpapi/openai/chat/handler_chat.go +++ b/internal/httpapi/openai/chat/handler_chat.go @@ -254,7 +254,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt OnParsed: func(parsed sse.LineResult) streamengine.ParsedDecision { decision := streamRuntime.onParsed(parsed) if historySession != nil { - historySession.progress(streamRuntime.thinking.String(), streamRuntime.text.String()) + historySession.progress(streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String()) } return decision }, @@ -268,14 +268,14 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt return } if streamRuntime.finalErrorMessage != "" { - historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.thinking.String(), streamRuntime.text.String()) + historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String()) return } historySession.success(http.StatusOK, streamRuntime.finalThinking, streamRuntime.finalText, streamRuntime.finalFinishReason, streamRuntime.finalUsage) }, OnContextDone: func() { if historySession != nil { - historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled)) + historySession.stopped(streamRuntime.accumulator.Thinking.String(), streamRuntime.accumulator.Text.String(), string(streamengine.StopReasonContextCancelled)) } }, }) diff --git a/internal/httpapi/openai/responses/responses_stream_runtime_core.go b/internal/httpapi/openai/responses/responses_stream_runtime_core.go index a4749c0..cfe9d5d 100644 --- a/internal/httpapi/openai/responses/responses_stream_runtime_core.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_core.go @@ -7,6 +7,7 @@ import ( "ds2api/internal/config" openaifmt "ds2api/internal/format/openai" + "ds2api/internal/httpapi/openai/shared" "ds2api/internal/promptcompat" "ds2api/internal/sse" streamengine "ds2api/internal/stream" @@ -36,31 +37,27 @@ type responsesStreamRuntime struct { toolCallsEmitted bool toolCallsDoneEmitted bool - sieve toolstream.State - rawThinking strings.Builder - thinking strings.Builder - toolDetectionThinking strings.Builder - rawText strings.Builder - text strings.Builder - visibleText strings.Builder - responseMessageID int - streamToolCallIDs map[int]string - functionItemIDs map[int]string - functionOutputIDs map[int]int - functionArgs map[int]string - functionDone map[int]bool - functionAdded map[int]bool - functionNames map[int]string - messageItemID string - messageOutputID int - nextOutputID int - messageAdded bool - messagePartAdded bool - sequence int - failed bool - finalErrorStatus int - finalErrorMessage string - finalErrorCode string + sieve toolstream.State + accumulator shared.StreamAccumulator + visibleText strings.Builder + responseMessageID int + streamToolCallIDs map[int]string + functionItemIDs map[int]string + functionOutputIDs map[int]int + functionArgs map[int]string + functionDone map[int]bool + functionAdded map[int]bool + functionNames map[int]string + messageItemID string + messageOutputID int + nextOutputID int + messageAdded bool + messagePartAdded bool + sequence int + failed bool + finalErrorStatus int + finalErrorMessage string + finalErrorCode string persistResponse func(obj map[string]any) } @@ -108,6 +105,11 @@ func newResponsesStreamRuntime( toolChoice: toolChoice, traceID: traceID, persistResponse: persistResponse, + accumulator: shared.StreamAccumulator{ + ThinkingEnabled: thinkingEnabled, + SearchEnabled: searchEnabled, + StripReferenceMarkers: stripReferenceMarkers, + }, } } @@ -155,10 +157,10 @@ func (s *responsesStreamRuntime) finalize(finishReason string, deferEmptyOutput s.processToolStreamEvents(toolstream.Flush(&s.sieve, s.toolNames), true, true) } - finalThinking := s.thinking.String() - finalToolDetectionThinking := s.toolDetectionThinking.String() - finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) - textParsed := detectAssistantToolCalls(s.rawText.String(), finalText, s.rawThinking.String(), finalToolDetectionThinking, s.toolNames) + finalThinking := s.accumulator.Thinking.String() + finalToolDetectionThinking := s.accumulator.ToolDetectionThinking.String() + finalText := cleanVisibleOutput(s.accumulator.Text.String(), s.stripReferenceMarkers) + textParsed := detectAssistantToolCalls(s.accumulator.RawText.String(), finalText, s.accumulator.RawThinking.String(), finalToolDetectionThinking, s.toolNames) detected := textParsed.Calls s.logToolPolicyRejections(textParsed) @@ -228,62 +230,27 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa return streamengine.ParsedDecision{Stop: true} } - contentSeen := false batch := responsesDeltaBatch{runtime: s} - for _, p := range parsed.ToolDetectionThinkingParts { - trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text) - if trimmed != "" { - s.toolDetectionThinking.WriteString(trimmed) - } - } - for _, p := range parsed.Parts { + accumulated := s.accumulator.Apply(parsed) + for _, p := range accumulated.Parts { if p.Type == "thinking" { - rawTrimmed := sse.TrimContinuationOverlap(s.rawThinking.String(), p.Text) - if rawTrimmed != "" { - s.rawThinking.WriteString(rawTrimmed) - contentSeen = true - } - if !s.thinkingEnabled { - continue - } - cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers) - if cleanedText == "" { - continue - } - trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText) - if trimmed == "" { - continue - } - s.thinking.WriteString(trimmed) - batch.append("reasoning", trimmed) + batch.append("reasoning", p.VisibleText) continue } - - rawTrimmed := sse.TrimContinuationOverlap(s.rawText.String(), p.Text) - if rawTrimmed == "" { + if p.RawText == "" { continue } - s.rawText.WriteString(rawTrimmed) - contentSeen = true - cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers) - if s.searchEnabled && sse.IsCitation(cleanedText) { + if p.CitationOnly { continue } - trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText) - if trimmed != "" { - s.text.WriteString(trimmed) - } if !s.bufferToolContent { - if trimmed == "" { - continue - } - batch.append("text", trimmed) + batch.append("text", p.VisibleText) continue } batch.flush() - s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, rawTrimmed, s.toolNames), true, true) + s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, p.RawText, s.toolNames), true, true) } batch.flush() - return streamengine.ParsedDecision{ContentSeen: contentSeen} + return streamengine.ParsedDecision{ContentSeen: accumulated.ContentSeen} } diff --git a/internal/httpapi/openai/shared/stream_accumulator.go b/internal/httpapi/openai/shared/stream_accumulator.go new file mode 100644 index 0000000..f819506 --- /dev/null +++ b/internal/httpapi/openai/shared/stream_accumulator.go @@ -0,0 +1,104 @@ +package shared + +import ( + "strings" + + "ds2api/internal/sse" +) + +type StreamAccumulator struct { + ThinkingEnabled bool + SearchEnabled bool + StripReferenceMarkers bool + + RawThinking strings.Builder + Thinking strings.Builder + ToolDetectionThinking strings.Builder + RawText strings.Builder + Text strings.Builder +} + +type StreamPartDelta struct { + Type string + RawText string + VisibleText string + CitationOnly bool +} + +type StreamAccumulatorResult struct { + ContentSeen bool + Parts []StreamPartDelta +} + +func (a *StreamAccumulator) Apply(parsed sse.LineResult) StreamAccumulatorResult { + out := StreamAccumulatorResult{} + for _, p := range parsed.ToolDetectionThinkingParts { + trimmed := sse.TrimContinuationOverlap(a.ToolDetectionThinking.String(), p.Text) + if trimmed != "" { + a.ToolDetectionThinking.WriteString(trimmed) + } + } + for _, p := range parsed.Parts { + if p.Type == "thinking" { + delta := a.applyThinkingPart(p.Text) + if delta.RawText != "" { + out.ContentSeen = true + } + if delta.RawText != "" || delta.VisibleText != "" { + out.Parts = append(out.Parts, delta) + } + continue + } + delta := a.applyTextPart(p.Text) + if delta.RawText != "" { + out.ContentSeen = true + } + if delta.RawText != "" || delta.VisibleText != "" || delta.CitationOnly { + out.Parts = append(out.Parts, delta) + } + } + return out +} + +func (a *StreamAccumulator) applyThinkingPart(text string) StreamPartDelta { + rawTrimmed := sse.TrimContinuationOverlap(a.RawThinking.String(), text) + if rawTrimmed != "" { + a.RawThinking.WriteString(rawTrimmed) + } + delta := StreamPartDelta{Type: "thinking", RawText: rawTrimmed} + if !a.ThinkingEnabled || rawTrimmed == "" { + return delta + } + cleanedText := CleanVisibleOutput(rawTrimmed, a.StripReferenceMarkers) + if cleanedText == "" { + return delta + } + trimmed := sse.TrimContinuationOverlap(a.Thinking.String(), cleanedText) + if trimmed == "" { + return delta + } + a.Thinking.WriteString(trimmed) + delta.VisibleText = trimmed + return delta +} + +func (a *StreamAccumulator) applyTextPart(text string) StreamPartDelta { + rawTrimmed := sse.TrimContinuationOverlap(a.RawText.String(), text) + if rawTrimmed == "" { + return StreamPartDelta{Type: "text"} + } + a.RawText.WriteString(rawTrimmed) + delta := StreamPartDelta{Type: "text", RawText: rawTrimmed} + cleanedText := CleanVisibleOutput(rawTrimmed, a.StripReferenceMarkers) + if a.SearchEnabled && sse.IsCitation(cleanedText) { + delta.CitationOnly = true + return delta + } + trimmed := sse.TrimContinuationOverlap(a.Text.String(), cleanedText) + if trimmed == "" { + return delta + } + a.Text.WriteString(trimmed) + delta.VisibleText = trimmed + return delta +} diff --git a/internal/httpapi/openai/shared/stream_accumulator_test.go b/internal/httpapi/openai/shared/stream_accumulator_test.go new file mode 100644 index 0000000..375cfbf --- /dev/null +++ b/internal/httpapi/openai/shared/stream_accumulator_test.go @@ -0,0 +1,97 @@ +package shared + +import ( + "testing" + + "ds2api/internal/sse" +) + +func TestStreamAccumulatorAppliesThinkingAndTextDedupe(t *testing.T) { + acc := StreamAccumulator{ThinkingEnabled: true, StripReferenceMarkers: true} + thinkingPrefix := "this is a long thinking snapshot prefix used by DeepSeek continue replay" + textPrefix := "this is a long visible answer snapshot prefix used by DeepSeek continue replay" + first := acc.Apply(sse.LineResult{ + Parsed: true, + Parts: []sse.ContentPart{ + {Type: "thinking", Text: thinkingPrefix}, + {Type: "text", Text: textPrefix}, + }, + }) + second := acc.Apply(sse.LineResult{ + Parsed: true, + Parts: []sse.ContentPart{ + {Type: "thinking", Text: thinkingPrefix + " next"}, + {Type: "text", Text: textPrefix + " world"}, + }, + }) + + if !first.ContentSeen || !second.ContentSeen { + t.Fatalf("expected both chunks to mark content seen") + } + if got := acc.RawThinking.String(); got != thinkingPrefix+" next" { + t.Fatalf("raw thinking = %q", got) + } + if got := acc.Thinking.String(); got != thinkingPrefix+" next" { + t.Fatalf("thinking = %q", got) + } + if got := acc.RawText.String(); got != textPrefix+" world" { + t.Fatalf("raw text = %q", got) + } + if got := acc.Text.String(); got != textPrefix+" world" { + t.Fatalf("text = %q", got) + } + if got := second.Parts[0].VisibleText; got != " next" { + t.Fatalf("thinking delta = %q", got) + } + if got := second.Parts[1].VisibleText; got != " world" { + t.Fatalf("text delta = %q", got) + } +} + +func TestStreamAccumulatorKeepsHiddenThinkingForToolDetection(t *testing.T) { + acc := StreamAccumulator{ThinkingEnabled: false, StripReferenceMarkers: true} + result := acc.Apply(sse.LineResult{ + Parsed: true, + Parts: []sse.ContentPart{ + {Type: "thinking", Text: ""}, + }, + ToolDetectionThinkingParts: []sse.ContentPart{ + {Type: "thinking", Text: "detect"}, + {Type: "thinking", Text: " tools"}, + }, + }) + + if !result.ContentSeen { + t.Fatalf("expected hidden thinking to count as upstream content") + } + if got := acc.RawThinking.String(); got != "" { + t.Fatalf("raw thinking = %q", got) + } + if got := acc.Thinking.String(); got != "" { + t.Fatalf("visible thinking = %q", got) + } + if got := acc.ToolDetectionThinking.String(); got != "detect tools" { + t.Fatalf("tool detection thinking = %q", got) + } +} + +func TestStreamAccumulatorSuppressesCitationTextWhenSearchEnabled(t *testing.T) { + acc := StreamAccumulator{SearchEnabled: true, StripReferenceMarkers: true} + result := acc.Apply(sse.LineResult{ + Parsed: true, + Parts: []sse.ContentPart{{Type: "text", Text: "[citation:1]"}}, + }) + + if !result.ContentSeen { + t.Fatalf("expected citation chunk to mark upstream content") + } + if len(result.Parts) != 1 || !result.Parts[0].CitationOnly { + t.Fatalf("expected citation-only delta, got %#v", result.Parts) + } + if got := acc.RawText.String(); got != "[citation:1]" { + t.Fatalf("raw text = %q", got) + } + if got := acc.Text.String(); got != "" { + t.Fatalf("visible text = %q", got) + } +} diff --git a/tests/node/chat-history-utils.test.js b/tests/node/chat-history-utils.test.js new file mode 100644 index 0000000..b5dd085 --- /dev/null +++ b/tests/node/chat-history-utils.test.js @@ -0,0 +1,60 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +async function loadUtils() { + return import('../../webui/src/features/chatHistory/chatHistoryUtils.js'); +} + +test('chat history strict parser merges current input file placeholder', async () => { + const { + buildListModeMessages, + } = await loadUtils(); + const t = (key) => key; + const item = { + messages: [{ + role: 'user', + content: '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.', + }], + history_text: [ + '<|begin▁of▁sentence|>', + '<|User|>hello', + '<|Assistant|>hi<|end▁of▁sentence|>', + ].join(''), + }; + + const result = buildListModeMessages(item, t); + assert.equal(result.historyMerged, true); + assert.deepEqual(result.messages, [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + ]); +}); + +test('chat history strict parser inserts history after system messages', async () => { + const { + buildListModeMessages, + } = await loadUtils(); + const t = (key) => key; + const item = { + messages: [ + { role: 'system', content: 'policy' }, + { role: 'user', content: 'latest' }, + ], + history_text: [ + '<|begin▁of▁sentence|>', + '<|User|>old', + '<|Assistant|>done<|end▁of▁sentence|>', + ].join(''), + }; + + const result = buildListModeMessages(item, t); + assert.equal(result.historyMerged, true); + assert.deepEqual(result.messages, [ + { role: 'system', content: 'policy' }, + { role: 'user', content: 'old' }, + { role: 'assistant', content: 'done' }, + { role: 'user', content: 'latest' }, + ]); +}); diff --git a/tests/scripts/run-unit-node.sh b/tests/scripts/run-unit-node.sh index 9983f69..71ca406 100755 --- a/tests/scripts/run-unit-node.sh +++ b/tests/scripts/run-unit-node.sh @@ -14,7 +14,7 @@ cleanup() { } trap cleanup EXIT -if ! node --test --test-concurrency=1 tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js "$@" 2>&1 | tee "$NODE_TEST_LOG"; then +if ! node --test --test-concurrency=1 tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/chat-history-utils.test.js tests/node/js_compat_test.js "$@" 2>&1 | tee "$NODE_TEST_LOG"; then echo echo "[run-unit-node] Node tests failed. 失败摘要如下:" if command -v rg >/dev/null 2>&1; then diff --git a/webui/src/features/chatHistory/ChatHistoryContainer.jsx b/webui/src/features/chatHistory/ChatHistoryContainer.jsx index c0dc98c..81a8834 100644 --- a/webui/src/features/chatHistory/ChatHistoryContainer.jsx +++ b/webui/src/features/chatHistory/ChatHistoryContainer.jsx @@ -1,603 +1,14 @@ -import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Copy, Download, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react' +import { Loader2, RefreshCcw, Trash2 } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { useI18n } from '../../i18n' - -const LIMIT_OPTIONS = [0, 10, 20, 50] -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 = '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.', -]) - -function isCurrentInputFilePrompt(value) { - const text = String(value || '').trim() - return text === CURRENT_INPUT_FILE_PROMPT || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text) -} - -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 '-' - } -} - -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` -} - -function previewText(item) { - return item?.preview || item?.content || item?.reasoning_content || item?.error || item?.user_input || '' -} - -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' - } -} - -function ExpandableText({ text = '', threshold = MESSAGE_COLLAPSE_AT, expandLabel, collapseLabel, buttonClassName = 'text-white hover:text-white/80' }) { - const shouldCollapse = text.length > threshold - const [expanded, setExpanded] = useState(false) - const contentRef = useRef(null) - const [maxHeight, setMaxHeight] = useState('none') - - useEffect(() => { - setExpanded(false) - }, [text]) - - const visibleText = shouldCollapse && !expanded ? `${text.slice(0, threshold)}...` : text - - useEffect(() => { - if (!contentRef.current) return - setMaxHeight(`${contentRef.current.scrollHeight}px`) - }, [expanded, visibleText]) - - return ( -
-
-
- {visibleText} -
-
- {shouldCollapse && ( - - )} -
- ) -} - -function ListModeIcon() { - return ( - - ) -} - -function MergeModeIcon() { - return ( - - ) -} - -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])) { - cursor += 1 - } - return cursor -} - -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 -} - -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 = parseStrictHistoryMessages(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 } -} - -function RequestMessages({ item, t, messages }) { - const requestMessages = Array.isArray(messages) && messages.length > 0 - ? messages - : [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }] - - return ( -
- {requestMessages.map((message, index) => { - const role = message.role || 'user' - const isUser = role === 'user' - const isAssistant = role === 'assistant' - const isTool = role === 'tool' - const label = isUser - ? t('chatHistory.role.user') - : (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system'))) - return ( -
-
- {isUser - ? - : } -
-
-
- {label} -
-
-
- {message.content || t('chatHistory.emptyUserInput')} -
-
-
-
- ) - })} -
- ) -} - -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')} -
-
- - -
-
-
- -
-
- ) -} - -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 ( -
-
-
- HISTORY -
-
- - -
-
-
- -
-
- ) -} - -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 && } - - {viewMode === 'list' - ? - : } - -
-
- -
-
- {(selectedItem.reasoning_content || '').trim() && ( -
-
- - {t('chatHistory.reasoningTrace')} -
-
- {selectedItem.reasoning_content} -
-
- )} - -
- {selectedItem.status === 'error' - ? {selectedItem.error || t('chatHistory.failedOutput')} - : (selectedItem.content || t('chatHistory.emptyAssistantOutput'))} -
-
-
- -
-
{t('chatHistory.metaTitle')}
-
-
-
{t('chatHistory.metaAccount')}
-
{selectedItem.account_id || t('chatHistory.metaUnknown')}
-
-
-
{t('chatHistory.metaElapsed')}
-
- - {formatElapsed(selectedItem.elapsed_ms, t)} -
-
-
-
{t('chatHistory.metaModel')}
-
{selectedItem.model || t('chatHistory.metaUnknown')}
-
-
-
{t('chatHistory.metaStatusCode')}
-
{selectedItem.status_code || '-'}
-
-
-
{t('chatHistory.metaStream')}
-
{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}
-
-
-
{t('chatHistory.metaCaller')}
-
{selectedItem.caller_id || t('chatHistory.metaUnknown')}
-
-
-
- - - - ) -} +import { ChatHistoryListPane, ConfirmClearDialog, DesktopDetailPane, MobileDetailModal } from './ChatHistoryPanels' +import { + DISABLED_LIMIT, + LIMIT_OPTIONS, + VIEW_MODE_KEY, +} from './chatHistoryUtils' export default function ChatHistoryContainer({ authFetch, onMessage }) { const { t, lang } = useI18n() @@ -968,273 +379,52 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) { )}
-
-
-
{t('chatHistory.listTitle')}
-
{items.length}
-
-
- {!items.length && ( -
- -
{t('chatHistory.emptyTitle')}
-
{t('chatHistory.emptyDesc')}
-
- )} + - {items.map(item => ( - -
-
-
- {previewText(item) || t('chatHistory.noPreview')} -
-
- {formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)} -
- - ))} -
- - -
-
-
-
{t('chatHistory.detailTitle')}
-
- {selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')} -
-
-
-
- - -
- - {selectedSummary && ( - - {t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)} - - )} -
-
- -
- {!selectedItem && ( -
- {t('chatHistory.selectPrompt')} -
- )} - - {selectedItem && ( - - )} -
-
+ - {isMobileView && mobileDetailOpen && selectedItem && ( -
-
event.stopPropagation()} - className={clsx( - 'w-full h-full rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col transition-transform duration-200 ease-out', - mobileDetailVisible ? 'scale-100' : 'scale-90' - )} - style={{ transformOrigin: `${mobileOrigin.x}% ${mobileOrigin.y}%` }} - > -
-
-
{t('chatHistory.detailTitle')}
-
- {formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)} -
-
-
-
- - -
- - -
-
+ -
- -
-
-
- )} - - {confirmClearOpen && ( -
-
-
-
-
- -
-
-
{t('chatHistory.confirmClearTitle')}
-
{t('chatHistory.confirmClearDesc')}
-
-
- -
-
- - -
-
-
- )} + setConfirmClearOpen(false)} + onConfirm={async () => { + setConfirmClearOpen(false) + await handleClear() + }} + /> ) } diff --git a/webui/src/features/chatHistory/ChatHistoryDetail.jsx b/webui/src/features/chatHistory/ChatHistoryDetail.jsx new file mode 100644 index 0000000..785359b --- /dev/null +++ b/webui/src/features/chatHistory/ChatHistoryDetail.jsx @@ -0,0 +1,284 @@ +import { ArrowDown, Bot, ChevronDown, Clock3, Copy, Download, Sparkles, UserRound } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import clsx from 'clsx' + +import { + MESSAGE_COLLAPSE_AT, + buildListModeMessages, + copyTextWithFallback, + downloadTextFile, + formatElapsed, +} from './chatHistoryUtils' + +function ExpandableText({ text = '', threshold = MESSAGE_COLLAPSE_AT, expandLabel, collapseLabel, buttonClassName = 'text-white hover:text-white/80' }) { + const shouldCollapse = text.length > threshold + const [expanded, setExpanded] = useState(false) + const contentRef = useRef(null) + const [maxHeight, setMaxHeight] = useState('none') + + useEffect(() => { + setExpanded(false) + }, [text]) + + const visibleText = shouldCollapse && !expanded ? `${text.slice(0, threshold)}...` : text + + useEffect(() => { + if (!contentRef.current) return + setMaxHeight(`${contentRef.current.scrollHeight}px`) + }, [expanded, visibleText]) + + return ( +
+
+
+ {visibleText} +
+
+ {shouldCollapse && ( + + )} +
+ ) +} + +function RequestMessages({ item, t, messages }) { + const requestMessages = Array.isArray(messages) && messages.length > 0 + ? messages + : [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }] + + return ( +
+ {requestMessages.map((message, index) => { + const role = message.role || 'user' + const isUser = role === 'user' + const isAssistant = role === 'assistant' + const isTool = role === 'tool' + const label = isUser + ? t('chatHistory.role.user') + : (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system'))) + return ( +
+
+ {isUser ? : } +
+
+
+ {label} +
+
+
+ {message.content || t('chatHistory.emptyUserInput')} +
+
+
+
+ ) + })} +
+ ) +} + +function PromptTextActions({ text, filename, copyTitle, downloadTitle, t, onMessage, buttonClassName }) { + const handleCopy = async () => { + try { + await copyTextWithFallback(text) + onMessage?.('success', t('chatHistory.copySuccess')) + } catch { + onMessage?.('error', t('chatHistory.copyFailed')) + } + } + + const handleDownload = () => { + try { + downloadTextFile(filename, text) + onMessage?.('success', t('chatHistory.downloadSuccess')) + } catch { + onMessage?.('error', t('chatHistory.downloadFailed')) + } + } + + return ( +
+ + +
+ ) +} + +function MergedPromptView({ item, t, onMessage }) { + const merged = item?.final_prompt || '' + + return ( +
+
+
+ {t('chatHistory.mergedInput')} +
+ +
+
+ +
+
+ ) +} + +function HistoryTextView({ item, t, onMessage }) { + const historyText = (item?.history_text || '').trim() + if (!historyText) return null + + return ( +
+
+
+ HISTORY +
+ +
+
+ +
+
+ ) +} + +function MetaGrid({ selectedItem, t }) { + return ( +
+
{t('chatHistory.metaTitle')}
+
+
+
{t('chatHistory.metaAccount')}
+
{selectedItem.account_id || t('chatHistory.metaUnknown')}
+
+
+
{t('chatHistory.metaElapsed')}
+
+ + {formatElapsed(selectedItem.elapsed_ms, t)} +
+
+
+
{t('chatHistory.metaModel')}
+
{selectedItem.model || t('chatHistory.metaUnknown')}
+
+
+
{t('chatHistory.metaStatusCode')}
+
{selectedItem.status_code || '-'}
+
+
+
{t('chatHistory.metaStream')}
+
{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}
+
+
+
{t('chatHistory.metaCaller')}
+
{selectedItem.caller_id || t('chatHistory.metaUnknown')}
+
+
+
+ ) +} + +export default 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 && } + + {viewMode === 'list' + ? + : } + +
+
+ +
+
+ {(selectedItem.reasoning_content || '').trim() && ( +
+
+ + {t('chatHistory.reasoningTrace')} +
+
+ {selectedItem.reasoning_content} +
+
+ )} + +
+ {selectedItem.status === 'error' + ? {selectedItem.error || t('chatHistory.failedOutput')} + : (selectedItem.content || t('chatHistory.emptyAssistantOutput'))} +
+
+
+ + + + + + ) +} diff --git a/webui/src/features/chatHistory/ChatHistoryPanels.jsx b/webui/src/features/chatHistory/ChatHistoryPanels.jsx new file mode 100644 index 0000000..cd42a72 --- /dev/null +++ b/webui/src/features/chatHistory/ChatHistoryPanels.jsx @@ -0,0 +1,250 @@ +import { ArrowUp, Loader2, MessageSquareText, Trash2, X } from 'lucide-react' +import clsx from 'clsx' + +import DetailConversation from './ChatHistoryDetail' +import { ListModeIcon, MergeModeIcon } from './HistoryModeIcons' +import { formatDateTime, previewText, statusTone } from './chatHistoryUtils' + +function ViewModeToggle({ t, viewMode, setViewMode, mobile = false }) { + const size = mobile ? 'h-9 w-10' : 'h-9 w-12' + return ( +
+ + +
+ ) +} + +export function ChatHistoryListPane({ items, selectedItem, deletingId, t, lang, onSelectItem, onDeleteItem }) { + return ( +
+
+
{t('chatHistory.listTitle')}
+
{items.length}
+
+
+ {!items.length && ( +
+ +
{t('chatHistory.emptyTitle')}
+
{t('chatHistory.emptyDesc')}
+
+ )} + + {items.map(item => ( + +
+
+
+ {previewText(item) || t('chatHistory.noPreview')} +
+
+ {formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)} +
+ + ))} + + + ) +} + +export function DesktopDetailPane({ selectedSummary, selectedItem, t, lang, viewMode, setViewMode, detailScrollRef, assistantStartRef, onMessage }) { + return ( +
+
+
+
{t('chatHistory.detailTitle')}
+
+ {selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')} +
+
+
+ + + {selectedSummary && ( + + {t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)} + + )} +
+
+ +
+ {!selectedItem && ( +
+ {t('chatHistory.selectPrompt')} +
+ )} + + {selectedItem && ( + + )} +
+
+ ) +} + +export function MobileDetailModal({ open, visible, origin, selectedItem, t, lang, viewMode, setViewMode, detailScrollRef, assistantStartRef, onClose }) { + if (!open || !selectedItem) return null + + return ( +
+
event.stopPropagation()} + className={clsx( + 'w-full h-full rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col transition-transform duration-200 ease-out', + visible ? 'scale-100' : 'scale-90' + )} + style={{ transformOrigin: `${origin.x}% ${origin.y}%` }} + > +
+
+
{t('chatHistory.detailTitle')}
+
+ {formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)} +
+
+
+ + + +
+
+ +
+ +
+
+
+ ) +} + +export function ConfirmClearDialog({ open, t, onCancel, onConfirm }) { + if (!open) return null + + return ( +
+
+
+
+
+ +
+
+
{t('chatHistory.confirmClearTitle')}
+
{t('chatHistory.confirmClearDesc')}
+
+
+ +
+
+ + +
+
+
+ ) +} diff --git a/webui/src/features/chatHistory/HistoryModeIcons.jsx b/webui/src/features/chatHistory/HistoryModeIcons.jsx new file mode 100644 index 0000000..9a59897 --- /dev/null +++ b/webui/src/features/chatHistory/HistoryModeIcons.jsx @@ -0,0 +1,15 @@ +export function ListModeIcon() { + return ( + + ) +} + +export function MergeModeIcon() { + return ( + + ) +} diff --git a/webui/src/features/chatHistory/chatHistoryUtils.js b/webui/src/features/chatHistory/chatHistoryUtils.js new file mode 100644 index 0000000..ae16ebc --- /dev/null +++ b/webui/src/features/chatHistory/chatHistoryUtils.js @@ -0,0 +1,250 @@ +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.', +]) + +function isCurrentInputFilePrompt(value) { + const text = String(value || '').trim() + return text === CURRENT_INPUT_FILE_PROMPT || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text) +} + +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 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 = parseStrictHistoryMessages(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 } +} From e7d6807c7c4d823e0cbefd8f172668c80574e5cc Mon Sep 17 00:00:00 2001 From: CJACK Date: Sat, 2 May 2026 20:54:10 +0800 Subject: [PATCH 2/3] feat: emit empty completion chunk along with keep-alive heartbeat in chat stream --- .../openai/chat/chat_stream_runtime.go | 8 ++- .../openai/chat/chat_stream_runtime_test.go | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 internal/httpapi/openai/chat/chat_stream_runtime_test.go diff --git a/internal/httpapi/openai/chat/chat_stream_runtime.go b/internal/httpapi/openai/chat/chat_stream_runtime.go index d183346..ed5034f 100644 --- a/internal/httpapi/openai/chat/chat_stream_runtime.go +++ b/internal/httpapi/openai/chat/chat_stream_runtime.go @@ -122,7 +122,13 @@ func (s *chatStreamRuntime) sendKeepAlive() { return } _, _ = s.w.Write([]byte(": keep-alive\n\n")) - _ = s.rc.Flush() + s.sendChunk(openaifmt.BuildChatStreamChunk( + s.completionID, + s.created, + s.model, + []map[string]any{}, + nil, + )) } func (s *chatStreamRuntime) sendChunk(v any) { diff --git a/internal/httpapi/openai/chat/chat_stream_runtime_test.go b/internal/httpapi/openai/chat/chat_stream_runtime_test.go new file mode 100644 index 0000000..db3026f --- /dev/null +++ b/internal/httpapi/openai/chat/chat_stream_runtime_test.go @@ -0,0 +1,53 @@ +package chat + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestChatStreamKeepAliveEmitsEmptyChoiceDataFrame(t *testing.T) { + rec := httptest.NewRecorder() + runtime := newChatStreamRuntime( + rec, + http.NewResponseController(rec), + true, + "chatcmpl-test", + time.Now().Unix(), + "deepseek-v4-flash", + "prompt", + false, + false, + true, + nil, + nil, + false, + false, + ) + + runtime.sendKeepAlive() + + body := rec.Body.String() + if !strings.Contains(body, ": keep-alive\n\n") { + t.Fatalf("expected keep-alive comment, got %q", body) + } + frames, done := parseSSEDataFrames(t, body) + if done { + t.Fatalf("keep-alive must not emit [DONE], body=%q", body) + } + if len(frames) != 1 { + t.Fatalf("expected one data frame, got %d body=%q", len(frames), body) + } + if got := asString(frames[0]["id"]); got != "chatcmpl-test" { + t.Fatalf("expected completion id to be preserved, got %q", got) + } + if got := asString(frames[0]["object"]); got != "chat.completion.chunk" { + t.Fatalf("expected chat chunk object, got %q", got) + } + choices, _ := frames[0]["choices"].([]any) + if len(choices) != 0 { + t.Fatalf("expected empty choices heartbeat, got %#v", choices) + } +} From a9f46f5b255d6e1b02dc2616ebbaf65f9c86bc8b Mon Sep 17 00:00:00 2001 From: CJACK Date: Sat, 2 May 2026 21:04:12 +0800 Subject: [PATCH 3/3] chore: bump version to 4.3.1 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 8089590..f77856a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.3.0 +4.3.1