"use client"; import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { resolveSessionTitle } from "@/lib/agent-reply"; import { fetchSessionDetail } from "@/lib/home-cloud-sync"; import { rectificationOpenIdentity } from "@/lib/rectification-session-open"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeSessionUrl } from "@/lib/chat-session-url"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { readCurrentSubjectId } from "@/lib/current-subject"; import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile"; import { timestamp, type Account, type AccountDialog, type ChartLibraryRecord, type ChatSession, type Message, type OnboardingStep, type OpenAccountDialogOptions, type Profile, type Theme, } from "@/lib/home-types"; import { entrySummaryFromResponse, isTerminalRectificationStatus, openRectificationRequestBody, openResponseFromPayload, type RectificationEntrySummary, } from "@/lib/rectification-entry"; import type { PersistedRectificationTurn, RectificationChatPanel, } from "@/components/conversational-birth-time-rectification"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { hydrateRectificationCase, parsePersistedRectificationTurns, RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE, RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY, rectificationCaseHref, type RectificationCaseSnapshotPayload, } from "@/lib/rectification-surface-state"; export type RectificationShellState = { sessionId: string | null; caseId: string | null; openingSessionId: string | null; loading: boolean; mutationPending: boolean; entrySummary: RectificationEntrySummary | null; entrySummarySettled: boolean; error: string; errorSessionId: string | null; }; export const EMPTY_RECTIFICATION_SHELL: RectificationShellState = { sessionId: null, caseId: null, openingSessionId: null, loading: false, mutationPending: false, entrySummary: null, entrySummarySettled: false, error: "", errorSessionId: null, }; function fieldSetter( setRectification: Dispatch>, key: K, ): Dispatch> { return (value) => { setRectification((current) => ({ ...current, [key]: typeof value === "function" ? (value as (prev: RectificationShellState[K]) => RectificationShellState[K])(current[key]) : value, })); }; } export function createRectificationShellSetters( setRectification: Dispatch>, ) { return { setRectificationSessionId: fieldSetter(setRectification, "sessionId"), setRectificationCaseId: fieldSetter(setRectification, "caseId"), setRectificationOpeningSessionId: fieldSetter(setRectification, "openingSessionId"), setRectificationLoading: fieldSetter(setRectification, "loading"), setRectificationMutationPending: fieldSetter(setRectification, "mutationPending"), setRectificationEntrySummary: fieldSetter(setRectification, "entrySummary"), setRectificationEntrySummarySettled: fieldSetter(setRectification, "entrySummarySettled"), setRectificationError: fieldSetter(setRectification, "error"), setRectificationErrorSessionId: fieldSetter(setRectification, "errorSessionId"), }; } export type RectificationSurfaceParams = { account: Account | null; activeSessionIdRef: MutableRefObject; chartLibrary: ChartLibraryRecord[]; creatingSession: boolean; modelCatalog: PublicLanguageModelCatalog | null; persistSession: (session: ChatSession, mode?: "create" | "update") => Promise; profile: Profile; rectification: RectificationShellState; setRectification: Dispatch>; sessionSelectionSource: MutableRefObject<"user" | "history">; sessions: ChatSession[]; setActiveSessionId: Dispatch>; setDraft: (value: string) => void; setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void; setDraftTheme: (theme: Theme | null) => void; setOnboardingStep: Dispatch>; setProfileNotice: Dispatch>; setSessions: Dispatch>; uiPreview: MutableRefObject; updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void; openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void; refreshAccount: () => Promise; rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise>; }; export function useRectificationSurface(params: RectificationSurfaceParams) { const { account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog, persistSession, profile, rectification, setRectification, sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint, setDraftTheme, setOnboardingStep, setProfileNotice, setSessions, uiPreview, updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef, } = params; const rectificationSessionId = rectification.sessionId; const rectificationCaseId = rectification.caseId; const rectificationLoading = rectification.loading; const rectificationMutationPending = rectification.mutationPending; const setRectificationSessionId = fieldSetter(setRectification, "sessionId"); const setRectificationCaseId = fieldSetter(setRectification, "caseId"); const setRectificationOpeningSessionId = fieldSetter(setRectification, "openingSessionId"); const setRectificationLoading = fieldSetter(setRectification, "loading"); const setRectificationMutationPending = fieldSetter(setRectification, "mutationPending"); const setRectificationEntrySummary = fieldSetter(setRectification, "entrySummary"); const setRectificationError = fieldSetter(setRectification, "error"); const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId"); const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationReadonly, setRectificationReadonly] = useState(false); const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false); const [rectificationTurns, setRectificationTurns] = useState([]); const [rectificationSnapshot, setRectificationSnapshot] = useState(null); const rectificationOpenInFlight = useRef(false); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); async function refreshRectificationEntrySummary() { if (!account) return; try { const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" }); if (!response.ok) return; const payload = await response.json().catch(() => null); setRectificationEntrySummary(entrySummaryFromResponse(payload)); } catch { // The CTA falls back to the server-agnostic default labels. } } async function refreshRectificationCase(caseId: string, sessionId: string) { try { const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" }); if (!response.ok) { setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY); return; } const payload = await response.json().catch(() => null); setRectificationTurns(parsePersistedRectificationTurns(payload?.turns)); } catch { setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY); } } async function openRectificationCase( intent: "homepage" | "session" | "new", exactSessionId: string | null, pendingConsultationQuestion: string | null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current || rectificationMutationPending) { sessionSelectionSource.current = "user"; return null; } const missingStep = missingProfileStep(profile); if (missingStep) { sessionSelectionSource.current = "user"; setRectificationSessionId(null); setRectificationCaseId(null); setRectificationPendingQuestion(null); setOnboardingStep(missingStep); setComposerNotice("请先完成出生资料,再开始生时校正。"); return null; } rectificationOpenInFlight.current = true; setRectificationLoading(true); setRectificationError(""); setRectificationErrorSessionId(null); // Read the selection source now: `selectSession` resets it synchronously // right after handing off, and the URL write below happens after awaits. const selectionSource = sessionSelectionSource.current; setRectificationOpeningSessionId(exactSessionId); function assignOpenError(message: string) { setRectificationError(message); setRectificationErrorSessionId(intent === "session" ? exactSessionId : null); } try { const response = await fetch("/api/rectification/cases/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)), }); const payload = await response.json().catch(() => null); if (!response.ok) { const code = payload?.code; if (code === "profile_incomplete") { assignOpenError("profile_incomplete"); handleRectificationProfileIncomplete(); } else if (code === "invalid_open_request") { assignOpenError(payload?.error || "生时校正打开请求不正确,请刷新后重试。"); } else if (code === "active_case_conflict") { assignOpenError(payload?.error || "仍有未完成的校正,请先回到当前校正。"); } else if (code === "case_session_not_found") { assignOpenError("该校正会话不存在或已结束。"); } else { assignOpenError(payload?.error || "暂时无法打开生时校正。"); } return null; } const opened = openResponseFromPayload(payload); if (!opened) { assignOpenError("生时校正服务响应不正确,请稍后重试。"); return null; } // Merge the server-created session into the local list. The browser // never generates a Case id; it only mirrors the returned binding. const existing = sessions.find((session) => session.id === opened.sessionId); const openIdentity = rectificationOpenIdentity(existing, { title: resolveSessionTitle("生时校正", undefined, { entrypoint: "birth_time_rectification", existingTitles: sessions.map((session) => session.title), }), updatedAt: timestamp(), }); const merged: ChatSession = { id: opened.sessionId, title: openIdentity.title, theme: "general", modelId: modelCatalog.defaultModelId ?? "", messages: [], createdAt: existing?.createdAt, updatedAt: openIdentity.updatedAt, sessionType: "birth_time_rectification", rectificationCaseId: opened.caseId, pinned: existing?.pinned ?? false, archivedAt: existing?.archivedAt ?? null, messagesHydrated: true, ...chartSnapshotForSession("self", chartLibrary, profile), }; setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); void persistSession(merged).catch(() => {}); // New sessions have no local title to preserve (BUG-699). The case insert // trigger already wrote the result title. Read that back so the sidebar // does not keep the minted "生时校正" after persist. if (!existing) { void fetchSessionDetail(opened.sessionId, modelCatalog).then((serverSession) => { if (!serverSession || serverSession.sessionType !== "birth_time_rectification") return; updateSession(opened.sessionId, (session) => ( session.title === serverSession.title ? session : { ...session, title: serverSession.title } )); }).catch(() => {}); } // One reveal: turns and snapshot are read before the surface mounts, so // the reader never sees an empty transcript or an empty question area // while they load. Past the deadline the surface opens with what arrived // and its own retry path fills in the rest. const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, { timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, }); setRectificationPendingQuestion(pendingConsultationQuestion); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); setRectificationSessionId(opened.sessionId); setRectificationCaseId(opened.caseId); setRectificationShouldStartOpening(opened.shouldStartOpening); setRectificationReadonly( opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status), ); setRectificationTurns(hydration.turns); setRectificationSnapshot(hydration.snapshot); activeSessionIdRef.current = opened.sessionId; setActiveSessionId(opened.sessionId); if (!uiPreview.current && selectionSource === "user") { writeSessionUrl(opened.sessionId, "push"); } if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE); void refreshRectificationEntrySummary(); return opened; } catch { assignOpenError("生时校正会话暂时无法打开,请稍后重试。"); return null; } finally { sessionSelectionSource.current = "user"; rectificationOpenInFlight.current = false; setRectificationOpeningSessionId(null); setRectificationLoading(false); } } function rectificationLimitedToSelf(): boolean { if (readCurrentSubjectId() === "self") return false; setRectificationError("生时校正暂时只支持本人。"); return true; } async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) { if (rectificationLimitedToSelf()) return; await openRectificationCase("homepage", null, pendingConsultationQuestion); } async function openRectificationSession(exactSessionId: string) { await openRectificationCase("session", exactSessionId, null); } async function reopenRectificationSession(exactSessionId: string) { setRectificationError(""); setRectificationErrorSessionId(null); await openRectificationSession(exactSessionId); } async function startNewRectification() { if (rectificationLimitedToSelf()) return; await openRectificationCase("new", null, null); } function handleRectificationProfileIncomplete() { setRectificationError("profile_incomplete"); setRectificationSessionId(null); setRectificationCaseId(null); setRectificationPendingQuestion(null); const missingStep = missingProfileStep(profile); if (missingStep) { setOnboardingStep(missingStep); setComposerNotice("请先完成出生资料,再开始生时校正。"); return; } openAccountDialog("profile"); setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。"); void refreshAccount(); } function handleRectificationMessagesChange(messages: Message[]) { if (!rectificationSessionId) return; updateSession(rectificationSessionId, (session) => ({ ...session, messages, updatedAt: timestamp(), })); } useEffect(() => { resumeRectificationSession.current = (session) => { sessionSelectionSource.current = "history"; void openRectificationSession(session.id); }; rectificationSessionOpenerRef.current = openRectificationSession; }); const rectificationPanel: RectificationChatPanel = { caseId: rectificationCaseId ?? "", sessionId: rectificationSessionId ?? "", readonly: rectificationReadonly, shouldStartOpening: rectificationShouldStartOpening, initialTurns: rectificationTurns, initialSnapshot: rectificationSnapshot, pendingConsultationQuestion: rectificationPendingQuestion, headerSlot: rectificationHeaderSlot, onMessagesChange: handleRectificationMessagesChange, onCompleted: () => { void refreshAccount(); if (rectificationCaseId && rectificationSessionId) { void refreshRectificationCase(rectificationCaseId, rectificationSessionId); } }, onPendingChange: setRectificationMutationPending, onProfileIncomplete: handleRectificationProfileIncomplete, onOpeningConsumed: () => setRectificationShouldStartOpening(false), onRestart: () => void startNewRectification(), }; return { rectificationHeaderSlot, setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession, refreshRectificationEntrySummary, refreshRectificationCase, openRectificationCase, openRectificationFromHomepage, openRectificationSession, reopenRectificationSession, startNewRectification, handleRectificationProfileIncomplete, handleRectificationMessagesChange, }; }