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 }>( schema: z.ZodType, ): z.ZodType { 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 { 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; const patch: Record = {}; 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 { 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); }