import { z } from "zod"; import { conversationalRectificationTurnSchema, type ConversationalRectificationTurn, } from "./conversational-rectification/contracts.ts"; export type RectificationQuestionHandoff = Readonly<{ question: string; sessionId: string; theme: Theme; }>; type HandoffFallback = Readonly<{ sessionId: string; theme: Theme; }>; type ContinueOriginalQuestion = ( handoff: RectificationQuestionHandoff, ) => Promise; function normalizedQuestion(value: string | null | undefined): string | null { if (typeof value !== "string") return null; const question = value.trim(); return question.length > 0 && question.length <= 500 ? question : null; } function sameHandoff( left: RectificationQuestionHandoff | null, right: RectificationQuestionHandoff, ) { return left?.question === right.question && left.sessionId === right.sessionId && left.theme === right.theme; } /** * Keeps the presentation-only session/theme context beside the question that * the v3 case persists. The durable question always wins after refresh; local * context is retained only while it still belongs to that same question. */ export function createRectificationQuestionHandoffCoordinator() { let current: RectificationQuestionHandoff | null = null; let activeContinuation: Promise | null = null; let activeContinuationToken: symbol | null = null; let consumedQuestion: string | null = null; const fromDurableQuestion = ( questionValue: string | null | undefined, fallback: HandoffFallback, ): RectificationQuestionHandoff | null => { const question = normalizedQuestion(questionValue); if (!question) return current; if (current?.question === question) return current; if (consumedQuestion === question) return null; if (!fallback.sessionId) return null; current = Object.freeze({ question, ...fallback }); return current; }; return Object.freeze({ capture(input: RectificationQuestionHandoff) { const question = normalizedQuestion(input.question); if (!question || !input.sessionId) { throw new TypeError("A visible question and session are required for rectification handoff"); } consumedQuestion = null; current = Object.freeze({ ...input, question }); return current; }, synchronizeDurableQuestion: fromDurableQuestion, peek() { return current; }, clear() { current = null; }, continueOriginalQuestion( questionValue: string, fallback: HandoffFallback, send: ContinueOriginalQuestion, ) { if (activeContinuation) return activeContinuation; const handoff = fromDurableQuestion(questionValue, fallback); if (!handoff) return Promise.resolve(false); const token = Symbol("rectification-question-continuation"); const operation = Promise.resolve() .then(() => send(handoff)) .then((completed) => { if (completed && sameHandoff(current, handoff)) { current = null; consumedQuestion = handoff.question; } return completed; }) .finally(() => { if (activeContinuationToken === token) { activeContinuation = null; activeContinuationToken = null; } }); activeContinuation = operation; activeContinuationToken = token; return operation; }, }); } export type RectificationQuestionHandoffCoordinator = ReturnType< typeof createRectificationQuestionHandoffCoordinator >; const durableHandoffSchema = z.object({ caseId: z.string().uuid(), turnVersion: z.number().int().nonnegative(), question: z.string().trim().min(1).max(500), questionFingerprint: z.string().regex(/^[0-9a-f]{64}$/), requestId: z.string().uuid(), status: z.enum(["pending", "claimed", "in_progress", "consumed"]), turn: conversationalRectificationTurnSchema, }).strict(); export type DurableRectificationQuestionHandoff = z.infer; export type ClaimedRectificationQuestionHandoff = DurableRectificationQuestionHandoff & Readonly<{ claimActionId: string; }>; export class DurableRectificationHandoffError extends Error { readonly name = "DurableRectificationHandoffError"; constructor( readonly status: number, readonly code: string | null, message: string, ) { super(message); } } type HandoffFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; async function handoffPayload(response: Response): Promise { if (response.status === 204) return null; const contentType = response.headers.get("content-type") ?? ""; return contentType.includes("application/json") ? response.json().catch(() => null) : null; } function errorFields(value: unknown): { code: string | null; message: string } { if (!value || typeof value !== "object") { return { code: null, message: "暂时无法保存或继续原问题,请稍后重试。" }; } const record = value as { code?: unknown; message?: unknown }; return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? record.message : "暂时无法保存或继续原问题,请稍后重试。", }; } /** * Browser transport for the account-level handoff. A claim keeps one action id * until the server returns a terminal response, so a lost HTTP response replays * the same durable identity rather than creating another consultation attempt. */ export function createDurableRectificationQuestionHandoffClient(input: Readonly<{ fetch?: HandoffFetch; createActionId?: () => string; }> = {}) { const fetcher = input.fetch ?? globalThis.fetch.bind(globalThis); const createActionId = input.createActionId ?? (() => globalThis.crypto.randomUUID()); const claimActions = new Map(); async function post(command: Readonly>): Promise { const body = JSON.stringify(command); let lastError: unknown = null; for (let attempt = 0; attempt < 2; attempt += 1) { try { const response = await fetcher("/api/birth-time-conversation/handoff", { method: "POST", headers: { "content-type": "application/json" }, body, }); const payload = await handoffPayload(response); if (!response.ok) { const fields = errorFields(payload); throw new DurableRectificationHandoffError( response.status, fields.code, fields.message, ); } return payload; } catch (error) { lastError = error; if (error instanceof DurableRectificationHandoffError || attempt > 0) throw error; } } throw lastError; } return Object.freeze({ async load(): Promise { const response = await fetcher("/api/birth-time-conversation/handoff", { method: "GET", headers: { accept: "application/json" }, }); const payload = await handoffPayload(response); if (response.status === 204) return null; if (!response.ok) { const fields = errorFields(payload); throw new DurableRectificationHandoffError(response.status, fields.code, fields.message); } return durableHandoffSchema.parse(payload); }, async attach(request: Readonly<{ caseId: string; turnVersion: number; question: string; actionId?: string; }>): Promise { const payload = await post({ type: "attach", caseId: request.caseId, turnVersion: request.turnVersion, actionId: request.actionId ?? createActionId(), question: request.question.trim(), }); return conversationalRectificationTurnSchema.parse(payload); }, async claim(request: Readonly<{ caseId: string; turnVersion: number; question: string; }>): Promise { const identity = JSON.stringify([ request.caseId, request.turnVersion, request.question.trim(), ]); const actionId = claimActions.get(identity) ?? createActionId(); claimActions.set(identity, actionId); const payload = durableHandoffSchema.parse(await post({ type: "claim", caseId: request.caseId, turnVersion: request.turnVersion, actionId, question: request.question.trim(), })); if (payload.status !== "claimed") claimActions.delete(identity); return Object.freeze({ ...payload, claimActionId: actionId }); }, }); } export type DurableRectificationQuestionHandoffClient = ReturnType< typeof createDurableRectificationQuestionHandoffClient >;