ce6a8a7e72
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>
215 lines
8.2 KiB
TypeScript
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);
|
|
}
|