fix(chat): 问答回答时间戳不再漂移到当前电脑时间
AI 回答无自身时间戳,原先在首次渲染时用 new Date() 兜底,导致 未在原会话完成、或从历史重新加载的回答显示加载时刻而非真实时间。 改为让 AI 回答继承同轮提问的可靠 client_ts(turnTime),并停止给 AI 消息打 now 戳;显示层优先用提问时间,可自愈历史错误时间。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c1143564ef
commit
6989dd45f1
2 files changed
+52
-7
No files matched your search
@@ -138,6 +138,7 @@ export function MessageListItem({
|
|||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
|
turnTime,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fitRowToContent?: boolean;
|
fitRowToContent?: boolean;
|
||||||
@@ -154,6 +155,13 @@ export function MessageListItem({
|
|||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
|
/**
|
||||||
|
* Durable ask time of the turn this message belongs to (the preceding
|
||||||
|
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
||||||
|
* timestamp of its own — without it the answer would fall back to the current
|
||||||
|
* wall clock at render time and appear to "drift" to now on every reload.
|
||||||
|
*/
|
||||||
|
turnTime?: string;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
const messageMarkdown =
|
const messageMarkdown =
|
||||||
@@ -182,6 +190,7 @@ export function MessageListItem({
|
|||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onMessageDeleted={onMessageDeleted}
|
onMessageDeleted={onMessageDeleted}
|
||||||
|
turnTime={turnTime}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
Human messages carry their own inline copy/edit toolbar inside
|
Human messages carry their own inline copy/edit toolbar inside
|
||||||
@@ -390,6 +399,7 @@ function MessageContent_({
|
|||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onMessageDeleted,
|
onMessageDeleted,
|
||||||
|
turnTime,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -400,6 +410,7 @@ function MessageContent_({
|
|||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
|
turnTime?: string;
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
|
|
||||||
@@ -456,9 +467,15 @@ function MessageContent_({
|
|||||||
[threadId, resolveArtifact],
|
[threadId, resolveArtifact],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
||||||
|
// The AI answer has no timestamp of its own, so it inherits the turn's ask
|
||||||
|
// time (its question's `client_ts`, threaded down as `turnTime`). This is
|
||||||
|
// durable and turn-accurate, and — unlike the old per-id `now` fallback —
|
||||||
|
// never drifts to the current clock when an unfinished answer is reloaded.
|
||||||
|
const ownAskTime = message.id ? askTimes[message.id] : undefined;
|
||||||
const messageTime = formatAskedTime(
|
const messageTime = formatAskedTime(
|
||||||
message.additional_kwargs?.client_ts ??
|
message.additional_kwargs?.client_ts ??
|
||||||
(message.id ? askTimes[message.id] : undefined),
|
(isHuman ? ownAskTime : (turnTime ?? ownAskTime)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawContent = extractContentFromMessage(message);
|
const rawContent = extractContentFromMessage(message);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { rememberAskTimes } from "@/core/chat-timestamps";
|
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -167,15 +167,24 @@ export function MessageList({
|
|||||||
[allMessages, hiddenMessageIds],
|
[allMessages, hiddenMessageIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stamp a wall-clock time onto every question/answer the first time it is
|
const askTimes = useChatAskTimes();
|
||||||
// seen, in a single batched commit. Questions sent this session already carry
|
|
||||||
// an accurate time (see useThreadStream); this also covers assistant answers
|
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
||||||
// and messages loaded from history. First-write-wins keeps the earliest time.
|
// a single batched commit. Questions sent this session already carry an
|
||||||
|
// accurate `client_ts` (see useThreadStream) and are skipped; this only backs
|
||||||
|
// up legacy questions loaded from history that predate `client_ts`.
|
||||||
|
// First-write-wins keeps the earliest time.
|
||||||
|
//
|
||||||
|
// AI answers are deliberately NOT stamped here: they carry no timestamp of
|
||||||
|
// their own, so a `now` stamp would be the *load* time, not the answer time —
|
||||||
|
// and for an unfinished/interrupted turn that re-stamps to the current clock
|
||||||
|
// on every reload. Instead the answer inherits its question's durable time
|
||||||
|
// via `turnTimes` below.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const entries: Record<string, string> = {};
|
const entries: Record<string, string> = {};
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type !== "human" && message.type !== "ai") continue;
|
if (message.type !== "human") continue;
|
||||||
if (!message.id || message.id.startsWith("opt-")) continue;
|
if (!message.id || message.id.startsWith("opt-")) continue;
|
||||||
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
||||||
entries[message.id] = now;
|
entries[message.id] = now;
|
||||||
@@ -183,6 +192,24 @@ export function MessageList({
|
|||||||
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// Durable ask time per message id, used to time-stamp the AI answer (which has
|
||||||
|
// no timestamp of its own) with the time of the question that triggered it.
|
||||||
|
const turnTimes = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
let lastQuestionTime: string | undefined;
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type === "human") {
|
||||||
|
const ts = message.additional_kwargs?.client_ts;
|
||||||
|
const fromAskTimes = message.id ? askTimes[message.id] : undefined;
|
||||||
|
lastQuestionTime =
|
||||||
|
typeof ts === "string" && ts ? ts : fromAskTimes;
|
||||||
|
} else if (message.type === "ai" && message.id && lastQuestionTime) {
|
||||||
|
map.set(message.id, lastQuestionTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [messages, askTimes]);
|
||||||
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
@@ -358,6 +385,7 @@ export function MessageList({
|
|||||||
onMessageDeleted={
|
onMessageDeleted={
|
||||||
allowDelete ? handleMessageDeleted : undefined
|
allowDelete ? handleMessageDeleted : undefined
|
||||||
}
|
}
|
||||||
|
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user