refactor(chat): extract profile onboarding and rectification surface hooks
Close the home-page split by moving account/onboarding and rectification glue out of page.tsx without relocating React state, keeping the route static and hook order intact. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
|
||||
import { resolveSessionTitle } from "@/lib/agent-reply";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { writeSessionUrl } from "@/lib/chat-session-url";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile";
|
||||
import {
|
||||
timestamp,
|
||||
type Account,
|
||||
type AccountDialog,
|
||||
type ChartLibraryRecord,
|
||||
type ChatSession,
|
||||
type Message,
|
||||
type OnboardingStep,
|
||||
type Profile,
|
||||
type Theme,
|
||||
} from "@/lib/home-types";
|
||||
import {
|
||||
entrySummaryFromResponse,
|
||||
isTerminalRectificationStatus,
|
||||
openRectificationRequestBody,
|
||||
openResponseFromPayload,
|
||||
type RectificationEntrySummary,
|
||||
} from "@/lib/rectification-entry";
|
||||
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
||||
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
|
||||
export type RectificationSurfaceParams = {
|
||||
account: Account | null;
|
||||
activeChartId: string;
|
||||
activeSessionIdRef: MutableRefObject<string>;
|
||||
chartLibrary: ChartLibraryRecord[];
|
||||
creatingSession: boolean;
|
||||
modelCatalog: PublicLanguageModelCatalog | null;
|
||||
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
|
||||
profile: Profile;
|
||||
rectificationLoading: boolean;
|
||||
rectificationMutationPending: boolean;
|
||||
rectificationOpenInFlight: MutableRefObject<boolean>;
|
||||
rectificationSessionId: string | null;
|
||||
resumeRectificationSession: MutableRefObject<(session: ChatSession) => void>;
|
||||
sessionSelectionSource: MutableRefObject<"user" | "history">;
|
||||
sessions: ChatSession[];
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
setDraft: (value: string) => void;
|
||||
setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void;
|
||||
setDraftTheme: (theme: Theme | null) => void;
|
||||
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
|
||||
setProfileNotice: Dispatch<SetStateAction<string>>;
|
||||
setRectificationCaseId: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationEntrySummary: Dispatch<SetStateAction<RectificationEntrySummary | null>>;
|
||||
setRectificationError: Dispatch<SetStateAction<string>>;
|
||||
setRectificationLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationPendingQuestion: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
|
||||
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
||||
uiPreview: MutableRefObject<boolean>;
|
||||
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
|
||||
openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void;
|
||||
refreshAccount: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
const {
|
||||
account,
|
||||
activeChartId,
|
||||
activeSessionIdRef,
|
||||
chartLibrary,
|
||||
creatingSession,
|
||||
modelCatalog,
|
||||
persistSession,
|
||||
profile,
|
||||
rectificationLoading,
|
||||
rectificationMutationPending,
|
||||
rectificationOpenInFlight,
|
||||
rectificationSessionId,
|
||||
resumeRectificationSession,
|
||||
sessionSelectionSource,
|
||||
sessions,
|
||||
setActiveSessionId,
|
||||
setDraft,
|
||||
setDraftEntrypoint,
|
||||
setDraftTheme,
|
||||
setOnboardingStep,
|
||||
setProfileNotice,
|
||||
setRectificationCaseId,
|
||||
setRectificationEntrySummary,
|
||||
setRectificationError,
|
||||
setRectificationLoading,
|
||||
setRectificationPendingQuestion,
|
||||
setRectificationReadonly,
|
||||
setRectificationSessionId,
|
||||
setRectificationShouldStartOpening,
|
||||
setRectificationTurns,
|
||||
setSessions,
|
||||
uiPreview,
|
||||
updateSession,
|
||||
openAccountDialog,
|
||||
refreshAccount,
|
||||
} = params;
|
||||
|
||||
async function refreshRectificationEntrySummary() {
|
||||
if (!account) return;
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json().catch(() => null);
|
||||
setRectificationEntrySummary(entrySummaryFromResponse(payload));
|
||||
} catch {
|
||||
// The CTA falls back to the server-agnostic default labels.
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRectificationCase(caseId: string, sessionId: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json().catch(() => null);
|
||||
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
|
||||
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({
|
||||
id: String(turn?.id ?? ""),
|
||||
role: turn?.role === "user" ? "user" as const : "assistant" as const,
|
||||
text: typeof turn?.text === "string" ? turn.text : null,
|
||||
status: String(turn?.status ?? "completed"),
|
||||
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
|
||||
status: String((turn.receipt as { status?: unknown }).status ?? ""),
|
||||
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
|
||||
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
|
||||
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
|
||||
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
|
||||
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
|
||||
} : null,
|
||||
})));
|
||||
} catch {
|
||||
// History refresh is best-effort; the stream restores live turns.
|
||||
}
|
||||
}
|
||||
|
||||
async function openRectificationCase(
|
||||
intent: "homepage" | "session" | "new",
|
||||
exactSessionId: string | null,
|
||||
pendingConsultationQuestion: string | null,
|
||||
) {
|
||||
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|
||||
|| rectificationMutationPending) {
|
||||
sessionSelectionSource.current = "user";
|
||||
return null;
|
||||
}
|
||||
const missingStep = missingProfileStep(profile);
|
||||
if (missingStep) {
|
||||
sessionSelectionSource.current = "user";
|
||||
setRectificationSessionId(null);
|
||||
setRectificationCaseId(null);
|
||||
setRectificationPendingQuestion(null);
|
||||
setOnboardingStep(missingStep);
|
||||
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
||||
return null;
|
||||
}
|
||||
|
||||
rectificationOpenInFlight.current = true;
|
||||
setRectificationLoading(true);
|
||||
setRectificationError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/open", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const code = payload?.code;
|
||||
if (code === "profile_incomplete") {
|
||||
handleRectificationProfileIncomplete();
|
||||
} else if (code === "invalid_open_request") {
|
||||
setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。");
|
||||
} else if (code === "active_case_conflict") {
|
||||
setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。");
|
||||
} else if (code === "case_session_not_found") {
|
||||
setRectificationError("该校正会话不存在或已结束。");
|
||||
} else {
|
||||
setRectificationError(payload?.error || "暂时无法打开生时校正。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const opened = openResponseFromPayload(payload);
|
||||
if (!opened) {
|
||||
setRectificationError("生时校正服务响应不正确,请稍后重试。");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Merge the server-created session into the local list. The browser
|
||||
// never generates a Case id; it only mirrors the returned binding.
|
||||
const existing = sessions.find((session) => session.id === opened.sessionId);
|
||||
const merged: ChatSession = {
|
||||
id: opened.sessionId,
|
||||
title: resolveSessionTitle("生时校正", undefined, {
|
||||
entrypoint: "birth_time_rectification",
|
||||
existingTitles: sessions.map((session) => session.title),
|
||||
}),
|
||||
theme: "general",
|
||||
modelId: modelCatalog.defaultModelId ?? "",
|
||||
messages: [],
|
||||
updatedAt: timestamp(),
|
||||
sessionType: "birth_time_rectification",
|
||||
rectificationCaseId: opened.caseId,
|
||||
pinned: existing?.pinned ?? false,
|
||||
archivedAt: existing?.archivedAt ?? null,
|
||||
messagesHydrated: true,
|
||||
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
|
||||
};
|
||||
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
|
||||
void persistSession(merged).catch(() => {});
|
||||
|
||||
setRectificationPendingQuestion(pendingConsultationQuestion);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setRectificationSessionId(opened.sessionId);
|
||||
setRectificationCaseId(opened.caseId);
|
||||
setRectificationShouldStartOpening(opened.shouldStartOpening);
|
||||
setRectificationReadonly(
|
||||
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
|
||||
);
|
||||
setRectificationTurns([]);
|
||||
activeSessionIdRef.current = opened.sessionId;
|
||||
setActiveSessionId(opened.sessionId);
|
||||
if (!uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
writeSessionUrl(opened.sessionId, "push");
|
||||
}
|
||||
void refreshRectificationCase(opened.caseId, opened.sessionId);
|
||||
void refreshRectificationEntrySummary();
|
||||
return opened;
|
||||
} catch {
|
||||
setRectificationError("生时校正会话暂时无法打开,请稍后重试。");
|
||||
return null;
|
||||
} finally {
|
||||
sessionSelectionSource.current = "user";
|
||||
rectificationOpenInFlight.current = false;
|
||||
setRectificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) {
|
||||
await openRectificationCase("homepage", null, pendingConsultationQuestion);
|
||||
}
|
||||
|
||||
async function openRectificationSession(exactSessionId: string) {
|
||||
await openRectificationCase("session", exactSessionId, null);
|
||||
}
|
||||
|
||||
async function startNewRectification() {
|
||||
await openRectificationCase("new", null, null);
|
||||
}
|
||||
|
||||
resumeRectificationSession.current = (session) => {
|
||||
sessionSelectionSource.current = "history";
|
||||
void openRectificationSession(session.id);
|
||||
};
|
||||
|
||||
function handleRectificationProfileIncomplete() {
|
||||
setRectificationError("profile_incomplete");
|
||||
setRectificationSessionId(null);
|
||||
setRectificationCaseId(null);
|
||||
setRectificationPendingQuestion(null);
|
||||
const missingStep = missingProfileStep(profile);
|
||||
if (missingStep) {
|
||||
setOnboardingStep(missingStep);
|
||||
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
||||
return;
|
||||
}
|
||||
openAccountDialog("profile");
|
||||
setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。");
|
||||
void refreshAccount();
|
||||
}
|
||||
|
||||
function handleRectificationMessagesChange(messages: Message[]) {
|
||||
if (!rectificationSessionId) return;
|
||||
updateSession(rectificationSessionId, (session) => ({
|
||||
...session,
|
||||
messages,
|
||||
updatedAt: timestamp(),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
refreshRectificationEntrySummary,
|
||||
refreshRectificationCase,
|
||||
openRectificationCase,
|
||||
openRectificationFromHomepage,
|
||||
openRectificationSession,
|
||||
startNewRectification,
|
||||
handleRectificationProfileIncomplete,
|
||||
handleRectificationMessagesChange,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user