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.
516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
"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,
|
||
};
|
||
}
|