"use client"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { useRef, useState } from "react"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeChatSession } from "@/lib/chat-session-write-contract"; import { SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE, parseSessionUrlQuery, writeSessionUrl, } from "@/lib/chat-session-url"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { fallbackSessionId } from "@/lib/rectification-session-composer-guard"; import { sortSessions } from "@/lib/session-groups"; import { clearBirthTimeConsultationConsent, type BirthTimeConsultationConsentState, } from "@/lib/birth-time-consultation-consent"; import { activeChartStorageKey, createSession, fetchSessionDetail, fetchSessions, LoginRedirectError, lookupSessionById, mergeHydratedSession, patchSessionModel, readSessions, } from "@/lib/home-cloud-sync"; import { isListedSidebarSession, isUnsavedEmptyConsultation, replaceUnsavedEmptyConsultations, } from "@/lib/session-list-filter"; import { chartSnapshotForSession } from "@/lib/home-profile"; import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { persistSessionModelSelection, type SessionModelPersistenceQueue, } from "@/lib/session-model-persistence"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { type Account, type ChartLibraryRecord, type ChatSession, type Profile, type RequestError, type Theme, } from "@/lib/home-types"; export type SessionManagementParams = { account: Account | null; accountId: string | undefined; activeChartId: string; activeSession: ChatSession | undefined; activeSessionId: string; activeSessionIdRef: MutableRefObject; applySessionPopStateRef: MutableRefObject<(search: string) => void>; cancellationPending: boolean; chartLibrary: ChartLibraryRecord[]; creatingSession: boolean; modelCatalog: PublicLanguageModelCatalog | null; modelPersistence: MutableRefObject; modelSelectionVersions: MutableRefObject>; modelSyncFailures: MutableRefObject>; pendingSessionId: string | null; profile: Profile; rectificationSessionId: string | null; sessionDetailInFlight: MutableRefObject>; sessionSelectionSource: MutableRefObject<"user" | "history">; sessions: ChatSession[]; sessionsRef: MutableRefObject; setActiveChartId: Dispatch>; setActiveSessionId: Dispatch>; setBirthTimeConsultationConsent: Dispatch>; setCreatingSession: Dispatch>; setDraft: (value: string) => void; setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void; setDraftTheme: (theme: Theme | null) => void; setRectificationError: Dispatch>; setRectificationErrorSessionId: Dispatch>; setRequestError: Dispatch>; setSessions: Dispatch>; uiPreview: MutableRefObject; openRectificationSession: (exactSessionId: string) => Promise | void; }; export function useSessionManagement(params: SessionManagementParams) { const { account, accountId, activeChartId, activeSession, activeSessionId, activeSessionIdRef, applySessionPopStateRef, cancellationPending, chartLibrary, creatingSession, modelCatalog, modelPersistence, modelSelectionVersions, modelSyncFailures, pendingSessionId, profile, rectificationSessionId, sessionDetailInFlight, sessionSelectionSource, sessions, sessionsRef, setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, setDraft, setDraftEntrypoint, setDraftTheme, setRectificationError, setRectificationErrorSessionId, setRequestError, setSessions, uiPreview, openRectificationSession, } = params; // Owned here since 2026-09-16 (state lowering batch 2). Home reads back only // what its own JSX renders; the rest never leaves this hook. const [sessionsCursor, setSessionsCursor] = useState(null); const [sessionMenuId, setSessionMenuId] = useState(null); const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); sessionsRef.current = sessions; const loadMoreInFlight = useRef(false); const pendingCreateById = useRef(new Map()); const cloudCreatedIds = useRef(new Set()); for (const session of sessions) { if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id); } const visibleSessions = sortSessions(sessions.filter(isListedSidebarSession)); function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); } async function persistSession( session: ChatSession, mode: "create" | "update" = "update", options?: { continuedFromSessionId?: string }, ) { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; if (mode === "create" && cloudCreatedIds.current.has(session.id)) return; const continuedFromSessionId = options?.continuedFromSessionId ?? pendingCreateById.current.get(session.id)?.continuedFromSessionId; const values = mode === "create" ? { title: session.title, theme: session.theme, model_id: session.modelId, messages: [] as const, session_type: session.sessionType, rectification_case_id: session.rectificationCaseId, chart_profile_id: session.chartProfileId, chart_profile_name: session.chartProfileName, chart_profile_role: session.chartProfileRole, ...(continuedFromSessionId ? { continued_from_session_id: continuedFromSessionId } : {}), } : { title: session.title, theme: session.theme, model_id: session.modelId, chart_profile_id: session.chartProfileId, chart_profile_name: session.chartProfileName, chart_profile_role: session.chartProfileRole, }; await writeChatSession(session.id, values, mode); if (mode === "create") { pendingCreateById.current.delete(session.id); cloudCreatedIds.current.add(session.id); if (!uiPreview.current) writeSessionUrl(session.id, "push"); } } async function ensureSessionMessages(sessionId: string) { if (!sessionId || uiPreview.current) return; if (pendingSessionId === sessionId) return; if (sessionDetailInFlight.current.has(sessionId)) return; const known = sessionsRef.current.find((session) => session.id === sessionId); if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return; sessionDetailInFlight.current.add(sessionId); setSessionDetailLoadingId(sessionId); try { const detailed = await fetchSessionDetail(sessionId, modelCatalog); if (!detailed) return; setSessions((existing) => { const live = existing.find((session) => session.id === sessionId); if (live?.messagesHydrated) return existing; return mergeHydratedSession(existing, detailed); }); } catch (caught) { if (caught instanceof LoginRedirectError) return; setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); } finally { sessionDetailInFlight.current.delete(sessionId); setSessionDetailLoadingId((currentId) => currentId === sessionId ? null : currentId); } } async function continueInNewChat(prompt: { question: string; theme: Theme }) { setSessionFullPrompt(null); const sourceSessionId = activeSessionId; const created = await startNewChat( sourceSessionId ? { continuedFromSessionId: sourceSessionId } : undefined, ); if (!created) return; setDraft(prompt.question); setDraftTheme(prompt.theme); } async function renameSession(session: ChatSession) { const title = window.prompt("重命名聊天记录", session.title)?.trim(); if (!title || title === session.title) return; const nextSession = { ...session, title }; updateSession(session.id, () => nextSession); try { await persistSession(nextSession); } catch (caught) { setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); } } async function deleteSession(session: ChatSession) { if (!account) return; const previousSessions = sessions; const nextSessions = sessions.filter((item) => item.id !== session.id); setSessions(nextSessions); setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); if (activeSessionId === session.id) { activateFallbackSession(nextSessions); } try { const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); const payload = await response.json().catch(() => null) as { error?: string } | null; if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); } catch (caught) { setSessions(previousSessions); setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); } } function togglePinnedSession(sessionId: string) { const session = sessions.find((item) => item.id === sessionId); if (!session) return; const nextPinned = !session.pinned; updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); }); } async function shareSession(session: ChatSession) { const sharePayload = { share_payload_version: 1, exported_at: new Date().toISOString(), title: session.title, theme: session.theme, message_count: session.messages.length, messages: session.messages.map((message) => ({ role: message.role, text: message.text })), }; const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages }); const transcript = [ `Jyotisha 对话:${session.title}`, "", ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), "", "---- Markdown 报告 ----", reportMarkdown, "", "---- JSON 分享包 ----", JSON.stringify(sharePayload, null, 2), ].join("\n"); try { await navigator.clipboard.writeText(transcript); setComposerNotice("已复制当前聊天,可粘贴转发。"); } catch { setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); } } async function startNewChat(options?: { continuedFromSessionId?: string }): Promise { if (!account || !modelCatalog || creatingSession) return null; const nextSession = { ...createSession(modelCatalog.defaultModelId), ...chartSnapshotForSession(activeChartId, chartLibrary, profile), }; for (const session of sessions) { if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id); } pendingCreateById.current.set(nextSession.id, { continuedFromSessionId: options?.continuedFromSessionId, }); setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession)); setActiveSessionId(nextSession.id); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); setComposerNotice(""); setRequestError(null); return nextSession; } function selectSession(sessionId: string) { const nextSession = sessions.find((session) => session.id === sessionId); // A rectification session that is not the open one is not switched to // here: the surface hook opens the Case, hydrates turns and snapshot, and // only then makes it active and writes the URL, so the current view stays // put instead of flashing a plain transcript and then an empty panel. // It reads the selection source before we reset it below. const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification" && nextSession.id !== rectificationSessionId; if (!deferredRectificationSwitch) setActiveSessionId(sessionId); setDraft(""); setDraftEntrypoint(null); setComposerNotice(""); setRectificationError(""); setRectificationErrorSessionId(null); if (nextSession?.chartProfileId) { const boundChart = chartLibrary.find((record) => record.id === nextSession.chartProfileId); if (boundChart && boundChart.id !== activeChartId && accountId) { setActiveChartId(boundChart.id); localStorage.setItem(activeChartStorageKey(accountId), boundChart.id); } } if (nextSession?.sessionType === "birth_time_rectification") { setRectificationError(""); if (deferredRectificationSwitch) { // The exact sessionId is passed to the server; the server resolves // the exact Case and never switches to another rectification record. void openRectificationSession(nextSession.id); } } else { void ensureSessionMessages(sessionId); } if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") { writeSessionUrl(sessionId, "push"); } sessionSelectionSource.current = "user"; } function activateFallbackSession(candidates: ChatSession[]) { const fallbackId = fallbackSessionId(candidates); if (!fallbackId) { setActiveSessionId(""); if (!uiPreview.current) writeSessionUrl(null, "replace"); return; } const fallback = candidates.find((session) => session.id === fallbackId); if (fallback?.sessionType === "birth_time_rectification") { selectSession(fallbackId); return; } setActiveSessionId(fallbackId); if (!uiPreview.current) writeSessionUrl(fallbackId, "replace"); } async function loadMoreSessions() { if (!beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return; try { const page = await fetchSessions(undefined, { before: sessionsCursor, }); const incoming = readSessions(page.sessions, modelCatalog).sessions; for (const session of incoming) cloudCreatedIds.current.add(session.id); setSessions((current) => mergeSessionPage(current, incoming)); setSessionsCursor(page.nextCursor); } catch (caught) { if (caught instanceof LoginRedirectError) return; setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); } finally { loadMoreInFlight.current = false; } } applySessionPopStateRef.current = (search: string) => { if (uiPreview.current) return; const listed = sessionsRef.current; const query = parseSessionUrlQuery(search); const fallbackId = fallbackSessionId(listed); const requestedId = query.sessionId; if (query.present && !requestedId) { writeSessionUrl(null, "replace"); sessionSelectionSource.current = "history"; if (fallbackId) selectSession(fallbackId); else setActiveSessionId(""); setComposerNotice(SESSION_MISSING_NOTICE); return; } if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) { void lookupSessionById(requestedId, modelCatalog).then((looked) => { if (looked.status === "found") { cloudCreatedIds.current.add(looked.session.id); setSessions((current) => mergeHydratedSession(current, looked.session)); sessionSelectionSource.current = "history"; selectSession(looked.session.id); return; } if (looked.status === "missing") { writeSessionUrl(null, "replace"); sessionSelectionSource.current = "history"; if (fallbackId) selectSession(fallbackId); else setActiveSessionId(""); setComposerNotice(SESSION_MISSING_NOTICE); return; } setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE); }).catch((caught) => { if (caught instanceof LoginRedirectError) return; setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE); }); return; } if (!requestedId) { if (fallbackId) { sessionSelectionSource.current = "history"; selectSession(fallbackId); } else { setActiveSessionId(""); } return; } sessionSelectionSource.current = "history"; selectSession(requestedId); }; async function selectSessionModel(modelId: string) { const userId = account?.user.id; if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return; const selectedModel = modelCatalog.models.find((model) => model.id === modelId); const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id); if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return; const nextSession: ChatSession = retryingFailedSync ? activeSession : { ...activeSession, modelId }; const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1; modelSelectionVersions.current.set(nextSession.id, selectionVersion); if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession); setRequestError(null); setComposerNotice(""); try { await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( async ({ values, sessionId }) => { if (process.env.NODE_ENV === "development" && uiPreview.current) { return { found: true, error: null }; } try { await patchSessionModel(sessionId, values.model_id); return { found: true, error: null }; } catch (error) { return { found: false, error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。", }; } }, userId, nextSession.id, modelId, )); if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; modelSelectionVersions.current.delete(nextSession.id); modelSyncFailures.current.delete(nextSession.id); } catch (caught) { if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; modelSelectionVersions.current.delete(nextSession.id); modelSyncFailures.current.add(nextSession.id); if (activeSessionIdRef.current === nextSession.id) { setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`); } setRequestError({ sessionId: nextSession.id, message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。", }); } } return { visibleSessions, sessionsCursor, setSessionsCursor, sessionMenuId, setSessionMenuId, pendingSessionDeletion, setPendingSessionDeletion, setSessionFullPrompt, updateSession, persistSession, ensureSessionMessages, continueInNewChat, renameSession, deleteSession, togglePinnedSession, shareSession, startNewChat, selectSession, selectSessionModel, loadMoreSessions, }; }