Files
Jyotisha/frontend/src/hooks/use-session-management.ts
T
jesse-ux 5a4b87383f
Independent Staging Quality Gate / validate (push) Failing after 6m35s
Independent Staging Quality Gate / publish (push) Skipped
fix(web): retire session archive UI and API, restore archived rows
Remove archive/restore from the session menu and drop archived=1 listing. PATCH still accepts archived_at from old bundles then ignores it. A forward migration clears archived_at without bumping updated_at. Delete stays unchanged.
2026-09-21 18:49:40 +08:00

516 lines
20 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 {
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<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,
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<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(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,
};
}