feat(session): add per-message created_at timestamps

- Persistence layer (jsonl.go addMsg/SetHistory) normalizes CreatedAt
  when missing so the invariant is guaranteed at the storage boundary
- API layer (session.go) exposes created_at on all transcript message
  types with session.updated fallback for legacy messages
- Frontend uses per-message timestamps when available
- messagesContentEqual ignores CreatedAt for tail-matching after
  JSONL roundtrip

Fixes #2787
This commit is contained in:
LiusCraft
2026-05-11 00:45:01 +08:00
parent 2992eccbf0
commit 81bbef62b1
10 changed files with 206 additions and 14 deletions
+15 -2
View File
@@ -51,6 +51,7 @@ type sessionChatMessage struct {
Content string `json:"content"`
Kind string `json:"kind,omitempty"`
ModelName string `json:"model_name,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
Media []string `json:"media,omitempty"`
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
@@ -512,6 +513,7 @@ func sessionTranscriptMessages(
Role: "user",
Content: msg.Content,
ModelName: msg.ModelName,
CreatedAt: msg.CreatedAt,
Media: append([]string(nil), msg.Media...),
Attachments: attachments,
}
@@ -533,8 +535,9 @@ func sessionTranscriptMessages(
msg.ToolCalls,
msg.ModelName,
toolFeedbackMaxArgsLength,
msg.CreatedAt,
)
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.ModelName)
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls, msg.ModelName, msg.CreatedAt)
// Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed
@@ -560,6 +563,7 @@ func sessionTranscriptMessages(
Role: "assistant",
Content: content,
ModelName: msg.ModelName,
CreatedAt: msg.CreatedAt,
Media: append([]string(nil), msg.Media...),
Attachments: attachments,
}
@@ -690,6 +694,7 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
Content: reasoning,
Kind: "thought",
ModelName: msg.ModelName,
CreatedAt: msg.CreatedAt,
}, true
}
@@ -697,6 +702,7 @@ func assistantToolCallsMessage(
toolCalls []providers.ToolCall,
modelName string,
toolFeedbackMaxArgsLength int,
createdAt *time.Time,
) (sessionChatMessage, bool) {
if len(toolCalls) == 0 {
return sessionChatMessage{}, false
@@ -714,6 +720,7 @@ func assistantToolCallsMessage(
Role: "assistant",
Kind: "tool_calls",
ModelName: modelName,
CreatedAt: createdAt,
ToolCalls: visibleToolCalls,
}, true
}
@@ -725,7 +732,7 @@ func visibleAssistantToolArgsPreview(
return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
}
func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName string) []sessionChatMessage {
func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName string, createdAt *time.Time) []sessionChatMessage {
if len(toolCalls) == 0 {
return nil
}
@@ -744,6 +751,7 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall, modelName stri
Role: "assistant",
Content: content,
ModelName: modelName,
CreatedAt: createdAt,
})
}
@@ -926,6 +934,11 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
}
}
for i := range sess.Messages {
if sess.Messages[i].CreatedAt == nil {
sess.Messages[i].CreatedAt = &sess.Updated
}
}
messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
w.Header().Set("Content-Type", "application/json")