Files
Jyotisha/frontend/src/lib/chat-session-write-contract.ts
T
Jesse_Chen ce6a8a7e72
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(chat): make cloud the only truth for charts, synastry, and pin/archive
Local fallbacks were creating fake saves and resurrecting deleted rows.
Pin and archive now live on chat_sessions so they follow the account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:38:21 +08:00

215 lines
8.2 KiB
TypeScript

import { z } from "zod";
import {
agentExecutionReceiptSchema,
workflowReceiptSchema,
type AgentExecutionReceipt,
type WorkflowReceipt,
} from "./consultation-agent-events.ts";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
import { publicThinkingSectionSchema, type PublicThinkingSection } from "./consultation-thinking-plan.ts";
export const CHAT_SESSION_MAX_MESSAGES = 200;
export const CHAT_SESSION_MAX_MESSAGE_CHARS = 16_000;
export const CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS = 200_000;
export const CHAT_SESSION_MAX_BODY_CHARS = 500_000;
const chatMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(CHAT_SESSION_MAX_MESSAGE_CHARS),
// Nothing writes suggestions since the follow-up chips were removed, but this schema
// is strict and a client running the previous bundle still sends them; rejecting the
// whole write would lose that user's message rather than a dead field.
suggestions: z.array(z.string().max(200)).max(3).optional(),
thinkingText: z.string().max(4_000).optional(),
thinkingSections: z.array(publicThinkingSectionSchema).max(12).optional(),
techniqueTruth: z.string().max(120).optional(),
agentExecutionReceipt: agentExecutionReceiptSchema.optional(),
workflowReceipt: workflowReceiptSchema.optional(),
}).strict();
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(),
pinned: z.boolean().optional(),
archived_at: z.string().datetime().nullable().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(),
...chartBindingSchema,
updated_at: z.string().datetime(),
}).strict();
function limitTranscriptSize<Output extends { messages: Array<{ text: string; thinkingText?: string; thinkingSections?: unknown }> }>(
schema: z.ZodType<Output>,
): z.ZodType<Output> {
return schema.superRefine((value, context) => {
const totalChars = value.messages.reduce(
(sum, message) => sum + message.text.length + (message.thinkingText?.length ?? 0)
+ (message.thinkingSections ? JSON.stringify(message.thinkingSections).length : 0),
0,
);
if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["messages"],
message: "聊天记录过长",
});
}
});
}
// 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() {
super("聊天记录过长");
this.name = "ChatSessionBodyTooLargeError";
}
}
export async function readChatSessionJson(request: Request): Promise<unknown> {
const raw = await request.text().catch(() => "");
if (raw.length > CHAT_SESSION_MAX_BODY_CHARS) throw new ChatSessionBodyTooLargeError();
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
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;
if ("pinned" in record) patch.pinned = record.pinned;
if ("archived_at" in record) patch.archived_at = record.archived_at;
return patch;
}
export type ChatSessionMetadataPatch = Readonly<{
title?: string;
theme?: ConsultationDomain;
model_id?: string;
pinned?: boolean;
archived_at?: string | null;
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;
theme: ConsultationDomain;
model_id: string;
messages: readonly Readonly<{
role: "user" | "assistant";
text: string;
suggestions?: readonly string[];
thinkingText?: string;
thinkingSections?: readonly PublicThinkingSection[];
techniqueTruth?: string;
agentExecutionReceipt?: AgentExecutionReceipt;
workflowReceipt?: WorkflowReceipt;
}>[];
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;
updated_at?: string;
}>;
function retryableStatus(status: number) {
return status === 408 || status === 429 || status >= 500;
}
class TerminalChatSessionWriteError extends Error {}
export async function writeChatSession(
id: string,
values: ChatSessionCreate | ChatSessionMetadataPatch,
mode: "create" | "update",
fetcher: typeof fetch = fetch,
): Promise<void> {
const url = mode === "create" ? "/api/sessions" : `/api/sessions/${encodeURIComponent(id)}`;
const body = mode === "create" ? { id, ...values } : values;
let lastError = "云端同步暂时不可用";
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await fetcher(url, {
method: mode === "create" ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null) as { error?: string } | null;
if (response.ok) return;
lastError = payload?.error || "云端同步暂时不可用";
if (!retryableStatus(response.status)) throw new TerminalChatSessionWriteError(lastError);
} catch (error) {
if (error instanceof TerminalChatSessionWriteError) throw error;
lastError = error instanceof TypeError
? "网络暂时不可用,云端记录尚未更新"
: error instanceof Error ? error.message : lastError;
}
if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(lastError);
}