fix(chat): make the server the only writer of session messages
List GET no longer ships transcripts; consult appends questions after reserve and ignores client history so dual-tab last-write-wins cannot erase messages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,8 +4,13 @@ export const chatNoticeToastId = "chat-notice";
|
||||
|
||||
export type NoticeTone = "info" | "success" | "error";
|
||||
|
||||
export type ChatNoticeAction = Readonly<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
const ongoingNotice = /正在|请稍候|请先|联网后/;
|
||||
const failedNotice = /失败|无法|不可用|未找到/;
|
||||
const failedNotice = /失败|无法|不可用|未找到|已写满/;
|
||||
const settledNotice = /^已|^回答已恢复/;
|
||||
|
||||
export function noticeTone(message: string): NoticeTone {
|
||||
@@ -17,23 +22,27 @@ export function noticeTone(message: string): NoticeTone {
|
||||
|
||||
let lastNotice = "";
|
||||
|
||||
export function showChatNotice(message: string) {
|
||||
export function showChatNotice(message: string, action?: ChatNoticeAction) {
|
||||
if (!message.trim()) {
|
||||
if (!lastNotice) return;
|
||||
lastNotice = "";
|
||||
toast.dismiss(chatNoticeToastId);
|
||||
return;
|
||||
}
|
||||
if (lastNotice === message) return;
|
||||
if (lastNotice === message && !action) return;
|
||||
lastNotice = message;
|
||||
const tone = noticeTone(message);
|
||||
const options = {
|
||||
id: chatNoticeToastId,
|
||||
...(action ? { action: { label: action.label, onClick: action.onClick }, duration: Infinity } : {}),
|
||||
};
|
||||
if (tone === "success") {
|
||||
toast.success(message, { id: chatNoticeToastId });
|
||||
toast.success(message, options);
|
||||
return;
|
||||
}
|
||||
if (tone === "error") {
|
||||
toast.error(message, { id: chatNoticeToastId });
|
||||
toast.error(message, options);
|
||||
return;
|
||||
}
|
||||
toast(message, { id: chatNoticeToastId });
|
||||
toast(message, options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { logAgentObservability } from "@/lib/agent-observability";
|
||||
|
||||
export function hashedUserId(userId: string): string {
|
||||
return createHash("sha256").update(userId).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function logIgnoredSessionMessages(sessionId: string, userId: string, messageCount: number): void {
|
||||
logAgentObservability({
|
||||
sessionId,
|
||||
requestId: `compat-${hashedUserId(userId)}`,
|
||||
errorCode: "compat_messages_ignored",
|
||||
evidenceCount: Math.max(0, messageCount),
|
||||
});
|
||||
}
|
||||
@@ -27,16 +27,48 @@ const chatMessageSchema = z.object({
|
||||
workflowReceipt: workflowReceiptSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const chatSessionWriteObjectSchema = z.object({
|
||||
const chartBindingSchema = {
|
||||
chart_profile_id: z.string().trim().max(100).nullable().optional(),
|
||||
chart_profile_name: z.string().trim().max(80).nullable().optional(),
|
||||
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
|
||||
};
|
||||
|
||||
export const chatSessionMetadataPatchSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160).optional(),
|
||||
theme: consultationDomainSchema.optional(),
|
||||
model_id: z.string().trim().min(1).max(64).optional(),
|
||||
...chartBindingSchema,
|
||||
}).strict().refine(
|
||||
(value) => Object.values(value).some((field) => field !== undefined),
|
||||
{ message: "empty_metadata_patch" },
|
||||
);
|
||||
|
||||
export const chatSessionModelPatchSchema = z.object({
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
}).strict();
|
||||
|
||||
export const chatSessionCreateSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().trim().min(1).max(160),
|
||||
theme: consultationDomainSchema,
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
// Former value: `.max(CHAT_SESSION_MAX_MESSAGES)` with transcript contents.
|
||||
// Create is no longer a history-import path; only an empty array is accepted.
|
||||
messages: z.array(chatMessageSchema).max(0),
|
||||
session_type: z.enum(["consultation", "birth_time_rectification"]),
|
||||
rectification_case_id: z.string().uuid().nullable(),
|
||||
...chartBindingSchema,
|
||||
updated_at: z.string().datetime().optional(),
|
||||
}).strict();
|
||||
|
||||
const chatSessionLegacyWriteObjectSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
theme: consultationDomainSchema,
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES),
|
||||
session_type: z.enum(["consultation", "birth_time_rectification"]),
|
||||
rectification_case_id: z.string().uuid().nullable(),
|
||||
chart_profile_id: z.string().trim().max(100).nullable().optional(),
|
||||
chart_profile_name: z.string().trim().max(80).nullable().optional(),
|
||||
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
|
||||
...chartBindingSchema,
|
||||
updated_at: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
@@ -59,12 +91,9 @@ function limitTranscriptSize<Output extends { messages: Array<{ text: string; th
|
||||
});
|
||||
}
|
||||
|
||||
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionWriteObjectSchema);
|
||||
export const chatSessionCreateSchema = limitTranscriptSize(
|
||||
chatSessionWriteObjectSchema.extend({
|
||||
id: z.string().uuid(),
|
||||
}).strict(),
|
||||
);
|
||||
// Former applied write contract. PATCH still parses this shape so old bundles
|
||||
// are accepted, then the messages field is ignored rather than stored.
|
||||
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionLegacyWriteObjectSchema);
|
||||
|
||||
export class ChatSessionBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
@@ -84,9 +113,39 @@ export async function readChatSessionJson(request: Request): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export const chatSessionModelPatchSchema = z.object({
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
}).strict();
|
||||
export function extractChatSessionMetadataPatch(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ("title" in record) patch.title = record.title;
|
||||
if ("theme" in record) patch.theme = record.theme;
|
||||
if ("model_id" in record) patch.model_id = record.model_id;
|
||||
if ("chart_profile_id" in record) patch.chart_profile_id = record.chart_profile_id;
|
||||
if ("chart_profile_name" in record) patch.chart_profile_name = record.chart_profile_name;
|
||||
if ("chart_profile_role" in record) patch.chart_profile_role = record.chart_profile_role;
|
||||
return patch;
|
||||
}
|
||||
|
||||
export type ChatSessionMetadataPatch = Readonly<{
|
||||
title?: string;
|
||||
theme?: ConsultationDomain;
|
||||
model_id?: string;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
}>;
|
||||
|
||||
export type ChatSessionCreate = Readonly<{
|
||||
title: string;
|
||||
theme: ConsultationDomain;
|
||||
model_id: string;
|
||||
messages: readonly [];
|
||||
session_type: "consultation" | "birth_time_rectification";
|
||||
rectification_case_id: string | null;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
}>;
|
||||
|
||||
export type ChatSessionWrite = Readonly<{
|
||||
title: string;
|
||||
@@ -107,7 +166,7 @@ export type ChatSessionWrite = Readonly<{
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
updated_at: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
|
||||
function retryableStatus(status: number) {
|
||||
@@ -118,7 +177,7 @@ class TerminalChatSessionWriteError extends Error {}
|
||||
|
||||
export async function writeChatSession(
|
||||
id: string,
|
||||
values: ChatSessionWrite,
|
||||
values: ChatSessionCreate | ChatSessionMetadataPatch,
|
||||
mode: "create" | "update",
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const CONSULTATION_HISTORY_LIMIT = 12;
|
||||
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 4_000;
|
||||
|
||||
export type ConsultationHistoryMessage = Readonly<{
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}>;
|
||||
|
||||
export function consultationHistoryFromStoredMessages(
|
||||
messages: unknown,
|
||||
options: { excludeRequestId?: string } = {},
|
||||
): ConsultationHistoryMessage[] {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
const rows: ConsultationHistoryMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
|
||||
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) continue;
|
||||
if (stored.role !== "user" && stored.role !== "assistant") continue;
|
||||
if (typeof stored.text !== "string" || !stored.text) continue;
|
||||
rows.push({
|
||||
role: stored.role,
|
||||
text: stored.text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS),
|
||||
});
|
||||
}
|
||||
return rows.slice(-CONSULTATION_HISTORY_LIMIT);
|
||||
}
|
||||
Reference in New Issue
Block a user