bf6989eccf
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>
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
"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,
|
|
};
|
|
}
|