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
+1
View File
@@ -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>
)
}
+1 -3
View File
@@ -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,
}))
}