refactor(chat): extract consultation and session hooks after contract repairs
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:
+114
-1174
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user