diff --git a/VERSION b/VERSION
index 8089590..f77856a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.3.0
+4.3.1
diff --git a/internal/httpapi/openai/chat/chat_stream_runtime.go b/internal/httpapi/openai/chat/chat_stream_runtime.go
index 110188c..ed5034f 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,
+ },
}
}
@@ -120,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) {
@@ -177,8 +185,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 +199,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 +273,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,86 +285,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.TrimContinuationOverlapFromBuilder(&s.toolDetectionThinking, 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" {
- s.rawThinking.WriteString(p.Text)
- contentSeen = true
- if s.thinkingEnabled {
- cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
- if cleanedText == "" {
- continue
- }
- s.thinking.WriteString(cleanedText)
- batch.append("reasoning_content", cleanedText)
- }
+ batch.append("reasoning_content", p.VisibleText)
+ continue
+ }
+ if p.RawText == "" {
+ continue
+ }
+ if p.CitationOnly {
+ continue
+ }
+ if !s.bufferToolContent {
+ batch.append("content", p.VisibleText)
} else {
- s.rawText.WriteString(p.Text)
- contentSeen = true
- cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
- if s.searchEnabled && sse.IsCitation(cleanedText) {
- continue
- }
- if cleanedText != "" {
- s.text.WriteString(cleanedText)
- }
- if !s.bufferToolContent {
- if cleanedText == "" {
+ 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", cleanedText)
- } else {
- events := toolstream.ProcessChunk(&s.toolSieve, p.Text, 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/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)
+ }
+}
diff --git a/internal/httpapi/openai/chat/empty_retry_runtime.go b/internal/httpapi/openai/chat/empty_retry_runtime.go
index be4af0d..68a570d 100644
--- a/internal/httpapi/openai/chat/empty_retry_runtime.go
+++ b/internal/httpapi/openai/chat/empty_retry_runtime.go
@@ -238,7 +238,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
},
@@ -250,7 +250,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))
}
},
})
@@ -270,7 +270,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)
@@ -279,7 +279,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 ee56448..0d960ca 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 9ff8268..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,50 +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.TrimContinuationOverlapFromBuilder(&s.toolDetectionThinking, 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" {
- s.rawThinking.WriteString(p.Text)
- contentSeen = true
- if !s.thinkingEnabled {
- continue
- }
- cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
- if cleanedText == "" {
- continue
- }
- s.thinking.WriteString(cleanedText)
- batch.append("reasoning", cleanedText)
+ batch.append("reasoning", p.VisibleText)
continue
}
-
- s.rawText.WriteString(p.Text)
- contentSeen = true
- cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
- if s.searchEnabled && sse.IsCitation(cleanedText) {
+ if p.RawText == "" {
continue
}
- if cleanedText != "" {
- s.text.WriteString(cleanedText)
+ if p.CitationOnly {
+ continue
}
if !s.bufferToolContent {
- if cleanedText == "" {
- continue
- }
- batch.append("text", cleanedText)
+ batch.append("text", p.VisibleText)
continue
}
batch.flush()
- s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, p.Text, 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..95ded0e
--- /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.TrimContinuationOverlapFromBuilder(&a.ToolDetectionThinking, 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.TrimContinuationOverlapFromBuilder(&a.RawThinking, 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.TrimContinuationOverlapFromBuilder(&a.Thinking, cleanedText)
+ if trimmed == "" {
+ return delta
+ }
+ a.Thinking.WriteString(trimmed)
+ delta.VisibleText = trimmed
+ return delta
+}
+
+func (a *StreamAccumulator) applyTextPart(text string) StreamPartDelta {
+ rawTrimmed := sse.TrimContinuationOverlapFromBuilder(&a.RawText, 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.TrimContinuationOverlapFromBuilder(&a.Text, 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/internal/sse/dedupe.go b/internal/sse/dedupe.go
index 6fbb51d..259c89f 100644
--- a/internal/sse/dedupe.go
+++ b/internal/sse/dedupe.go
@@ -1,6 +1,9 @@
package sse
-import "strings"
+import (
+ "strings"
+ "unicode/utf8"
+)
const minContinuationSnapshotLen = 32
@@ -11,7 +14,7 @@ func TrimContinuationOverlap(existing, incoming string) string {
if existing == "" {
return incoming
}
- if len(incoming) < minContinuationSnapshotLen {
+ if utf8.RuneCountInString(incoming) < minContinuationSnapshotLen {
return incoming
}
if len(incoming) > len(existing) {
@@ -33,7 +36,7 @@ func TrimContinuationOverlapFromBuilder(existing *strings.Builder, incoming stri
if existing == nil || existing.Len() == 0 {
return incoming
}
- if len(incoming) < minContinuationSnapshotLen {
+ if utf8.RuneCountInString(incoming) < minContinuationSnapshotLen {
return incoming
}
existingLen := existing.Len()
diff --git a/internal/sse/dedupe_test.go b/internal/sse/dedupe_test.go
index 8ac6422..71692c4 100644
--- a/internal/sse/dedupe_test.go
+++ b/internal/sse/dedupe_test.go
@@ -1,6 +1,9 @@
package sse
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestTrimContinuationOverlapReturnsSuffixForSnapshotReplay(t *testing.T) {
existing := "我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。"
@@ -37,3 +40,12 @@ func TestTrimContinuationOverlapKeepsShortPrefixLikeNormalToken(t *testing.T) {
t.Fatalf("expected short token preserved, got %q", got)
}
}
+
+func TestTrimContinuationOverlapKeepsShortMultibyteChunk(t *testing.T) {
+ existing := strings.Repeat("字", 36)
+ incoming := strings.Repeat("字", 16)
+ got := TrimContinuationOverlap(existing, incoming)
+ if got != incoming {
+ t.Fatalf("expected short multibyte chunk preserved, got %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 (
-
-
- {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 => (
-
- ))}
-
-
-
-
-
-
-
{t('chatHistory.detailTitle')}
-
- {selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')}
-
-
-
-
- setViewMode('list')}
- className={clsx(
- 'h-9 w-12 rounded-lg flex items-center justify-center transition-colors',
- viewMode === 'list'
- ? 'bg-secondary text-foreground'
- : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
- )}
- title={t('chatHistory.viewModeList')}
- >
-
-
- setViewMode('merged')}
- className={clsx(
- 'h-9 w-12 rounded-lg flex items-center justify-center transition-colors',
- viewMode === 'merged'
- ? 'bg-secondary text-foreground'
- : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
- )}
- title={t('chatHistory.viewModeMerged')}
- >
-
-
-
-
detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
- 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.backToTop')}
- >
-
-
- {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)}
-
-
-
-
- setViewMode('list')}
- className={clsx(
- 'h-9 w-10 rounded-lg flex items-center justify-center transition-colors',
- viewMode === 'list'
- ? 'bg-secondary text-foreground'
- : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
- )}
- title={t('chatHistory.viewModeList')}
- >
-
-
- setViewMode('merged')}
- className={clsx(
- 'h-9 w-10 rounded-lg flex items-center justify-center transition-colors',
- viewMode === 'merged'
- ? 'bg-secondary text-foreground'
- : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
- )}
- title={t('chatHistory.viewModeMerged')}
- >
-
-
-
-
detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
- className="h-9 w-9 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.backToTop')}
- >
-
-
-
-
-
-
-
+
-
-
-
-
-
- )}
-
- {confirmClearOpen && (
-
-
-
-
-
-
-
-
-
{t('chatHistory.confirmClearTitle')}
-
{t('chatHistory.confirmClearDesc')}
-
-
-
setConfirmClearOpen(false)}
- className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/70"
- >
-
-
-
-
- setConfirmClearOpen(false)}
- className="h-10 px-4 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/60"
- >
- {t('actions.cancel')}
-
- {
- setConfirmClearOpen(false)
- await handleClear()
- }}
- className="h-10 px-4 rounded-lg border border-destructive/20 bg-destructive/10 text-destructive hover:bg-destructive/15 flex items-center gap-2"
- >
-
- {t('chatHistory.confirmClearAction')}
-
-
-
-
- )}
+ 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 (
+
+
+ {shouldCollapse && (
+
setExpanded(prev => !prev)}
+ className={clsx('mt-3 inline-flex items-center gap-2 text-xs font-medium transition-colors', buttonClassName)}
+ >
+
+ {expanded ? collapseLabel : expandLabel}
+
+ )}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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'))}
+
+
+
+
+
+
+ detailScrollRef.current?.scrollTo({ top: detailScrollRef.current?.scrollHeight || 0, behavior: 'smooth' })}
+ className={clsx('h-12 w-12 rounded-full border border-border bg-card/95 backdrop-blur shadow-lg text-muted-foreground hover:text-foreground hover:bg-secondary/90 flex items-center justify-center', bottomButtonClassName)}
+ title={t('chatHistory.backToBottom')}
+ >
+
+
+ >
+ )
+}
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 (
+
+ setViewMode('list')}
+ className={clsx(
+ size,
+ 'rounded-lg flex items-center justify-center transition-colors',
+ viewMode === 'list' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
+ )}
+ title={t('chatHistory.viewModeList')}
+ >
+
+
+ setViewMode('merged')}
+ className={clsx(
+ size,
+ 'rounded-lg flex items-center justify-center transition-colors',
+ viewMode === 'merged' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
+ )}
+ title={t('chatHistory.viewModeMerged')}
+ >
+
+
+
+ )
+}
+
+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 => (
+
onSelectItem(item.id, event)}
+ className={clsx(
+ 'w-full text-left rounded-xl border px-4 py-3 transition-colors',
+ selectedItem?.id === item.id ? 'border-primary/40 bg-primary/5' : 'border-border hover:bg-secondary/40'
+ )}
+ >
+
+
+
+ {item.user_input || t('chatHistory.untitled')}
+
+
+ {item.model || '-'}
+
+
+
+
+ {t(`chatHistory.status.${item.status || 'streaming'}`)}
+
+ {
+ event.stopPropagation()
+ onDeleteItem(item.id)
+ }}
+ disabled={deletingId === item.id}
+ className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10"
+ >
+ {deletingId === item.id ? : }
+
+
+
+
+ {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')}
+
+
+
+
+
detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
+ 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.backToTop')}
+ >
+
+
+ {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)}
+
+
+
+
+
detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
+ className="h-9 w-9 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.backToTop')}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ConfirmClearDialog({ open, t, onCancel, onConfirm }) {
+ if (!open) return null
+
+ return (
+
+
+
+
+
+
+
+
+
{t('chatHistory.confirmClearTitle')}
+
{t('chatHistory.confirmClearDesc')}
+
+
+
+
+
+
+
+
+ {t('actions.cancel')}
+
+
+
+ {t('chatHistory.confirmClearAction')}
+
+
+
+
+ )
+}
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 }
+}