mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SessionDetail {
|
||||
messages: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
created_at?: string
|
||||
kind?: "normal" | "thought" | "tool_calls"
|
||||
model_name?: string
|
||||
media?: string[]
|
||||
|
||||
@@ -117,12 +117,17 @@ export function AssistantMessage({
|
||||
<span className="text-muted-foreground/45">{trimmedModelName}</span>
|
||||
)}
|
||||
</div>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
|
||||
isExpanded ? "rotate-180" : "",
|
||||
<div className="flex items-center gap-2">
|
||||
{formattedTimestamp && (
|
||||
<span className="opacity-50">{formattedTimestamp}</span>
|
||||
)}
|
||||
/>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
|
||||
isExpanded ? "rotate-180" : "",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && (
|
||||
|
||||
@@ -384,6 +384,7 @@ export function ChatPage() {
|
||||
<UserMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string
|
||||
attachments?: ChatAttachment[]
|
||||
timestamp?: string | number
|
||||
}
|
||||
|
||||
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
export function UserMessage({
|
||||
content,
|
||||
attachments = [],
|
||||
timestamp = "",
|
||||
}: UserMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copy, isCopied } = useCopyToClipboard()
|
||||
const hasText = content.trim().length > 0
|
||||
@@ -22,6 +28,8 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
const copyMessageLabel = isCopied
|
||||
? t("chat.copiedLabel")
|
||||
: t("chat.copyMessage")
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
||||
return (
|
||||
<div className="group flex w-full flex-col items-end gap-1.5">
|
||||
@@ -81,6 +89,12 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedTimestamp && (
|
||||
<span className="px-1 text-[12px] text-zinc-400">
|
||||
{formattedTimestamp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ export async function loadSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<ChatMessage[]> {
|
||||
const detail = await getSessionHistory(sessionId)
|
||||
const fallbackTime = detail.updated
|
||||
|
||||
return detail.messages.map((message, index) => ({
|
||||
id: `hist-${index}-${Date.now()}`,
|
||||
role: message.role,
|
||||
@@ -59,7 +57,7 @@ export async function loadSessionMessages(
|
||||
media: message.media,
|
||||
attachments: message.attachments,
|
||||
}),
|
||||
timestamp: fallbackTime,
|
||||
timestamp: message.created_at ?? detail.updated,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user