send() 对校正会话不发 /api/consult,草稿保留并打开校正面。 普通输入框只有禁用态。咨询接口把类型不对改成 409 session_not_consultation。 删除和回退不再直接切到校正会话。
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
|
|
|
import { resolveSessionTitle } from "@/lib/agent-reply";
|
|
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 { 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,
|
|
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;
|
|
activeChartId: string;
|
|
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,
|
|
activeChartId,
|
|
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) return;
|
|
const payload = await response.json().catch(() => null);
|
|
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
|
|
} 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("");
|
|
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: [],
|
|
updatedAt: openIdentity.updatedAt,
|
|
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(() => {});
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) {
|
|
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() {
|
|
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,
|
|
};
|
|
}
|