mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 16:35:27 +08:00
Merge origin/dev into PR 406
This commit is contained in:
@@ -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}
|
||||
}
|
||||
|
||||
53
internal/httpapi/openai/chat/chat_stream_runtime_test.go
Normal file
53
internal/httpapi/openai/chat/chat_stream_runtime_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
104
internal/httpapi/openai/shared/stream_accumulator.go
Normal file
104
internal/httpapi/openai/shared/stream_accumulator.go
Normal file
@@ -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
|
||||
}
|
||||
97
internal/httpapi/openai/shared/stream_accumulator_test.go
Normal file
97
internal/httpapi/openai/shared/stream_accumulator_test.go
Normal file
@@ -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: "<tool_calls></tool_calls>"},
|
||||
},
|
||||
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 != "<tool_calls></tool_calls>" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
60
tests/node/chat-history-utils.test.js
Normal file
60
tests/node/chat-history-utils.test.js
Normal file
@@ -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' },
|
||||
]);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div
|
||||
className="overflow-hidden transition-[max-height] duration-300 ease-out"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<div ref={contentRef} className="whitespace-pre-wrap break-words">
|
||||
{visibleText}
|
||||
</div>
|
||||
</div>
|
||||
{shouldCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
className={clsx('mt-3 inline-flex items-center gap-2 text-xs font-medium transition-colors', buttonClassName)}
|
||||
>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform duration-300', expanded && 'rotate-180')} />
|
||||
{expanded ? collapseLabel : expandLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListModeIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3 0h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm0 8h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MergeModeIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-5 max-w-4xl mx-auto">
|
||||
{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 (
|
||||
<div key={`${role}-${index}`} className={clsx('flex gap-4', isUser && 'flex-row-reverse justify-start')}>
|
||||
<div className={clsx(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
|
||||
isUser
|
||||
? 'bg-secondary'
|
||||
: (isAssistant ? 'bg-muted' : 'bg-background')
|
||||
)}>
|
||||
{isUser
|
||||
? <UserRound className="w-4 h-4 text-muted-foreground" />
|
||||
: <Bot className="w-4 h-4 text-foreground" />}
|
||||
</div>
|
||||
<div className="max-w-[88%] lg:max-w-[78%] text-left">
|
||||
<div className={clsx('text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-2 px-1', isUser && 'text-right')}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'rounded-2xl px-5 py-3 text-sm leading-relaxed shadow-sm border whitespace-pre-wrap break-words',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground rounded-tr-sm border-primary/30'
|
||||
: (isAssistant
|
||||
? 'bg-secondary/60 text-foreground rounded-tl-sm border-border'
|
||||
: 'bg-background text-foreground rounded-tl-sm border-border')
|
||||
)}>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{message.content || t('chatHistory.emptyUserInput')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MergedPromptView({ item, t, onMessage }) {
|
||||
const merged = item?.final_prompt || ''
|
||||
const mergedFilename = `Merged_${item?.id || 'prompt'}.txt`
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyTextWithFallback(merged)
|
||||
onMessage?.('success', t('chatHistory.copySuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadTextFile(mergedFilename, merged)
|
||||
onMessage?.('success', t('chatHistory.downloadSuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
|
||||
style={{
|
||||
backgroundColor: 'rgb(231, 176, 8)',
|
||||
borderColor: 'rgba(231, 176, 8, 0.45)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300]">
|
||||
{t('chatHistory.mergedInput')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
|
||||
title={t('chatHistory.copyMerged')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
|
||||
title={t('chatHistory.downloadMerged')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
|
||||
<ExpandableText
|
||||
text={merged || t('chatHistory.emptyMergedPrompt')}
|
||||
expandLabel={t('chatHistory.expand')}
|
||||
collapseLabel={t('chatHistory.collapse')}
|
||||
buttonClassName="text-[#2f2200] hover:text-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryTextView({ item, t, onMessage }) {
|
||||
const historyText = (item?.history_text || '').trim()
|
||||
if (!historyText) return null
|
||||
const historyFilename = `History_${item?.id || 'history'}.txt`
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyTextWithFallback(historyText)
|
||||
onMessage?.('success', t('chatHistory.copySuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadTextFile(historyFilename, historyText)
|
||||
onMessage?.('success', t('chatHistory.downloadSuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto rounded-2xl border border-border bg-background px-5 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground text-left">
|
||||
HISTORY
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
|
||||
title={t('chatHistory.copyHistory')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
|
||||
title={t('chatHistory.downloadHistory')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words font-mono">
|
||||
<ExpandableText
|
||||
text={historyText}
|
||||
threshold={Math.floor(MESSAGE_COLLAPSE_AT / 4)}
|
||||
expandLabel={t('chatHistory.expand')}
|
||||
collapseLabel={t('chatHistory.collapse')}
|
||||
buttonClassName="text-foreground hover:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) {
|
||||
if (!selectedItem) return null
|
||||
const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
|
||||
const showHistoryAtTop = viewMode !== 'list' || !listModeState?.historyMerged
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} onMessage={onMessage} />}
|
||||
|
||||
{viewMode === 'list'
|
||||
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
|
||||
: <MergedPromptView item={selectedItem} t={t} onMessage={onMessage} />}
|
||||
|
||||
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
|
||||
<div className={clsx(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
|
||||
selectedItem.status === 'error' ? 'bg-destructive/10 border-destructive/20' : 'bg-muted'
|
||||
)}>
|
||||
<Bot className={clsx('w-4 h-4', selectedItem.status === 'error' ? 'text-destructive' : 'text-foreground')} />
|
||||
</div>
|
||||
<div className="space-y-4 flex-1 min-w-0">
|
||||
{(selectedItem.reasoning_content || '').trim() && (
|
||||
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{t('chatHistory.reasoningTrace')}</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[12px] md:text-[13px] max-h-64 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50 break-words">
|
||||
{selectedItem.reasoning_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words">
|
||||
{selectedItem.status === 'error'
|
||||
? <span className="text-destructive font-medium">{selectedItem.error || t('chatHistory.failedOutput')}</span>
|
||||
: (selectedItem.content || t('chatHistory.emptyAssistantOutput'))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto rounded-xl border border-border bg-background/70 p-4 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">{t('chatHistory.metaTitle')}</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaAccount')}</div>
|
||||
<div className="text-sm font-medium text-foreground">{selectedItem.account_id || t('chatHistory.metaUnknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaElapsed')}</div>
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Clock3 className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{formatElapsed(selectedItem.elapsed_ms, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaModel')}</div>
|
||||
<div className="text-sm font-medium text-foreground break-all">{selectedItem.model || t('chatHistory.metaUnknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStatusCode')}</div>
|
||||
<div className="text-sm font-medium text-foreground">{selectedItem.status_code || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStream')}</div>
|
||||
<div className="text-sm font-medium text-foreground">{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaCaller')}</div>
|
||||
<div className="text-sm font-medium text-foreground break-all">{selectedItem.caller_id || t('chatHistory.metaUnknown')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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 }) {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[340px,minmax(0,1fr)] gap-6 h-[calc(100vh-240px)] min-h-[520px]">
|
||||
<div className="rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">{t('chatHistory.listTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{items.length}</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{!items.length && (
|
||||
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex flex-col items-center justify-center gap-2 text-center px-6">
|
||||
<MessageSquareText className="w-8 h-8 text-muted-foreground/50" />
|
||||
<div className="text-sm font-medium text-foreground">{t('chatHistory.emptyTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-6">{t('chatHistory.emptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<ChatHistoryListPane
|
||||
items={items}
|
||||
selectedItem={selectedItem}
|
||||
deletingId={deletingId}
|
||||
t={t}
|
||||
lang={lang}
|
||||
onSelectItem={handleSelectItem}
|
||||
onDeleteItem={handleDeleteItem}
|
||||
/>
|
||||
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={(event) => handleSelectItem(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'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{item.user_input || t('chatHistory.untitled')}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1 truncate">
|
||||
{item.model || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={clsx('px-2 py-0.5 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(item.status))}>
|
||||
{t(`chatHistory.status.${item.status || 'streaming'}`)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDeleteItem(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 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-3 line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{previewText(item) || t('chatHistory.noPreview')}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground/80 mt-3">
|
||||
{formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex-col relative">
|
||||
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ListModeIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<MergeModeIcon />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
{selectedSummary && (
|
||||
<span className={clsx('px-2.5 py-1 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(selectedSummary.status))}>
|
||||
{t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 lg:p-6 space-y-6">
|
||||
{!selectedItem && (
|
||||
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t('chatHistory.selectPrompt')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedItem && (
|
||||
<DetailConversation
|
||||
selectedItem={selectedItem}
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
bottomButtonClassName="absolute right-5 bottom-5"
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DesktopDetailPane
|
||||
selectedSummary={selectedSummary}
|
||||
selectedItem={selectedItem}
|
||||
t={t}
|
||||
lang={lang}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMobileView && mobileDetailOpen && selectedItem && (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-50 flex items-center justify-center px-3 py-4 bg-background/65 backdrop-blur-sm transition-opacity duration-200',
|
||||
mobileDetailVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onClick={closeMobileDetail}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => 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}%` }}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-border flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ListModeIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<MergeModeIcon />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMobileDetail}
|
||||
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('actions.cancel')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<MobileDetailModal
|
||||
open={isMobileView && mobileDetailOpen}
|
||||
visible={mobileDetailVisible}
|
||||
origin={mobileOrigin}
|
||||
selectedItem={selectedItem}
|
||||
t={t}
|
||||
lang={lang}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
onClose={closeMobileDetail}
|
||||
/>
|
||||
|
||||
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 space-y-6">
|
||||
<DetailConversation
|
||||
selectedItem={selectedItem}
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
bottomButtonClassName="fixed right-5 bottom-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmClearOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border bg-card shadow-2xl p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-11 w-11 rounded-2xl bg-[#111214] text-muted-foreground flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">{t('chatHistory.confirmClearTitle')}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{t('chatHistory.confirmClearDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClearOpen(false)}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/70"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('chatHistory.confirmClearAction')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmClearDialog
|
||||
open={confirmClearOpen}
|
||||
t={t}
|
||||
onCancel={() => setConfirmClearOpen(false)}
|
||||
onConfirm={async () => {
|
||||
setConfirmClearOpen(false)
|
||||
await handleClear()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
284
webui/src/features/chatHistory/ChatHistoryDetail.jsx
Normal file
284
webui/src/features/chatHistory/ChatHistoryDetail.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<div className="overflow-hidden transition-[max-height] duration-300 ease-out" style={{ maxHeight }}>
|
||||
<div ref={contentRef} className="whitespace-pre-wrap break-words">
|
||||
{visibleText}
|
||||
</div>
|
||||
</div>
|
||||
{shouldCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
className={clsx('mt-3 inline-flex items-center gap-2 text-xs font-medium transition-colors', buttonClassName)}
|
||||
>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform duration-300', expanded && 'rotate-180')} />
|
||||
{expanded ? collapseLabel : expandLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestMessages({ item, t, messages }) {
|
||||
const requestMessages = Array.isArray(messages) && messages.length > 0
|
||||
? messages
|
||||
: [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }]
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-4xl mx-auto">
|
||||
{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 (
|
||||
<div key={`${role}-${index}`} className={clsx('flex gap-4', isUser && 'flex-row-reverse justify-start')}>
|
||||
<div className={clsx(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
|
||||
isUser ? 'bg-secondary' : (isAssistant ? 'bg-muted' : 'bg-background')
|
||||
)}>
|
||||
{isUser ? <UserRound className="w-4 h-4 text-muted-foreground" /> : <Bot className="w-4 h-4 text-foreground" />}
|
||||
</div>
|
||||
<div className="max-w-[88%] lg:max-w-[78%] text-left">
|
||||
<div className={clsx('text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-2 px-1', isUser && 'text-right')}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'rounded-2xl px-5 py-3 text-sm leading-relaxed shadow-sm border whitespace-pre-wrap break-words',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground rounded-tr-sm border-primary/30'
|
||||
: (isAssistant ? 'bg-secondary/60 text-foreground rounded-tl-sm border-border' : 'bg-background text-foreground rounded-tl-sm border-border')
|
||||
)}>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{message.content || t('chatHistory.emptyUserInput')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={handleCopy} className={buttonClassName} title={copyTitle}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={handleDownload} className={buttonClassName} title={downloadTitle}>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MergedPromptView({ item, t, onMessage }) {
|
||||
const merged = item?.final_prompt || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
|
||||
style={{ backgroundColor: 'rgb(231, 176, 8)', borderColor: 'rgba(231, 176, 8, 0.45)' }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300]">
|
||||
{t('chatHistory.mergedInput')}
|
||||
</div>
|
||||
<PromptTextActions
|
||||
text={merged}
|
||||
filename={`Merged_${item?.id || 'prompt'}.txt`}
|
||||
copyTitle={t('chatHistory.copyMerged')}
|
||||
downloadTitle={t('chatHistory.downloadMerged')}
|
||||
t={t}
|
||||
onMessage={onMessage}
|
||||
buttonClassName="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
|
||||
<ExpandableText
|
||||
text={merged || t('chatHistory.emptyMergedPrompt')}
|
||||
expandLabel={t('chatHistory.expand')}
|
||||
collapseLabel={t('chatHistory.collapse')}
|
||||
buttonClassName="text-[#2f2200] hover:text-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryTextView({ item, t, onMessage }) {
|
||||
const historyText = (item?.history_text || '').trim()
|
||||
if (!historyText) return null
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto rounded-2xl border border-border bg-background px-5 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground text-left">
|
||||
HISTORY
|
||||
</div>
|
||||
<PromptTextActions
|
||||
text={historyText}
|
||||
filename={`History_${item?.id || 'history'}.txt`}
|
||||
copyTitle={t('chatHistory.copyHistory')}
|
||||
downloadTitle={t('chatHistory.downloadHistory')}
|
||||
t={t}
|
||||
onMessage={onMessage}
|
||||
buttonClassName="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"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words font-mono">
|
||||
<ExpandableText
|
||||
text={historyText}
|
||||
threshold={Math.floor(MESSAGE_COLLAPSE_AT / 4)}
|
||||
expandLabel={t('chatHistory.expand')}
|
||||
collapseLabel={t('chatHistory.collapse')}
|
||||
buttonClassName="text-foreground hover:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaGrid({ selectedItem, t }) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto rounded-xl border border-border bg-background/70 p-4 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">{t('chatHistory.metaTitle')}</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaAccount')}</div>
|
||||
<div className="text-sm font-medium text-foreground">{selectedItem.account_id || t('chatHistory.metaUnknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaElapsed')}</div>
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Clock3 className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{formatElapsed(selectedItem.elapsed_ms, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaModel')}</div>
|
||||
<div className="text-sm font-medium text-foreground break-all">{selectedItem.model || t('chatHistory.metaUnknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStatusCode')}</div>
|
||||
<div className="text-sm font-medium text-foreground">{selectedItem.status_code || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStream')}</div>
|
||||
<div className="text-sm font-medium text-foreground">{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaCaller')}</div>
|
||||
<div className="text-sm font-medium text-foreground break-all">{selectedItem.caller_id || t('chatHistory.metaUnknown')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 && <HistoryTextView item={selectedItem} t={t} onMessage={onMessage} />}
|
||||
|
||||
{viewMode === 'list'
|
||||
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
|
||||
: <MergedPromptView item={selectedItem} t={t} onMessage={onMessage} />}
|
||||
|
||||
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
|
||||
<div className={clsx(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
|
||||
selectedItem.status === 'error' ? 'bg-destructive/10 border-destructive/20' : 'bg-muted'
|
||||
)}>
|
||||
<Bot className={clsx('w-4 h-4', selectedItem.status === 'error' ? 'text-destructive' : 'text-foreground')} />
|
||||
</div>
|
||||
<div className="space-y-4 flex-1 min-w-0">
|
||||
{(selectedItem.reasoning_content || '').trim() && (
|
||||
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{t('chatHistory.reasoningTrace')}</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[12px] md:text-[13px] max-h-64 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50 break-words">
|
||||
{selectedItem.reasoning_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words">
|
||||
{selectedItem.status === 'error'
|
||||
? <span className="text-destructive font-medium">{selectedItem.error || t('chatHistory.failedOutput')}</span>
|
||||
: (selectedItem.content || t('chatHistory.emptyAssistantOutput'))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetaGrid selectedItem={selectedItem} t={t} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
250
webui/src/features/chatHistory/ChatHistoryPanels.jsx
Normal file
250
webui/src/features/chatHistory/ChatHistoryPanels.jsx
Normal file
@@ -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 (
|
||||
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ListModeIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<MergeModeIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatHistoryListPane({ items, selectedItem, deletingId, t, lang, onSelectItem, onDeleteItem }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">{t('chatHistory.listTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{items.length}</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{!items.length && (
|
||||
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex flex-col items-center justify-center gap-2 text-center px-6">
|
||||
<MessageSquareText className="w-8 h-8 text-muted-foreground/50" />
|
||||
<div className="text-sm font-medium text-foreground">{t('chatHistory.emptyTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-6">{t('chatHistory.emptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={(event) => 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'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{item.user_input || t('chatHistory.untitled')}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1 truncate">
|
||||
{item.model || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={clsx('px-2 py-0.5 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(item.status))}>
|
||||
{t(`chatHistory.status.${item.status || 'streaming'}`)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
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 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-3 line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{previewText(item) || t('chatHistory.noPreview')}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground/80 mt-3">
|
||||
{formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesktopDetailPane({ selectedSummary, selectedItem, t, lang, viewMode, setViewMode, detailScrollRef, assistantStartRef, onMessage }) {
|
||||
return (
|
||||
<div className="hidden lg:flex rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex-col relative">
|
||||
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewModeToggle t={t} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
{selectedSummary && (
|
||||
<span className={clsx('px-2.5 py-1 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(selectedSummary.status))}>
|
||||
{t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 lg:p-6 space-y-6">
|
||||
{!selectedItem && (
|
||||
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t('chatHistory.selectPrompt')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedItem && (
|
||||
<DetailConversation
|
||||
selectedItem={selectedItem}
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
bottomButtonClassName="absolute right-5 bottom-5"
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileDetailModal({ open, visible, origin, selectedItem, t, lang, viewMode, setViewMode, detailScrollRef, assistantStartRef, onClose }) {
|
||||
if (!open || !selectedItem) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-50 flex items-center justify-center px-3 py-4 bg-background/65 backdrop-blur-sm transition-opacity duration-200',
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => 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}%` }}
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-border flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewModeToggle t={t} viewMode={viewMode} setViewMode={setViewMode} mobile />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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('actions.cancel')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 space-y-6">
|
||||
<DetailConversation
|
||||
selectedItem={selectedItem}
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
bottomButtonClassName="fixed right-5 bottom-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmClearDialog({ open, t, onCancel, onConfirm }) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border bg-card shadow-2xl p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-11 w-11 rounded-2xl bg-[#111214] text-muted-foreground flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">{t('chatHistory.confirmClearTitle')}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{t('chatHistory.confirmClearDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onCancel} className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/70">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={onCancel} 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')}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm} 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">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('chatHistory.confirmClearAction')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
webui/src/features/chatHistory/HistoryModeIcons.jsx
Normal file
15
webui/src/features/chatHistory/HistoryModeIcons.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export function ListModeIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3 0h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm0 8h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MergeModeIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
250
webui/src/features/chatHistory/chatHistoryUtils.js
Normal file
250
webui/src/features/chatHistory/chatHistoryUtils.js
Normal file
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user