fix(chat): make the server the only writer of session messages
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

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:
Jesse_Chen
2026-09-01 19:51:19 +08:00
co-authored by Cursor
parent b98777aa5c
commit b6989c3eea
23 changed files with 871 additions and 150 deletions
+52 -5
View File
@@ -71,6 +71,7 @@ import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga";
import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history";
import { z } from "zod";
export const runtime = "nodejs";
@@ -94,6 +95,7 @@ const chatRequestMetadataSchema = z.object({
}),
)
.max(20)
.optional()
.default([]),
});
@@ -143,6 +145,11 @@ const consultationCompletionSchema = z.object({
error_code: z.string().nullable().optional(),
});
const consultationQuestionAppendSchema = z.object({
success: z.boolean(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
@@ -273,7 +280,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type")
.select("id,model_id,model_config_version,session_type,messages")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
@@ -329,11 +336,11 @@ export async function POST(request: Request) {
const consultationTheme = parsed.data.theme;
const visibleQuestion = parsed.data.question;
// Client `history` stays in the request schema for old bundles and is not read.
const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages);
const userControlledPrompt = [
parsed.data.question,
...parsed.data.history
.filter((message) => message.role === "user")
.map((message) => message.text),
...storedHistory.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
@@ -504,6 +511,46 @@ export async function POST(request: Request) {
}
}
let appendedQuestion: { success: boolean; error_code?: string | null };
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_question_message: { role: "user", text: visibleQuestion },
});
const parsedAppend = consultationQuestionAppendSchema.safeParse(first(data ?? []));
if (error || !parsedAppend.success) {
throw new CreditRpcError(error?.message || "invalid_question_append_response");
}
return parsedAppend.data;
});
} catch {
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!appendedQuestion.success) {
await cancel();
if (appendedQuestion.error_code === "session_full") {
return NextResponse.json(
{
error: "这段对话已写满",
message: "这段对话已写满,开个新对话继续吧",
code: "session_full",
},
{ status: 409 },
);
}
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
const usageStartedAt = Date.now();
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
@@ -1044,7 +1091,7 @@ export async function POST(request: Request) {
}
try {
const { history } = parsed.data;
const history = storedHistory;
const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const generalDailyContext = shouldLoadGeneralDailyPanchanga({