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,92 @@
|
||||
# PROGRESS · 拆分首页巨石组件·第三批:onboarding/profile 与校正胶水(2026-09-01)
|
||||
|
||||
工作树:`.worktrees/home-split-batch3-20260901`
|
||||
分支:`codex/home-split-batch3-20260901`
|
||||
基线:`origin/staging` @ `80e77361`(含报告语气 PR #48 与出生精度 PR #49)。`551d6317` 之后 **没有** 未验收的 `page.tsx` 改动:`git log 551d6317..origin/staging -- frontend/src/app/page.tsx` 为空。
|
||||
|
||||
不要写成 `PROGRESS.md`。本轮未提交、未推送。
|
||||
|
||||
React Compiler 仍关。未引入 context/store。无手写 `useCallback`/`useMemo`。自定义 hook 内部 **0** 个 `useState` / `useRef` / `useEffect`。`site-styles` import 仍在 `page.tsx` 顶。`const BirthTimeRectification = dynamic(...)` 未动。
|
||||
|
||||
脏树未碰:`yinduzhanxing` @ `codex/minute-rectification-p0`、`.worktrees/fix-general-daily-20260815` 仍有本地 `page.tsx` 残留。
|
||||
|
||||
## 度量
|
||||
|
||||
| 项 | 拆前(第二批后 2445 / staging 同文件) | 拆后 | 说明 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `frontend/src/app/page.tsx` 行数 | 2445 | 1997 | 目标 ≤2000,达标 |
|
||||
| `useState(` Home / profile hook / rectification hook | 26 / — / — | 26 / 0 / 0 | 独占 state 未下移 |
|
||||
| `useRef(` Home / profile / rectification | 19 / — / — | 19 / 0 / 0 | 同上 |
|
||||
| `useEffect(` Home / profile / rectification | 20 / — / — | 20 / 0 / 0 | 拉取/恢复 effect 仍在 Home |
|
||||
| `/` 路由 | `○ Static` | `○ Static` | webpack `next build --webpack` |
|
||||
| 首屏 JS gzip -9 | 第二批 501104 B | 503980 B = 492.2 KB | +2876 B / **+0.57%**(±2% 内) |
|
||||
| 前端测试 | staging 高于 2428 | **2460** | fail=0 skipped=0 |
|
||||
|
||||
gzip 相对第二批的 +0.57% 含 `80e77361` 已合入的语气/精度前端,不单是本次搬家。同 SHA 干净树未再打一份对照包。
|
||||
|
||||
本批按任务书收官:starter 三函数、合盘起草、composer 小函数仍留在 `Home`。
|
||||
|
||||
## Hook 调用顺序(红线 1)
|
||||
|
||||
`useRouter` → `useState`×26 → `useRef`×19 → `useBirthTimeGuidedJourney` → `useSessionManagement` → `useProfileOnboarding` → `useRectificationSurface` → `useEffect`×8 → `useConversationScrollAnchor` → `useConsultationRun` → 其余 `useEffect`
|
||||
|
||||
新增的两个 hook **内部零 React hook**。`useState`/`useRef`/`useEffect` 在 Home 里的相对次序与搬前相同。
|
||||
|
||||
| 调用点 | 位置 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `useSessionManagement` | `useBirthTimeGuidedJourney` 之后 | 第二批已定;`openRectificationSession` 经模块级 holder 转发给尚未声明的校正 hook |
|
||||
| `useProfileOnboarding` | session hook 之后、第一条 `useEffect` 之前 | 账户/引导函数要给校正 hook 与咨询 hook |
|
||||
| `useRectificationSurface` | profile hook 之后、第一条 `useEffect` 之前 | `persistSession` / `updateSession` / `refreshAccount` / `openAccountDialog` 已齐 |
|
||||
|
||||
依赖方向:session → Home → profile / rectification / consult。四个 hook 文件 **不得、也没有** 互相 import。
|
||||
|
||||
跨声明顺序的回调用 **模块级 `{ current }` 对象**,不是新的 `useRef`(避免改变 Home hook 数量):
|
||||
|
||||
- `rectificationSessionOpener`:session hook 的 `openRectificationSession` 转发到校正 hook
|
||||
- `guidedBirthTimeReady` / `editDeclaredBirthTimeDetailsHolder`:`useBirthTimeGuidedJourney` 早于 profile hook,原函数声明提升不再可用
|
||||
|
||||
`setDraft` 等仍是 `Home` 内 function declaration(JS 提升),可出现在 hook 参数里。
|
||||
|
||||
## 任务 1–2 · 搬家
|
||||
|
||||
函数体相对 `80e77361:frontend/src/app/page.tsx` 去缩进后逐行 **IDENTICAL**(22/22):
|
||||
|
||||
| 文件 | 行 | 内容 |
|
||||
| --- | ---: | ---: |
|
||||
| `frontend/src/hooks/use-profile-onboarding.ts` | 424 | `refreshAccount` … `signOut` |
|
||||
| `frontend/src/hooks/use-rectification-surface.ts` | 306 | `refreshRectificationEntrySummary` … `handleRectificationMessagesChange`(含 `resumeRectificationSession.current =`) |
|
||||
|
||||
公证抽样(全部 22 个,不少于 4):`refreshAccount` 19、`persistProfile` 41、`saveOnboardingName` 22、`openRectificationCase` 105、以及其余 18 个均为 IDENTICAL。改名清单:**零**。
|
||||
|
||||
仍留在 Home:`chooseSuggestedQuestion` / `startSuggestedConsultation` / `startDailyStarlanguageConsultation`、`draftSynastryQuestionFromChart` 及之后、`copyAssistantMessage` / `handleComposerKeyDown`。`send`/`submit` 已在第二批进咨询 hook。
|
||||
|
||||
## 合同测试路径(同一 token,只换读取面)
|
||||
|
||||
拼接件扩容:可选 `use-profile-onboarding.ts` / `use-rectification-surface.ts`。
|
||||
|
||||
| 测试 | 原路径 | 新路径 |
|
||||
| --- | --- | --- |
|
||||
| `profile-state.test.ts` `refreshAccount` 切片 | 已是 `homeSurface` | 仍 `homeSurface`(函数已进 profile hook;去掉未用的 `readFileSync`) |
|
||||
| `profile-persistence.test.ts` `persistProfile` | 已是 `homeSurface` | 仍 `homeSurface` |
|
||||
| `birth-time-assessment-loading.test.ts` `saveOnboardingBirth`/`Place` | `page.tsx` + overlay | `homeSurface` + overlay |
|
||||
| `birth-time-guided-review-fixes.test.ts` 终端 CJK | `homeSurface` | 仍 `homeSurface` |
|
||||
| `chat-navigation-a11y` sign-out 切片 | `homeSurface`,终点 `chooseSuggestedQuestion`(仍在 page,拼在 hook **之前**) | 终点改为 profile hook 的 `return { refreshAccount`(断言内容未改) |
|
||||
| `rectification-agentic-entry` `handleRectificationProfileIncomplete` | 终点 `draftSynastryQuestionFromChart`(仍在 page) | 终点改为仍相邻的 `handleRectificationMessagesChange` |
|
||||
| `tests/test_daily_and_rectification_entrypoints.py` `openRectificationFromHomepage` | `PAGE`+starter+cloud | 若存在则拼接 rectification hook |
|
||||
| 三个 Python `_home_surface()` | 可选 session/consult hook | 再列入两个新 hook |
|
||||
|
||||
## 验证
|
||||
|
||||
- `./node_modules/.bin/tsc --noEmit`:通过。
|
||||
- 全量 `npm test`:**2460 pass / 0 fail / 0 skipped**。
|
||||
- Python:`test_session_management_entrypoints.py`、`test_supabase_user_data_contract.py`、`test_birth_time_journey_contract.py`、`test_daily_and_rectification_entrypoints.py` 全绿。
|
||||
- `./node_modules/.bin/next build --webpack`:`┌ ○ /` Static。首屏 JS gzip-9 503980 B vs 第二批 501104 B(+0.57%)。
|
||||
- 行为抽查:无登录态浏览器点选会话 / 发送咨询。搬家由源码合同锁住。
|
||||
|
||||
未改 `.gitea/workflows/**`。未动数据库。未提升 `main`。未重开 React Compiler。未提交。
|
||||
|
||||
## 明确未做
|
||||
|
||||
- 合盘起草与 composer 小函数(任务书本批不动)。
|
||||
- 未把 onboarding/profile/rectification **state** 下移进 hook。
|
||||
- 引擎运行时治理在独立 worktree `engine-runtime-hygiene-20260901`,不碰 `page.tsx`。
|
||||
+68
-516
@@ -20,9 +20,6 @@ import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
|
||||
import {
|
||||
entrySummaryFromResponse,
|
||||
isTerminalRectificationStatus,
|
||||
openRectificationRequestBody,
|
||||
openResponseFromPayload,
|
||||
rectificationEntryLabels,
|
||||
resolveRectificationEntryAction,
|
||||
type RectificationEntrySummary,
|
||||
@@ -40,15 +37,12 @@ import { ComposerCharacterRemaining } from "@/components/composer-character-rema
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
|
||||
import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply";
|
||||
import { BirthLocationFields } from "@/components/birth-location-fields";
|
||||
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
|
||||
import { ChartLibraryPanel } from "@/components/chart-library-panel";
|
||||
import { StarterHome } from "@/components/starter-home";
|
||||
import {
|
||||
beamAvatarPalettes,
|
||||
beamAvatarSchema,
|
||||
type BeamAvatarPatch,
|
||||
} from "@/lib/beam-avatar";
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
@@ -58,15 +52,12 @@ import {
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import {
|
||||
applyBirthTimeDraftPatch,
|
||||
applyPersistedBirthTime,
|
||||
assistantIntentCopy,
|
||||
birthTimePersistenceValues,
|
||||
hydrateDeclaredWindowDraft,
|
||||
isBirthTimeDraftReady,
|
||||
birthTimeDraftReadyHint,
|
||||
} from "@/lib/birth-time-intake-model";
|
||||
import {
|
||||
birthTimeConsultationOptionsCopy,
|
||||
clearBirthTimeConsultationConsent,
|
||||
createLatestAccountRequestGuard,
|
||||
createBirthTimeConsultationConsentState,
|
||||
@@ -78,11 +69,12 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
|
||||
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
|
||||
import { useConsultationRun } from "@/hooks/use-consultation-run";
|
||||
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
|
||||
import { useProfileOnboarding } from "@/hooks/use-profile-onboarding";
|
||||
import { useRectificationSurface } from "@/hooks/use-rectification-surface";
|
||||
import { useSessionManagement } from "@/hooks/use-session-management";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement";
|
||||
import {
|
||||
requestBirthTimeAssessment,
|
||||
type JourneyClientResponse,
|
||||
} from "@/lib/birth-time-journey-client";
|
||||
import {
|
||||
@@ -158,7 +150,6 @@ import {
|
||||
import {
|
||||
type PublicLanguageModelCatalog,
|
||||
} from "@/lib/public-models";
|
||||
import { selfHostedOtpActions } from "@/modules/identity/client";
|
||||
import {
|
||||
accountDialogClasses,
|
||||
accountDialogTitles,
|
||||
@@ -189,9 +180,7 @@ import {
|
||||
type Theme,
|
||||
} from "@/lib/home-types";
|
||||
import {
|
||||
birthProfileDeclarationChanged,
|
||||
birthQuestion,
|
||||
buildSelfChartRecord,
|
||||
buildSynastryQuestion,
|
||||
chartSnapshotForSession,
|
||||
completedOnboardingMessage,
|
||||
@@ -233,7 +222,6 @@ import {
|
||||
readStoredDailyStarlanguage,
|
||||
readStoredPendingConsultation,
|
||||
redirectToLogin,
|
||||
saveCloudChartProfile,
|
||||
saveCloudSynastryReport,
|
||||
waitForUndoWindow,
|
||||
writeStoredDailyStarlanguage,
|
||||
@@ -247,6 +235,16 @@ const BirthTimeRectification = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
const rectificationSessionOpener = {
|
||||
current: async (_exactSessionId: string) => {},
|
||||
};
|
||||
const guidedBirthTimeReady = {
|
||||
current: (_result: JourneyClientResponse) => {},
|
||||
};
|
||||
const editDeclaredBirthTimeDetailsHolder = {
|
||||
current: () => {},
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<Profile>(emptyProfile);
|
||||
@@ -365,8 +363,8 @@ export default function Home() {
|
||||
journey: birthTimeJourney,
|
||||
preview: process.env.NODE_ENV === "development" && uiPreview.current,
|
||||
onJourney: setBirthTimeJourney,
|
||||
onReady: completeGuidedBirthTime,
|
||||
onEditBirthTimeDetails: editDeclaredBirthTimeDetails,
|
||||
onReady: (result) => guidedBirthTimeReady.current(result),
|
||||
onEditBirthTimeDetails: () => editDeclaredBirthTimeDetailsHolder.current(),
|
||||
});
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
||||
@@ -437,44 +435,62 @@ export default function Home() {
|
||||
selectSession,
|
||||
selectSessionModel,
|
||||
} = useSessionManagement({
|
||||
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,
|
||||
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: (exactSessionId) => rectificationSessionOpener.current(exactSessionId),
|
||||
});
|
||||
|
||||
const {
|
||||
refreshAccount,
|
||||
openAccountDialog,
|
||||
closeAccountDialog,
|
||||
persistAvatar,
|
||||
saveProfile,
|
||||
saveOnboardingName,
|
||||
saveOnboardingBirth,
|
||||
editDeclaredBirthTimeDetails,
|
||||
saveOnboardingPlace,
|
||||
completeGuidedBirthTime,
|
||||
retryBirthTimeAssessment,
|
||||
signOut,
|
||||
} = useProfileOnboarding({
|
||||
account, accountRefreshGuard, accountTrigger, activeChartId, avatarSaving,
|
||||
birthTimeRevisionPending, chartLibrary, dialogReturnTarget, profile, profileDraft,
|
||||
profileSaving, setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog,
|
||||
setActiveChartId, setAvatarNotice, setAvatarSaving, setBirthTimeAssessmentPhase,
|
||||
setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setDraft,
|
||||
setEditingSelfChart, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength,
|
||||
setProfile, setProfileDraft, setProfileNotice, setProfileSaving, setRectificationError,
|
||||
setSigningOut, setStartGreeting, signingOut, uiPreview,
|
||||
});
|
||||
guidedBirthTimeReady.current = completeGuidedBirthTime;
|
||||
editDeclaredBirthTimeDetailsHolder.current = editDeclaredBirthTimeDetails;
|
||||
|
||||
const {
|
||||
refreshRectificationCase,
|
||||
openRectificationFromHomepage,
|
||||
openRectificationSession,
|
||||
startNewRectification,
|
||||
handleRectificationProfileIncomplete,
|
||||
handleRectificationMessagesChange,
|
||||
} = useRectificationSurface({
|
||||
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,
|
||||
});
|
||||
rectificationSessionOpener.current = openRectificationSession;
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
@@ -1304,282 +1320,6 @@ export default function Home() {
|
||||
};
|
||||
}, [accountId, hydrated]);
|
||||
|
||||
async function refreshAccount() {
|
||||
const requestIdentity = accountRefreshGuard.current.begin();
|
||||
try {
|
||||
const latest = await fetchAccount();
|
||||
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
|
||||
const nextProfile = readProfile(latest.profile);
|
||||
const activeOther = activeChartId !== "self"
|
||||
&& chartLibrary.find((record) => record.id === activeChartId && record.role === "other");
|
||||
if (!activeOther) {
|
||||
setProfile((current) => preserveShallowEqual(current, nextProfile));
|
||||
}
|
||||
setAccount(latest);
|
||||
setAccountError("");
|
||||
} catch (caught) {
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
|
||||
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) {
|
||||
dialogReturnTarget.current = returnTarget ?? accountTrigger.current;
|
||||
setAccountMenuOpen(false);
|
||||
setAccountError("");
|
||||
if (dialog === "profile") {
|
||||
setProfileNotice("");
|
||||
setAvatarNotice("");
|
||||
}
|
||||
if (dialog === "chart-library") {
|
||||
setProfileDraft(profile);
|
||||
setProfileNotice("");
|
||||
setEditingSelfChart(false);
|
||||
}
|
||||
setActiveAccountDialog(dialog);
|
||||
}
|
||||
|
||||
function closeAccountDialog() {
|
||||
if (signingOut) return;
|
||||
setActiveAccountDialog(null);
|
||||
const returnTarget = dialogReturnTarget.current;
|
||||
window.requestAnimationFrame(() => returnTarget?.focus());
|
||||
}
|
||||
|
||||
async function persistAvatar(patch: BeamAvatarPatch) {
|
||||
if (!account?.avatar || avatarSaving) return;
|
||||
setAvatarSaving(true);
|
||||
setAvatarNotice("");
|
||||
setAccountError("");
|
||||
try {
|
||||
const response = await fetch("/api/account/avatar", {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null;
|
||||
if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。");
|
||||
const avatar = beamAvatarSchema.parse(payload?.avatar);
|
||||
setAccount((current) => current ? { ...current, avatar } : current);
|
||||
setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。");
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败"));
|
||||
} finally {
|
||||
setAvatarSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistProfile(nextProfile: Profile): Promise<Profile> {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile;
|
||||
const birthPlace = selectedBirthPlace(nextProfile);
|
||||
const response = await fetch("/api/account", {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: nextProfile.name.trim() || null,
|
||||
birth_date: nextProfile.date || null,
|
||||
...birthTimePersistenceValues(nextProfile),
|
||||
country_code: nextProfile.countryCode,
|
||||
province_code: nextProfile.provinceCode || null,
|
||||
city_code: nextProfile.cityCode || null,
|
||||
district_code: nextProfile.districtCode || null,
|
||||
birth_place_label: nextProfile.birthPlaceLabel || null,
|
||||
birth_place_type: nextProfile.birthPlaceType || null,
|
||||
birth_place_provider: nextProfile.birthPlaceProvider || null,
|
||||
birth_place_provider_id: nextProfile.birthPlaceProviderId || null,
|
||||
latitude: birthPlace?.lat ?? null,
|
||||
longitude: birthPlace?.lon ?? null,
|
||||
timezone_id: nextProfile.timezoneId || null,
|
||||
timezone_offset: birthPlace?.tz ?? null,
|
||||
timezone_source: nextProfile.timezoneSource || null,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as {
|
||||
error?: string;
|
||||
birthTime?: unknown;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || "账户资料暂时无法保存。");
|
||||
}
|
||||
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
|
||||
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
|
||||
setActiveChartId("self");
|
||||
localStorage.setItem(activeChartStorageKey(account.user.id), "self");
|
||||
await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null);
|
||||
return savedProfile;
|
||||
}
|
||||
|
||||
|
||||
async function assessSavedBirthTime(nextProfile: Profile) {
|
||||
const result = process.env.NODE_ENV === "development" && uiPreview.current
|
||||
? previewRectificationJourney
|
||||
: await requestBirthTimeAssessment();
|
||||
const nextStatus = result.snapshot.state === "ready"
|
||||
? "confirmed"
|
||||
: result.snapshot.state === "candidate"
|
||||
? "candidate"
|
||||
: "rectifying";
|
||||
const assessedProfile: Profile = {
|
||||
...nextProfile,
|
||||
time: result.snapshot.activeTime ?? "",
|
||||
birthTimeStatus: nextStatus,
|
||||
rectificationCaseId: result.caseId,
|
||||
};
|
||||
setBirthTimeJourney(result);
|
||||
setBirthTimeError("");
|
||||
setProfile(assessedProfile);
|
||||
setProfileDraft(assessedProfile);
|
||||
return assessedProfile;
|
||||
}
|
||||
|
||||
async function saveProfile(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setProfileNotice("");
|
||||
setAccountError("");
|
||||
try {
|
||||
const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft);
|
||||
const savedProfile = await persistProfile(profileDraft);
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setRectificationError("");
|
||||
if (declarationChanged) {
|
||||
setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState());
|
||||
void refreshAccount();
|
||||
}
|
||||
setProfileNotice(savedProfile.birthTimeStatus === "confirmed"
|
||||
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
|
||||
: `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnboardingName() {
|
||||
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
if (!name || !account || profileSaving) return;
|
||||
const nextProfile = { ...profileDraft, name };
|
||||
setProfileSaving(true);
|
||||
setAccountError("");
|
||||
try {
|
||||
const savedProfile = await persistProfile(nextProfile);
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setStartGreeting(createStartGreeting(savedProfile.name));
|
||||
setDraft("");
|
||||
setPresetMessageLength(0);
|
||||
const nextStep = missingProfileStep(savedProfile);
|
||||
if (nextStep) setOnboardingStep(nextStep);
|
||||
else setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败"));
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeAssessmentPhase("saving_profile");
|
||||
setAccountError("");
|
||||
try {
|
||||
const savedProfile = await persistProfile(profileDraft);
|
||||
birthTimeRevisionPending.current = false;
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setPresetMessageLength(0);
|
||||
const nextStep = missingProfileStep(savedProfile);
|
||||
if (nextStep) setOnboardingStep(nextStep);
|
||||
else setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败"));
|
||||
} finally {
|
||||
setBirthTimeAssessmentPhase(null);
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function editDeclaredBirthTimeDetails() {
|
||||
birthTimeRevisionPending.current = true;
|
||||
setBirthTimeError("");
|
||||
setPresetMessageLength(0);
|
||||
setOnboardingStep("birth");
|
||||
}
|
||||
|
||||
async function saveOnboardingPlace(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeAssessmentPhase("entering_home");
|
||||
setAccountError("");
|
||||
try {
|
||||
const savedProfile = await persistProfile(profileDraft);
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setStartGreeting(createStartGreeting(savedProfile.name));
|
||||
setPresetMessageLength(0);
|
||||
setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
|
||||
} finally {
|
||||
setBirthTimeAssessmentPhase(null);
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function completeGuidedBirthTime(result: JourneyClientResponse) {
|
||||
if (result.nextAction.kind !== "ready") return;
|
||||
const confirmedProfile: Profile = {
|
||||
...profileDraft,
|
||||
time: result.nextAction.activeTime,
|
||||
birthTimeStatus: "confirmed",
|
||||
rectificationCaseId: result.caseId,
|
||||
};
|
||||
setProfile(confirmedProfile);
|
||||
setProfileDraft(confirmedProfile);
|
||||
setPresetMessageLength(0);
|
||||
setOnboardingJustCompleted(false);
|
||||
}
|
||||
|
||||
async function retryBirthTimeAssessment() {
|
||||
if (!account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeError("");
|
||||
try {
|
||||
const assessedProfile = await assessSavedBirthTime(profileDraft);
|
||||
setPresetMessageLength(0);
|
||||
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。");
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
if (signingOut) return;
|
||||
setSigningOut(true);
|
||||
setAccountError("");
|
||||
try {
|
||||
await selfHostedOtpActions.signOut();
|
||||
window.location.assign("/login");
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : "退出失败";
|
||||
setAccountError(friendlyError(message));
|
||||
setSigningOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
function chooseSuggestedQuestion(
|
||||
question: string,
|
||||
theme?: Theme,
|
||||
@@ -1631,194 +1371,6 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
|
||||
if (record.role !== "other") return;
|
||||
if (synastryPendingId) return;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react";
|
||||
|
||||
import {
|
||||
beamAvatarSchema,
|
||||
type BeamAvatarPatch,
|
||||
} from "@/lib/beam-avatar";
|
||||
import {
|
||||
applyPersistedBirthTime,
|
||||
birthTimePersistenceValues,
|
||||
isBirthTimeDraftReady,
|
||||
} from "@/lib/birth-time-intake-model";
|
||||
import {
|
||||
birthTimeConsultationOptionsCopy,
|
||||
createBirthTimeConsultationConsentState,
|
||||
type BirthTimeConsultationConsentState,
|
||||
type LatestAccountRequestGuard,
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import { composerDraftSnapshot } from "@/lib/composer-draft";
|
||||
import {
|
||||
activeChartStorageKey,
|
||||
fetchAccount,
|
||||
friendlyError,
|
||||
LoginRedirectError,
|
||||
saveCloudChartProfile,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import {
|
||||
birthProfileDeclarationChanged,
|
||||
buildSelfChartRecord,
|
||||
missingProfileStep,
|
||||
readProfile,
|
||||
selectedBirthPlace,
|
||||
} from "@/lib/home-profile";
|
||||
import { createStartGreeting } from "@/lib/onboarding-client";
|
||||
import {
|
||||
timestamp,
|
||||
type Account,
|
||||
type AccountDialog,
|
||||
type ChartLibraryRecord,
|
||||
type OnboardingStep,
|
||||
type Profile,
|
||||
} from "@/lib/home-types";
|
||||
import { preserveShallowEqual } from "@/lib/preserve-shallow-equal";
|
||||
import {
|
||||
requestBirthTimeAssessment,
|
||||
type JourneyClientResponse,
|
||||
} from "@/lib/birth-time-journey-client";
|
||||
import { previewRectificationJourney } from "@/lib/birth-time-guided-preview";
|
||||
import { selfHostedOtpActions } from "@/modules/identity/client";
|
||||
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
|
||||
export type ProfileOnboardingParams = {
|
||||
account: Account | null;
|
||||
accountRefreshGuard: MutableRefObject<LatestAccountRequestGuard>;
|
||||
accountTrigger: MutableRefObject<HTMLButtonElement | null>;
|
||||
activeChartId: string;
|
||||
avatarSaving: boolean;
|
||||
birthTimeRevisionPending: MutableRefObject<boolean>;
|
||||
chartLibrary: ChartLibraryRecord[];
|
||||
dialogReturnTarget: MutableRefObject<HTMLButtonElement | null>;
|
||||
profile: Profile;
|
||||
profileDraft: Profile;
|
||||
profileSaving: boolean;
|
||||
setAccount: Dispatch<SetStateAction<Account | null>>;
|
||||
setAccountError: Dispatch<SetStateAction<string>>;
|
||||
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setActiveAccountDialog: Dispatch<SetStateAction<AccountDialog | null>>;
|
||||
setActiveChartId: Dispatch<SetStateAction<string>>;
|
||||
setAvatarNotice: Dispatch<SetStateAction<string>>;
|
||||
setAvatarSaving: Dispatch<SetStateAction<boolean>>;
|
||||
setBirthTimeAssessmentPhase: Dispatch<SetStateAction<BirthTimeAssessmentPhase | null>>;
|
||||
setBirthTimeConsultationConsent: Dispatch<SetStateAction<BirthTimeConsultationConsentState>>;
|
||||
setBirthTimeError: Dispatch<SetStateAction<string>>;
|
||||
setBirthTimeJourney: Dispatch<SetStateAction<JourneyClientResponse | null>>;
|
||||
setDraft: (value: string) => void;
|
||||
setEditingSelfChart: Dispatch<SetStateAction<boolean>>;
|
||||
setOnboardingJustCompleted: Dispatch<SetStateAction<boolean>>;
|
||||
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
|
||||
setPresetMessageLength: Dispatch<SetStateAction<number>>;
|
||||
setProfile: Dispatch<SetStateAction<Profile>>;
|
||||
setProfileDraft: Dispatch<SetStateAction<Profile>>;
|
||||
setProfileNotice: Dispatch<SetStateAction<string>>;
|
||||
setProfileSaving: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationError: Dispatch<SetStateAction<string>>;
|
||||
setSigningOut: Dispatch<SetStateAction<boolean>>;
|
||||
setStartGreeting: Dispatch<SetStateAction<string>>;
|
||||
signingOut: boolean;
|
||||
uiPreview: MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
||||
const {
|
||||
account,
|
||||
accountRefreshGuard,
|
||||
accountTrigger,
|
||||
activeChartId,
|
||||
avatarSaving,
|
||||
birthTimeRevisionPending,
|
||||
chartLibrary,
|
||||
dialogReturnTarget,
|
||||
profile,
|
||||
profileDraft,
|
||||
profileSaving,
|
||||
setAccount,
|
||||
setAccountError,
|
||||
setAccountMenuOpen,
|
||||
setActiveAccountDialog,
|
||||
setActiveChartId,
|
||||
setAvatarNotice,
|
||||
setAvatarSaving,
|
||||
setBirthTimeAssessmentPhase,
|
||||
setBirthTimeConsultationConsent,
|
||||
setBirthTimeError,
|
||||
setBirthTimeJourney,
|
||||
setDraft,
|
||||
setEditingSelfChart,
|
||||
setOnboardingJustCompleted,
|
||||
setOnboardingStep,
|
||||
setPresetMessageLength,
|
||||
setProfile,
|
||||
setProfileDraft,
|
||||
setProfileNotice,
|
||||
setProfileSaving,
|
||||
setRectificationError,
|
||||
setSigningOut,
|
||||
setStartGreeting,
|
||||
signingOut,
|
||||
uiPreview,
|
||||
} = params;
|
||||
|
||||
async function refreshAccount() {
|
||||
const requestIdentity = accountRefreshGuard.current.begin();
|
||||
try {
|
||||
const latest = await fetchAccount();
|
||||
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
|
||||
const nextProfile = readProfile(latest.profile);
|
||||
const activeOther = activeChartId !== "self"
|
||||
&& chartLibrary.find((record) => record.id === activeChartId && record.role === "other");
|
||||
if (!activeOther) {
|
||||
setProfile((current) => preserveShallowEqual(current, nextProfile));
|
||||
}
|
||||
setAccount(latest);
|
||||
setAccountError("");
|
||||
} catch (caught) {
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
|
||||
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) {
|
||||
dialogReturnTarget.current = returnTarget ?? accountTrigger.current;
|
||||
setAccountMenuOpen(false);
|
||||
setAccountError("");
|
||||
if (dialog === "profile") {
|
||||
setProfileNotice("");
|
||||
setAvatarNotice("");
|
||||
}
|
||||
if (dialog === "chart-library") {
|
||||
setProfileDraft(profile);
|
||||
setProfileNotice("");
|
||||
setEditingSelfChart(false);
|
||||
}
|
||||
setActiveAccountDialog(dialog);
|
||||
}
|
||||
|
||||
function closeAccountDialog() {
|
||||
if (signingOut) return;
|
||||
setActiveAccountDialog(null);
|
||||
const returnTarget = dialogReturnTarget.current;
|
||||
window.requestAnimationFrame(() => returnTarget?.focus());
|
||||
}
|
||||
|
||||
async function persistAvatar(patch: BeamAvatarPatch) {
|
||||
if (!account?.avatar || avatarSaving) return;
|
||||
setAvatarSaving(true);
|
||||
setAvatarNotice("");
|
||||
setAccountError("");
|
||||
try {
|
||||
const response = await fetch("/api/account/avatar", {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null;
|
||||
if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。");
|
||||
const avatar = beamAvatarSchema.parse(payload?.avatar);
|
||||
setAccount((current) => current ? { ...current, avatar } : current);
|
||||
setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。");
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败"));
|
||||
} finally {
|
||||
setAvatarSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistProfile(nextProfile: Profile): Promise<Profile> {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile;
|
||||
const birthPlace = selectedBirthPlace(nextProfile);
|
||||
const response = await fetch("/api/account", {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: nextProfile.name.trim() || null,
|
||||
birth_date: nextProfile.date || null,
|
||||
...birthTimePersistenceValues(nextProfile),
|
||||
country_code: nextProfile.countryCode,
|
||||
province_code: nextProfile.provinceCode || null,
|
||||
city_code: nextProfile.cityCode || null,
|
||||
district_code: nextProfile.districtCode || null,
|
||||
birth_place_label: nextProfile.birthPlaceLabel || null,
|
||||
birth_place_type: nextProfile.birthPlaceType || null,
|
||||
birth_place_provider: nextProfile.birthPlaceProvider || null,
|
||||
birth_place_provider_id: nextProfile.birthPlaceProviderId || null,
|
||||
latitude: birthPlace?.lat ?? null,
|
||||
longitude: birthPlace?.lon ?? null,
|
||||
timezone_id: nextProfile.timezoneId || null,
|
||||
timezone_offset: birthPlace?.tz ?? null,
|
||||
timezone_source: nextProfile.timezoneSource || null,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as {
|
||||
error?: string;
|
||||
birthTime?: unknown;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || "账户资料暂时无法保存。");
|
||||
}
|
||||
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
|
||||
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
|
||||
setActiveChartId("self");
|
||||
localStorage.setItem(activeChartStorageKey(account.user.id), "self");
|
||||
await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null);
|
||||
return savedProfile;
|
||||
}
|
||||
|
||||
|
||||
async function assessSavedBirthTime(nextProfile: Profile) {
|
||||
const result = process.env.NODE_ENV === "development" && uiPreview.current
|
||||
? previewRectificationJourney
|
||||
: await requestBirthTimeAssessment();
|
||||
const nextStatus = result.snapshot.state === "ready"
|
||||
? "confirmed"
|
||||
: result.snapshot.state === "candidate"
|
||||
? "candidate"
|
||||
: "rectifying";
|
||||
const assessedProfile: Profile = {
|
||||
...nextProfile,
|
||||
time: result.snapshot.activeTime ?? "",
|
||||
birthTimeStatus: nextStatus,
|
||||
rectificationCaseId: result.caseId,
|
||||
};
|
||||
setBirthTimeJourney(result);
|
||||
setBirthTimeError("");
|
||||
setProfile(assessedProfile);
|
||||
setProfileDraft(assessedProfile);
|
||||
return assessedProfile;
|
||||
}
|
||||
|
||||
async function saveProfile(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setProfileNotice("");
|
||||
setAccountError("");
|
||||
try {
|
||||
const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft);
|
||||
const savedProfile = await persistProfile(profileDraft);
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setRectificationError("");
|
||||
if (declarationChanged) {
|
||||
setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState());
|
||||
void refreshAccount();
|
||||
}
|
||||
setProfileNotice(savedProfile.birthTimeStatus === "confirmed"
|
||||
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
|
||||
: `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnboardingName() {
|
||||
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
if (!name || !account || profileSaving) return;
|
||||
const nextProfile = { ...profileDraft, name };
|
||||
setProfileSaving(true);
|
||||
setAccountError("");
|
||||
try {
|
||||
const savedProfile = await persistProfile(nextProfile);
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setStartGreeting(createStartGreeting(savedProfile.name));
|
||||
setDraft("");
|
||||
setPresetMessageLength(0);
|
||||
const nextStep = missingProfileStep(savedProfile);
|
||||
if (nextStep) setOnboardingStep(nextStep);
|
||||
else setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败"));
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeAssessmentPhase("saving_profile");
|
||||
setAccountError("");
|
||||
try {
|
||||
const savedProfile = await persistProfile(profileDraft);
|
||||
birthTimeRevisionPending.current = false;
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setPresetMessageLength(0);
|
||||
const nextStep = missingProfileStep(savedProfile);
|
||||
if (nextStep) setOnboardingStep(nextStep);
|
||||
else setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败"));
|
||||
} finally {
|
||||
setBirthTimeAssessmentPhase(null);
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function editDeclaredBirthTimeDetails() {
|
||||
birthTimeRevisionPending.current = true;
|
||||
setBirthTimeError("");
|
||||
setPresetMessageLength(0);
|
||||
setOnboardingStep("birth");
|
||||
}
|
||||
|
||||
async function saveOnboardingPlace(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeAssessmentPhase("entering_home");
|
||||
setAccountError("");
|
||||
try {
|
||||
const savedProfile = await persistProfile(profileDraft);
|
||||
setProfile(savedProfile);
|
||||
setProfileDraft(savedProfile);
|
||||
setStartGreeting(createStartGreeting(savedProfile.name));
|
||||
setPresetMessageLength(0);
|
||||
setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
|
||||
} finally {
|
||||
setBirthTimeAssessmentPhase(null);
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function completeGuidedBirthTime(result: JourneyClientResponse) {
|
||||
if (result.nextAction.kind !== "ready") return;
|
||||
const confirmedProfile: Profile = {
|
||||
...profileDraft,
|
||||
time: result.nextAction.activeTime,
|
||||
birthTimeStatus: "confirmed",
|
||||
rectificationCaseId: result.caseId,
|
||||
};
|
||||
setProfile(confirmedProfile);
|
||||
setProfileDraft(confirmedProfile);
|
||||
setPresetMessageLength(0);
|
||||
setOnboardingJustCompleted(false);
|
||||
}
|
||||
|
||||
async function retryBirthTimeAssessment() {
|
||||
if (!account || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setBirthTimeError("");
|
||||
try {
|
||||
const assessedProfile = await assessSavedBirthTime(profileDraft);
|
||||
setPresetMessageLength(0);
|
||||
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false);
|
||||
} catch (caught) {
|
||||
setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。");
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
if (signingOut) return;
|
||||
setSigningOut(true);
|
||||
setAccountError("");
|
||||
try {
|
||||
await selfHostedOtpActions.signOut();
|
||||
window.location.assign("/login");
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : "退出失败";
|
||||
setAccountError(friendlyError(message));
|
||||
setSigningOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refreshAccount,
|
||||
openAccountDialog,
|
||||
closeAccountDialog,
|
||||
persistAvatar,
|
||||
persistProfile,
|
||||
assessSavedBirthTime,
|
||||
saveProfile,
|
||||
saveOnboardingName,
|
||||
saveOnboardingBirth,
|
||||
editDeclaredBirthTimeDetails,
|
||||
saveOnboardingPlace,
|
||||
completeGuidedBirthTime,
|
||||
retryBirthTimeAssessment,
|
||||
signOut,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
test("onboarding keeps the current card visible while birth-time assessment is pending", () => {
|
||||
// Given: saving the selected time can span profile persistence and assessment requests.
|
||||
const source = [
|
||||
readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"),
|
||||
homeSurface,
|
||||
readFileSync(new URL("../src/components/birth-time-assessment-overlay.tsx", import.meta.url), "utf8"),
|
||||
].join("\n");
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { guidedTerminalPath } from "../src/lib/birth-time-guided-terminal.ts";
|
||||
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
|
||||
import { dynamicBirthTimePreview } from "../src/lib/birth-time-dynamic-preview.ts";
|
||||
import { guidedBirthTimePreview } from "../src/lib/birth-time-guided-preview.ts";
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
test("draft revision publishes its new version before confirmation can fail", async () => {
|
||||
const original = guidedBirthTimePreview("birth-time-rectification-draft");
|
||||
@@ -147,7 +148,7 @@ test("unconfirmed terminal candidates preserve the range without offering direct
|
||||
|
||||
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
|
||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const pageSource = homeSurface;
|
||||
|
||||
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
|
||||
assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/);
|
||||
|
||||
@@ -69,7 +69,7 @@ test("auth redirects stay hard document loads so stale session state cannot surv
|
||||
assert.doesNotMatch(pageSource, /router\.replace\("\/login"\)/);
|
||||
|
||||
// And: the login redirect after sign-out drops the whole React tree with the session.
|
||||
const signOut = sourceBetween(pageSource, "async function signOut()", "function chooseSuggestedQuestion");
|
||||
const signOut = sourceBetween(pageSource, "async function signOut()", "return {\n refreshAccount,");
|
||||
assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/);
|
||||
|
||||
// And: the sibling membership surface keeps the same precedent.
|
||||
|
||||
@@ -15,6 +15,8 @@ const homeSurfaceFiles = [
|
||||
const optionalHomeHookFiles = [
|
||||
"../src/hooks/use-session-management.ts",
|
||||
"../src/hooks/use-consultation-run.ts",
|
||||
"../src/hooks/use-profile-onboarding.ts",
|
||||
"../src/hooks/use-rectification-surface.ts",
|
||||
] as const;
|
||||
|
||||
export const homeSurface = [
|
||||
|
||||
@@ -2,14 +2,16 @@ import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
test("upserts a missing profile when saving account details", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const source = homeSurface;
|
||||
assert.match(source, /fetch\("\/api\/account",[\s\S]*?\{[\s\S]*?method:\s*"PATCH"/);
|
||||
assert.match(source, /credentials:\s*"same-origin"/);
|
||||
});
|
||||
|
||||
test("every profile save adopts the birth-time truth the account write returned", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const source = homeSurface;
|
||||
|
||||
// Given: the account write derives birth-time status and active minute.
|
||||
assert.match(source, /const savedProfile = applyPersistedBirthTime\(nextProfile, payload\?\.birthTime\)/);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { preserveShallowEqual } from "../src/lib/preserve-shallow-equal.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
import { homeSurface as pageSource } from "./home-surface.ts";
|
||||
|
||||
test("preserves the current profile reference when refreshed values are unchanged", () => {
|
||||
const current = {
|
||||
|
||||
@@ -128,7 +128,7 @@ test("server profile failures pause automatic rectification resume", () => {
|
||||
);
|
||||
const incompleteHandler = page.slice(
|
||||
page.indexOf("function handleRectificationProfileIncomplete"),
|
||||
page.indexOf("async function draftSynastryQuestionFromChart"),
|
||||
page.indexOf("function handleRectificationMessagesChange"),
|
||||
);
|
||||
assert.match(resumeEffect, /\|\| rectificationError\) return/);
|
||||
assert.match(incompleteHandler, /setRectificationError\("profile_incomplete"\)/);
|
||||
|
||||
@@ -139,6 +139,8 @@ def _home_surface() -> str:
|
||||
FRONTEND / "src" / "components" / "starter-home.tsx",
|
||||
FRONTEND / "src" / "hooks" / "use-session-management.ts",
|
||||
FRONTEND / "src" / "hooks" / "use-consultation-run.ts",
|
||||
FRONTEND / "src" / "hooks" / "use-profile-onboarding.ts",
|
||||
FRONTEND / "src" / "hooks" / "use-rectification-surface.ts",
|
||||
]
|
||||
return "".join(path.read_text(encoding="utf-8") for path in files if path.exists())
|
||||
|
||||
|
||||
@@ -4,12 +4,20 @@ from pathlib import Path
|
||||
PAGE = Path("frontend/src/app/page.tsx")
|
||||
STARTER_HOME = Path("frontend/src/components/starter-home.tsx")
|
||||
HOME_CLOUD = Path("frontend/src/lib/home-cloud-sync.ts")
|
||||
RECTIFICATION_HOOK = Path("frontend/src/hooks/use-rectification-surface.ts")
|
||||
DAILY_ROUTE = Path("frontend/src/app/api/daily-starlanguage/route.ts")
|
||||
RECTIFICATION_ROUTE = Path("frontend/src/app/api/birth-rectification/route.ts")
|
||||
|
||||
|
||||
def _home_entrypoint_surface() -> str:
|
||||
parts = [PAGE, STARTER_HOME, HOME_CLOUD]
|
||||
if RECTIFICATION_HOOK.exists():
|
||||
parts.append(RECTIFICATION_HOOK)
|
||||
return "".join(path.read_text(encoding="utf-8") for path in parts)
|
||||
|
||||
|
||||
def test_daily_starlanguage_entrypoint_is_productized() -> None:
|
||||
source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8")
|
||||
source = _home_entrypoint_surface()
|
||||
assert "今日星语" in source
|
||||
assert "fetchDailyStarlanguage" in source
|
||||
assert "daily-starlanguage-card" in source
|
||||
@@ -32,7 +40,7 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
|
||||
|
||||
|
||||
def test_birth_time_rectification_entrypoint_is_productized() -> None:
|
||||
source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8")
|
||||
source = _home_entrypoint_surface()
|
||||
assert "生时校正" in source
|
||||
assert "birth-rectification-card" in source
|
||||
assert "openRectificationFromHomepage" in source
|
||||
|
||||
@@ -17,6 +17,8 @@ HOME_SURFACE_FILES = (
|
||||
OPTIONAL_HOME_HOOKS = (
|
||||
_FRONTEND_SRC / "hooks" / "use-session-management.ts",
|
||||
_FRONTEND_SRC / "hooks" / "use-consultation-run.ts",
|
||||
_FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts",
|
||||
_FRONTEND_SRC / "hooks" / "use-rectification-surface.ts",
|
||||
)
|
||||
STYLES = Path("frontend/src/app/globals.css")
|
||||
SESSION_ROW = Path("frontend/src/components/sidebar-session-row.tsx")
|
||||
|
||||
@@ -55,6 +55,8 @@ HOME_SURFACE_FILES = (
|
||||
OPTIONAL_HOME_HOOKS = (
|
||||
_FRONTEND_SRC / "hooks" / "use-session-management.ts",
|
||||
_FRONTEND_SRC / "hooks" / "use-consultation-run.ts",
|
||||
_FRONTEND_SRC / "hooks" / "use-profile-onboarding.ts",
|
||||
_FRONTEND_SRC / "hooks" / "use-rectification-surface.ts",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user