Files
Jyotisha/frontend/src/hooks/use-session-management.ts
T

602 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
createSession,
fetchSessionDetail,
fetchSessions,
LoginRedirectError,
lookupSessionById,
mergeHydratedSession,
patchSessionModel,
readSessions,
} from "@/lib/home-cloud-sync";
import {
isListedSidebarSession,
isUnsavedEmptyConsultation,
replaceUnsavedEmptyConsultations,
} from "@/lib/session-list-filter";
import {
chatSessionSubjectLocked,
emptySessionBindingWrite,
NEW_CHAT_SUBJECT_ID,
} from "@/lib/chat-profile-picker-model";
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
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<string>;
applySessionPopStateRef: MutableRefObject<(search: string) => void>;
cancellationPending: boolean;
chartLibrary: ChartLibraryRecord[];
creatingSession: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
modelPersistence: MutableRefObject<SessionModelPersistenceQueue>;
modelSelectionVersions: MutableRefObject<Map<string, number>>;
modelSyncFailures: MutableRefObject<Set<string>>;
pendingSessionId: string | null;
profile: Profile;
rectificationSessionId: string | null;
sessionDetailInFlight: MutableRefObject<Set<string>>;
sessionSelectionSource: MutableRefObject<"user" | "history">;
sessions: ChatSession[];
sessionsRef: MutableRefObject<ChatSession[]>;
setActiveChartId: Dispatch<SetStateAction<string>>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
setBirthTimeConsultationConsent: Dispatch<SetStateAction<BirthTimeConsultationConsentState>>;
setCreatingSession: Dispatch<SetStateAction<boolean>>;
setDraft: (value: string) => void;
setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void;
setDraftTheme: (theme: Theme | null) => void;
setRectificationError: Dispatch<SetStateAction<string>>;
setRectificationErrorSessionId: Dispatch<SetStateAction<string | null>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
};
export function useSessionManagement(params: SessionManagementParams) {
const {
account,
activeSession,
activeSessionId,
activeSessionIdRef,
applySessionPopStateRef,
cancellationPending,
chartLibrary,
creatingSession,
modelCatalog,
modelPersistence,
modelSelectionVersions,
modelSyncFailures,
pendingSessionId,
profile,
rectificationSessionId,
sessionDetailInFlight,
sessionSelectionSource,
sessions,
sessionsRef,
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<string | null>(null);
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
sessionsRef.current = sessions;
const loadMoreInFlight = useRef(false);
const pendingCreateById = useRef(new Map<string, { continuedFromSessionId?: string }>());
const cloudCreatedIds = useRef(new Set<string>());
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<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(NEW_CHAT_SUBJECT_ID, 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);
// Opening a stored session keeps that session's binding. It does not
// follow or rewrite the account-level active chart.
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 : "模型选择暂时无法同步到云端。",
});
}
}
function selectableChartRecord(chartProfileId: string) {
const record = chartProfileId === "self"
? chartLibrary.find((item) => item.id === "self" || item.role === "self")
: chartLibrary.find((item) => item.id === chartProfileId && item.role === "other");
if (!record || !isConsultationSubjectProfileComplete(record.profile)) return null;
return record;
}
async function bindEmptyChatSubject(chartProfileId: string): Promise<boolean> {
const session = sessionsRef.current.find((item) => item.id === activeSession?.id) ?? activeSession;
if (!session || chatSessionSubjectLocked(session)) return false;
const record = selectableChartRecord(chartProfileId);
if (!record) return false;
const requestedId = record.role === "self" ? "self" : record.id;
if (session.chartProfileId === requestedId && session.chartProfileRole === record.role) return true;
if (cloudCreatedIds.current.has(session.id)) {
const previousRole = session.chartProfileRole === "self" || session.chartProfileRole === "other"
? session.chartProfileRole
: null;
try {
await writeChatSession(session.id, emptySessionBindingWrite(requestedId, record.role), "update");
const detailed = await fetchSessionDetail(session.id, modelCatalog);
if (detailed?.chartProfileId !== requestedId || detailed.chartProfileRole !== record.role) {
throw new Error("人物没有绑上");
}
updateSession(session.id, (current) => {
if (chatSessionSubjectLocked(current)) return current;
return {
...current,
chartProfileId: detailed.chartProfileId,
chartProfileName: detailed.chartProfileName,
chartProfileRole: detailed.chartProfileRole,
};
});
return true;
} catch (caught) {
if (previousRole) {
try {
await writeChatSession(
session.id,
emptySessionBindingWrite(session.chartProfileId ?? "self", previousRole),
"update",
);
} catch {
setComposerNotice("人物没有绑上");
return false;
}
}
setComposerNotice(caught instanceof Error && caught.message !== "人物没有绑上"
? caught.message
: "人物没有绑上");
return false;
}
}
const snapshot = chartSnapshotForSession(requestedId, chartLibrary, profile);
updateSession(session.id, (current) => {
if (chatSessionSubjectLocked(current)) return current;
return { ...current, ...snapshot };
});
return true;
}
async function openChatBoundToProfile(chartProfileId: string): Promise<boolean> {
if (!account || !modelCatalog || creatingSession) return false;
if (pendingSessionId || cancellationPending) return false;
const record = selectableChartRecord(chartProfileId);
if (!record) return false;
const requestedId = record.role === "self" ? "self" : record.id;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(requestedId, chartLibrary, profile),
};
for (const existing of sessions) {
if (isUnsavedEmptyConsultation(existing)) pendingCreateById.current.delete(existing.id);
}
pendingCreateById.current.set(nextSession.id, {});
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
setActiveSessionId(nextSession.id);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
setRequestError(null);
return true;
}
return {
visibleSessions,
sessionsCursor,
setSessionsCursor,
sessionMenuId,
setSessionMenuId,
pendingSessionDeletion,
setPendingSessionDeletion,
setSessionFullPrompt,
updateSession,
persistSession,
ensureSessionMessages,
continueInNewChat,
renameSession,
deleteSession,
togglePinnedSession,
shareSession,
startNewChat,
bindEmptyChatSubject,
openChatBoundToProfile,
selectSession,
selectSessionModel,
loadMoreSessions,
};
}