Files
Jyotisha/frontend/src/hooks/use-rectification-surface.ts
T
Jesse_ChenandClaude Opus 5.5 f04da1038b docs+fix(home): amend resumable hint to point at history (brief amendment)
- D2 resumable hint becomes「上次那次校正还没完成,可以在历史对话里接着做。」
  so it does not imply the homepage pill resumes (it always creates).
- The click-time non-self error in useRectificationSurface is the same
  user-visible sentence as the homepage hint; add the full stop.
- Source, test, VOICE, DESIGN, CHANGELOG, BUG-1041, testing list and
  PROGRESS (amendment by Claude) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 08:50:18 +08:00

443 lines
17 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { resolveSessionTitle } from "@/lib/agent-reply";
import { fetchSessionDetail } from "@/lib/home-cloud-sync";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeSessionUrl } from "@/lib/chat-session-url";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import { readCurrentSubjectId } from "@/lib/current-subject";
import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile";
import {
timestamp,
type Account,
type AccountDialog,
type ChartLibraryRecord,
type ChatSession,
type Message,
type OnboardingStep,
type OpenAccountDialogOptions,
type Profile,
type Theme,
} from "@/lib/home-types";
import {
entrySummaryFromResponse,
isTerminalRectificationStatus,
openRectificationRequestBody,
openResponseFromPayload,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import type {
PersistedRectificationTurn,
RectificationChatPanel,
} from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
rectificationCaseHref,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
export type RectificationShellState = {
sessionId: string | null;
caseId: string | null;
openingSessionId: string | null;
loading: boolean;
mutationPending: boolean;
entrySummary: RectificationEntrySummary | null;
entrySummarySettled: boolean;
error: string;
errorSessionId: string | null;
};
export const EMPTY_RECTIFICATION_SHELL: RectificationShellState = {
sessionId: null,
caseId: null,
openingSessionId: null,
loading: false,
mutationPending: false,
entrySummary: null,
entrySummarySettled: false,
error: "",
errorSessionId: null,
};
function fieldSetter<K extends keyof RectificationShellState>(
setRectification: Dispatch<SetStateAction<RectificationShellState>>,
key: K,
): Dispatch<SetStateAction<RectificationShellState[K]>> {
return (value) => {
setRectification((current) => ({
...current,
[key]: typeof value === "function"
? (value as (prev: RectificationShellState[K]) => RectificationShellState[K])(current[key])
: value,
}));
};
}
export function createRectificationShellSetters(
setRectification: Dispatch<SetStateAction<RectificationShellState>>,
) {
return {
setRectificationSessionId: fieldSetter(setRectification, "sessionId"),
setRectificationCaseId: fieldSetter(setRectification, "caseId"),
setRectificationOpeningSessionId: fieldSetter(setRectification, "openingSessionId"),
setRectificationLoading: fieldSetter(setRectification, "loading"),
setRectificationMutationPending: fieldSetter(setRectification, "mutationPending"),
setRectificationEntrySummary: fieldSetter(setRectification, "entrySummary"),
setRectificationEntrySummarySettled: fieldSetter(setRectification, "entrySummarySettled"),
setRectificationError: fieldSetter(setRectification, "error"),
setRectificationErrorSessionId: fieldSetter(setRectification, "errorSessionId"),
};
}
export type RectificationSurfaceParams = {
account: Account | null;
activeSessionIdRef: MutableRefObject<string>;
chartLibrary: ChartLibraryRecord[];
creatingSession: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
profile: Profile;
rectification: RectificationShellState;
setRectification: Dispatch<SetStateAction<RectificationShellState>>;
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>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
refreshAccount: () => Promise<void>;
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
};
export function useRectificationSurface(params: RectificationSurfaceParams) {
const {
account,
activeSessionIdRef,
chartLibrary,
creatingSession,
modelCatalog,
persistSession,
profile,
rectification,
setRectification,
sessionSelectionSource,
sessions,
setActiveSessionId,
setDraft,
setDraftEntrypoint,
setDraftTheme,
setOnboardingStep,
setProfileNotice,
setSessions,
uiPreview,
updateSession,
openAccountDialog,
refreshAccount,
rectificationSessionOpenerRef,
} = params;
const rectificationSessionId = rectification.sessionId;
const rectificationCaseId = rectification.caseId;
const rectificationLoading = rectification.loading;
const rectificationMutationPending = rectification.mutationPending;
const setRectificationSessionId = fieldSetter(setRectification, "sessionId");
const setRectificationCaseId = fieldSetter(setRectification, "caseId");
const setRectificationOpeningSessionId = fieldSetter(setRectification, "openingSessionId");
const setRectificationLoading = fieldSetter(setRectification, "loading");
const setRectificationMutationPending = fieldSetter(setRectification, "mutationPending");
const setRectificationEntrySummary = fieldSetter(setRectification, "entrySummary");
const setRectificationError = fieldSetter(setRectification, "error");
const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId");
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const rectificationOpenInFlight = useRef(false);
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
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(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) {
setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY);
return;
}
const payload = await response.json().catch(() => null);
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
} catch {
setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY);
}
}
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("");
setRectificationErrorSessionId(null);
// Read the selection source now: `selectSession` resets it synchronously
// right after handing off, and the URL write below happens after awaits.
const selectionSource = sessionSelectionSource.current;
setRectificationOpeningSessionId(exactSessionId);
function assignOpenError(message: string) {
setRectificationError(message);
setRectificationErrorSessionId(intent === "session" ? exactSessionId : null);
}
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") {
assignOpenError("profile_incomplete");
handleRectificationProfileIncomplete();
} else if (code === "invalid_open_request") {
assignOpenError(payload?.error || "生时校正打开请求不正确,请刷新后重试。");
} else if (code === "active_case_conflict") {
assignOpenError(payload?.error || "仍有未完成的校正,请先回到当前校正。");
} else if (code === "case_session_not_found") {
assignOpenError("该校正会话不存在或已结束。");
} else {
assignOpenError(payload?.error || "暂时无法打开生时校正。");
}
return null;
}
const opened = openResponseFromPayload(payload);
if (!opened) {
assignOpenError("生时校正服务响应不正确,请稍后重试。");
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 openIdentity = rectificationOpenIdentity(existing, {
title: resolveSessionTitle("生时校正", undefined, {
entrypoint: "birth_time_rectification",
existingTitles: sessions.map((session) => session.title),
}),
updatedAt: timestamp(),
});
const merged: ChatSession = {
id: opened.sessionId,
title: openIdentity.title,
theme: "general",
modelId: modelCatalog.defaultModelId ?? "",
messages: [],
createdAt: existing?.createdAt,
updatedAt: openIdentity.updatedAt,
sessionType: "birth_time_rectification",
rectificationCaseId: opened.caseId,
pinned: existing?.pinned ?? false,
archivedAt: existing?.archivedAt ?? null,
messagesHydrated: true,
...chartSnapshotForSession("self", chartLibrary, profile),
};
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
void persistSession(merged).catch(() => {});
// New sessions have no local title to preserve (BUG-699). The case insert
// trigger already wrote the result title. Read that back so the sidebar
// does not keep the minted "生时校正" after persist.
if (!existing) {
void fetchSessionDetail(opened.sessionId, modelCatalog).then((serverSession) => {
if (!serverSession || serverSession.sessionType !== "birth_time_rectification") return;
updateSession(opened.sessionId, (session) => (
session.title === serverSession.title ? session : { ...session, title: serverSession.title }
));
}).catch(() => {});
}
// One reveal: turns and snapshot are read before the surface mounts, so
// the reader never sees an empty transcript or an empty question area
// while they load. Past the deadline the surface opens with what arrived
// and its own retry path fills in the rest.
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
});
setRectificationPendingQuestion(pendingConsultationQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setRectificationSessionId(opened.sessionId);
setRectificationCaseId(opened.caseId);
setRectificationShouldStartOpening(opened.shouldStartOpening);
setRectificationReadonly(
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
);
setRectificationTurns(hydration.turns);
setRectificationSnapshot(hydration.snapshot);
activeSessionIdRef.current = opened.sessionId;
setActiveSessionId(opened.sessionId);
if (!uiPreview.current && selectionSource === "user") {
writeSessionUrl(opened.sessionId, "push");
}
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
void refreshRectificationEntrySummary();
return opened;
} catch {
assignOpenError("生时校正会话暂时无法打开,请稍后重试。");
return null;
} finally {
sessionSelectionSource.current = "user";
rectificationOpenInFlight.current = false;
setRectificationOpeningSessionId(null);
setRectificationLoading(false);
}
}
function rectificationLimitedToSelf(): boolean {
if (readCurrentSubjectId() === "self") return false;
setRectificationError("生时校正暂时只支持本人。");
return true;
}
async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("homepage", null, pendingConsultationQuestion);
}
async function openRectificationSession(exactSessionId: string) {
await openRectificationCase("session", exactSessionId, null);
}
async function reopenRectificationSession(exactSessionId: string) {
setRectificationError("");
setRectificationErrorSessionId(null);
await openRectificationSession(exactSessionId);
}
async function startNewRectification() {
if (rectificationLimitedToSelf()) return;
await openRectificationCase("new", null, null);
}
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(),
}));
}
useEffect(() => {
resumeRectificationSession.current = (session) => {
sessionSelectionSource.current = "history";
void openRectificationSession(session.id);
};
rectificationSessionOpenerRef.current = openRectificationSession;
});
const rectificationPanel: RectificationChatPanel = {
caseId: rectificationCaseId ?? "",
sessionId: rectificationSessionId ?? "",
readonly: rectificationReadonly,
shouldStartOpening: rectificationShouldStartOpening,
initialTurns: rectificationTurns,
initialSnapshot: rectificationSnapshot,
pendingConsultationQuestion: rectificationPendingQuestion,
headerSlot: rectificationHeaderSlot,
onMessagesChange: handleRectificationMessagesChange,
onCompleted: () => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
},
onPendingChange: setRectificationMutationPending,
onProfileIncomplete: handleRectificationProfileIncomplete,
onOpeningConsumed: () => setRectificationShouldStartOpening(false),
onRestart: () => void startNewRectification(),
};
return {
rectificationHeaderSlot,
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
refreshRectificationEntrySummary,
refreshRectificationCase,
openRectificationCase,
openRectificationFromHomepage,
openRectificationSession,
reopenRectificationSession,
startNewRectification,
handleRectificationProfileIncomplete,
handleRectificationMessagesChange,
};
}