refactor(chat): extract consultation and session hooks after contract repairs
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Keep archive as a PATCH of archived_at rather than DELETE, pass entry_mode through onboarding, and let popstate to the default chat reuse selectSession. Then move send/stop/recovery and session management out of page.tsx so the home surface stays within the batch-two line budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 04:34:14 +08:00
co-authored by Cursor
parent 8b7eda9f2d
commit 551d6317ae
18 changed files with 1908 additions and 1195 deletions
+114 -1174
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
"use client";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import {
SESSION_MISSING_NOTICE,
parseSessionUrlQuery,
writeSessionUrl,
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
clearBirthTimeConsultationConsent,
type BirthTimeConsultationConsentState,
} from "@/lib/birth-time-consultation-consent";
import {
activeChartStorageKey,
createSession,
fetchSessionDetail,
LoginRedirectError,
mergeHydratedSession,
patchSessionModel,
} from "@/lib/home-cloud-sync";
import { chartSnapshotForSession } from "@/lib/home-profile";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import {
persistSessionModelSelection,
type SessionModelPersistenceQueue,
} from "@/lib/session-model-persistence";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
timestamp,
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>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessionDetailLoadingId: Dispatch<SetStateAction<string | null>>;
setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
visibleSessions: ChatSession[];
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,
setCreatingSession,
setDraft,
setDraftEntrypoint,
setDraftTheme,
setRectificationError,
setRequestError,
setSessionDetailLoadingId,
setSessionFullPrompt,
setSessions,
uiPreview,
visibleSessions,
openRectificationSession,
} = params;
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") {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
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,
}
: {
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);
}
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 created = await startNewChat();
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, updatedAt: timestamp() };
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) {
const fallbackId = nextSessions[0]?.id ?? "";
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
}
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 : "置顶同步失败");
});
}
function toggleArchivedSession(sessionId: string) {
const session = sessions.find((item) => item.id === sessionId);
if (!session) return;
const restoring = Boolean(session.archivedAt);
const previousActiveId = activeSessionId;
const nextArchivedAt = restoring ? null : new Date().toISOString();
updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt }));
if (!restoring && activeSessionId === sessionId) {
const fallbackId = visibleSessions.find((item) => item.id !== sessionId)?.id ?? "";
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => {
updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt }));
if (!restoring && previousActiveId === sessionId) {
setActiveSessionId(previousActiveId);
if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace");
}
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(): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
};
const previousSessionId = activeSession?.id ?? "";
const previousHref = `${window.location.pathname}${window.location.search}`;
setCreatingSession(true);
setSessions((current) => [nextSession, ...current]);
setActiveSessionId(nextSession.id);
if (!uiPreview.current) writeSessionUrl(nextSession.id, "push");
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
setRequestError(null);
try {
await persistSession(nextSession, "create");
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
setActiveSessionId(previousSessionId);
if (!uiPreview.current) window.history.replaceState(null, "", previousHref);
setRequestError({
sessionId: previousSessionId,
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
});
return null;
} finally {
setCreatingSession(false);
}
}
async function startConsultationAfterRectification() {
await startNewChat();
setDraft("请用刚才采用的代表性出生时间看盘。");
setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。");
}
function selectSession(sessionId: string) {
const nextSession = sessions.find((session) => session.id === sessionId);
setActiveSessionId(sessionId);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
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 (nextSession.id !== rectificationSessionId) {
// 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 (!uiPreview.current && sessionSelectionSource.current === "user") {
writeSessionUrl(sessionId, "push");
}
sessionSelectionSource.current = "user";
}
applySessionPopStateRef.current = (search: string) => {
if (uiPreview.current) return;
const listed = sessionsRef.current;
const query = parseSessionUrlQuery(search);
const fallbackId = listed[0]?.id ?? "";
const requestedId = query.sessionId;
if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) {
writeSessionUrl(null, "replace");
sessionSelectionSource.current = "history";
if (fallbackId) selectSession(fallbackId);
else setActiveSessionId("");
setComposerNotice(SESSION_MISSING_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, updatedAt: timestamp() };
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 {
updateSession,
persistSession,
ensureSessionMessages,
continueInNewChat,
renameSession,
deleteSession,
togglePinnedSession,
toggleArchivedSession,
shareSession,
startNewChat,
startConsultationAfterRectification,
selectSession,
selectSessionModel,
};
}