Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e005340b | ||
|
|
e530d6bbfe |
@@ -1,5 +1,11 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-25 — 首页内部重构,无用户可见变化
|
||||
|
||||
- 首页启动流程、咨询恢复轮询、错误屏和引导屏从首页组件拆到独立模块。落点、20 秒错误屏、局部重试、pending 优先级、恢复轮询和文案都不变。
|
||||
- `?settings=chart` 仍打开星盘资料。
|
||||
- Skill 版本不 bump。浏览器和 `next build` 未验收。
|
||||
|
||||
## 2026-09-25 — 0 年大运、星历切日期、星盘无资料入口(待验收)
|
||||
|
||||
- Chara 出现 0 年大运时照实显示「0 年」,不占时间轴、不展开子运,不再把整张盘打成「算不出来」。不改引擎口径。
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# PROGRESS · 首页 page.tsx 拆分(2026-09-25)
|
||||
|
||||
## 当前结论
|
||||
|
||||
T1、T2、T3、T5 完成。T4 让步到 1417 行(允许 ≤1450,未到 ≤1300)。没有新 BUG。没有推 `staging`,没有推 `main`。`?settings=chart` 仍打开星盘资料。
|
||||
|
||||
- 工作树:`G:\Ferti\Jyotisha\.worktrees\home-page-split-20260925`
|
||||
- 分支:`codex/home-page-split-20260925`
|
||||
- 开工基线:`origin/staging` = `59c23018`(与本分支当时一致)
|
||||
- 开工后 `origin/staging` 又前进了报告读者版 `3c2f7bd5` 和状态板 `9167d75e`。这两笔不碰首页。本分支没有把它们合进来,也没有快进 `staging`。
|
||||
- 实现提交:`e530d6bbfeaf337f7040b60d6b6635746f136656`(短 SHA `e530d6bb`)。状态板写这个 SHA。
|
||||
|
||||
## 逐项
|
||||
|
||||
| 项 | 结果 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 完成 | `runHomeBootstrap(deps, signal)` 在 `frontend/src/lib/home-bootstrap-run.ts`。内部再拆成 `runAccountPhase` / `resolveLanding`(注入项超过 15)。新文件 0 个 React hook。`Home()` 只留一个 effect 调用它。 |
|
||||
| T2 | 完成 | `runConsultationRecoveryPoll` 在 `frontend/src/lib/consultation-recovery-poll.ts`。假计时器覆盖 404 第一次只确认、下一次重放一次、第 4 次放弃、reserved 成功把缺失计数清零。 |
|
||||
| T3 | 完成 | `HomeBootstrapError`、`HomeOnboardingShell`。文案和按钮与拆分前一致。「重试」仍走 `retryHomeBootstrap(...reloadSessionList)`。 |
|
||||
| T4 | 让步 | `page.tsx` 1417 行(换行计数,与增长合同相同)。目标 ≤1300 未到,任务书允许先到 ≤1450。import 挤行已还原:`^import .*; import ` 对 `page.tsx` 为 0。增长合同 `PAGE_LINE_COUNT_BASELINE` 下调到 1417,上限 1567。`useState` 仍是 34(上限保持 36),`useRef` 仍是 37(上限保持 37),都没有增加。 |
|
||||
| T5 | 完成 | DESIGN 未改。CHANGELOG 一条「首页内部重构,无用户可见变化」。不新开 BUG。 |
|
||||
|
||||
## 还留在 Home() 里的大块
|
||||
|
||||
下一步若要压到 1300,还在首页里的是:账户弹窗模型(资料 / 星盘库 / 点数 / 退出)、对话区和 `ChatComposer` 装配、星盘库同步、今日星语、设置深链。设置深链必须留在首页,`query.pane === "chart"` 仍调用 `openAccountDialog("chart-library")`。
|
||||
|
||||
## 断言改写
|
||||
|
||||
| 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `new-chat-recovery.test.ts` 把 page.tsx 激活段和 hook 的 restore 放进 vm,断言 active 等于预置本地会话 | 调用 `runHomeBootstrap`,restore 仍是 hook 里的真实函数;new-chat 会新建空咨询,恢复后仍停在这条新会话 | 激活段已搬进函数。直接调用还覆盖「新建落点与 reserved 恢复共存」,比只跑后半段更强 |
|
||||
| `consultation-recovery.test.ts` 按 page.tsx 恢复 effect 切片匹配 404 / 重放 / 第 4 次放弃 | 直接调用 `runConsultationRecoveryPoll`;逐步假计时器在 `consultation-recovery-poll.test.ts` | 轮询搬出后,源码切片不再落在 page.tsx |
|
||||
| 从 page.tsx 切 `storedPending` 分支,并用源码顺序断言它先于全局状态 | 调用 `runHomeBootstrap`:本页 pending 覆盖全局 reserved;无 pending 时不删 key 的反例由「有 pending 且 reserved 则不删 key」锁住。写入 `sessionId/requestId/question` 的 effect 仍留在 page.tsx,继续按该 effect 切片 | 启动流程搬走;优先级改由结果锁定,不弱于源码顺序 |
|
||||
| `homeSurface` 里预留判断、`window.setTimeout(() => void poll(), 1_750)`、完成后的 `fetchSessionDetail` | 预留判断在 `home-bootstrap-run.ts`;1750ms 和详情读取在 `consultation-recovery-poll.ts`,调用变成 `fetchDetail(deps.pendingSessionId, deps.modelCatalog, signal)` | 两段流程搬出首页。状态端点、间隔、完成后拉详情都还在 |
|
||||
| `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)` 在 homeSurface | 同一调用在 `home-bootstrap-run.ts` | 启动恢复搬走,仍把 stored pending 交给真实 restore |
|
||||
| page.tsx 直接调用 `resolveStarterHomeLandingSessionId` / `starterHomeLandingNeedsConsultation`,并引用 `HOME_BOOTSTRAP_SLOW_MS` | 这两个调用和 20 秒预算在 `home-bootstrap-run.ts`;`shouldAutoOpenRectificationSession` 仍在 page.tsx | 落点和超时属于启动流程。揭幕阶段的校正会话判断留在首页 |
|
||||
| `if (bootstrapFailed) setHydrated(true); else setBootstrapPhase("prepare")` 在 page.tsx | 同一分支在 `runHomeBootstrap`,setter 经 `deps.commit` | 失败仍揭幕错误屏,成功仍进入 prepare,不半揭幕 |
|
||||
| 错误屏 `<main className="app-loading app-loading-error">` 在 page.tsx | 同一段在 `home-bootstrap-error.tsx`。加载中的 polite main 仍在 page.tsx | 错误屏抽成组件,class 与 `role="alert"` 不变 |
|
||||
| homeSurface 两处「回答已恢复,已显示在对话区末尾。」 | `use-consultation-run.ts` 与 `consultation-recovery-poll.ts` 各一处 | 轮询搬走,两处文案都还在 |
|
||||
| page.tsx 里 `clearStaleClientReload(sessionStorage)`,以及从 `loadCloudData` 切登录跳转不关超时 | 首页传入 `storage: sessionStorage`,函数内 `clearStaleClientReload(deps.storage)`;登录跳转的 try/finally 在 `home-bootstrap-run.ts`,仍是 `window.clearTimeout(bootstrapTimeout)` 且 `redirectedToLogin` 时提前 return | 清的仍是会话存储。登录跳转仍不取消 20 秒超时 |
|
||||
| 从 homeSurface 切 `async function loadCloudData()` | 切 `resolveLanding`。`defaultSessionId` 现为 `ready.nextSessions[0].id` | 落点代码原样搬家。清 new-chat URL 仍在 `setActiveSessionId` 之前,默认落点仍不 `push` |
|
||||
| 从 page.tsx 切 `if (previewMode)` 到 `await sessionListReady` | 预览分支在 `home-bootstrap-run.ts`,结束锚点是 `await deps.sessionListReady`。首页把 `sessionListReady` / `sessionListBoot` 传进去 | 预览仍不写会话 URL。`/` 仍不在首页里 `fetchSessions` |
|
||||
| `session-list-provider.test.ts` 匹配 page.tsx 的 `await sessionListReady;` | 匹配首页传入的 `sessionListReady`,以及函数里的 `await deps.sessionListReady` | 会话列表仍只由 provider 拉一次 |
|
||||
|
||||
## 验证
|
||||
|
||||
开工前,下面四份测试 `npx tsx --test`:**28 pass / 0 fail**。
|
||||
|
||||
- `frontend/tests/new-chat-recovery.test.ts`
|
||||
- `frontend/tests/consultation-recovery.test.ts`
|
||||
- `frontend/tests/home-shell-growth-contract.test.ts`
|
||||
- `frontend/tests/home-bootstrap-reveal.test.ts`
|
||||
|
||||
改完后同一组,加上本单新测试和会被搬家碰到的合同(`chat-session-url`、`stale-client-recovery`、`chat-navigation-a11y-contract`、`session-list-provider`、`home-bootstrap-run`、`consultation-recovery-poll`、`home-bootstrap-screens`、`settings-url`):**全部通过,没有新增失败**。最后一次合并跑是 81 pass / 0 fail;补上地点卡文案后又跑了 screens + settings-url,7 pass / 0 fail。增长合同与 `tsc --noEmit` 随后再跑,exit 0。
|
||||
|
||||
- `npx tsx --test` 使用明确文件路径。没有用全量 `npm test` 当信号。
|
||||
- `.\node_modules\.bin\tsc --noEmit`:0 错。
|
||||
- `npm run lint`:0 error,124 warnings。没有去清原本就未使用的 import,也没有为消 warning 改 effect 依赖。
|
||||
- `npm run build`:**没有通过**。Turbopack 拒绝本工作树的 `frontend/node_modules` junction(指向主检出,超出它认定的文件系统根)。按约束没有删除或重建这个 junction,也没有 `npm install`。因此不能声称 `/` 仍是 Static,也不能报首屏 gzip。
|
||||
|
||||
## 没做的验证
|
||||
|
||||
- 没有登录浏览器走首页。本单声称无用户可见变化,靠测试锁行为。
|
||||
- 没有 `next build`,见上。
|
||||
- 没有推 `origin/staging`,没有推 `main`,没有改 workflow,没有动 DNS。
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-home-page-split-20260925.md` | — | **首页 page.tsx 拆分(只搬不改)**:启动流程约 285 行抽成 lib 纯函数 `runHomeBootstrap`、恢复轮询约 100 行抽出、错误 / 引导屏抽组件,目标 ≤1300 行并下调增长合同基线;两个按源码切片的测试改为直接调用函数;还原 824647f2 的 import 挤行。排在 chart-ephemeris 之后、people-archive P1 之前 | 待领取 | — |
|
||||
| `TASK-home-page-split-20260925.md` | `PROGRESS-home-page-split-20260925.md` | **首页 page.tsx 拆分(只搬不改)**:启动流程约 285 行抽成 lib 纯函数 `runHomeBootstrap`、恢复轮询约 100 行抽出、错误 / 引导屏抽组件,目标 ≤1300 行并下调增长合同基线;两个按源码切片的测试改为直接调用函数;还原 824647f2 的 import 挤行。排在 chart-ephemeris 之后、people-archive P1 之前 | 待验收 | `e530d6bb` |
|
||||
| `TASK-staging-gate-fix-20260924.md` | — | **修复单(阻塞 staging 部署)**:慢网首页 `0e4dd0f4` 起 5 条源码合同测试未跟上(硬跳转计数、重试 reload 正则、两处按已删语句切片)→ 门禁红,`0e4dd0f4..f125fae0` 全部未部署;另 page.tsx +9 行、订阅支付不刷新余额、原始附录三文件残留。只修测试与收尾,不改已验收行为;失败名单须与 `edc9c22c` 逐条一致 | 已验收(Claude 09-25:5 条转绿、失败名单与 edc9c22c 逐条一致 56/56、门禁 run 2877 绿;import 挤行凑数为 P3,由 home-page-split D5 还原;原始附录三文件仍未删) | `651c75e7`/`7a001456`/`824647f2`;deploy run 2879 因 ACR 网络失败,待同 SHA 重跑 |
|
||||
| `TASK-people-archive-p1-20260924.md` | — | **星盘档案(多人物)P1**:每个档案 = 一个人(含本人),最多 5 人、无关系字段;标题旁人名 = 全局切换器(对话 / 星盘 / 星历 / 报告 / 档案页同一组件),星盘、星历、报告、今日星语、新建对话都跟当前人物走(推翻 BUG-1000 新对话固定本人);历史只显示当前人物;档案管理改侧栏 `/people` 页、设置删星盘资料;删人连带删对话与报告;取消「设为默认」/`activeChartId`(BUG-1030 错人)。他人资料改类型化列 + 与户主同级守卫,统一 `resolveSubjectBirth`;户主暂留 `profiles`。生时校正跟人与任意两人合盘留 P2。**同日四单全部合入后开工**,需 Docker 跑 test:db | 待领取 | — |
|
||||
| `TASK-home-slow-network-20260924.md` | — | **慢网首页半揭幕 + 账户与点数打开慢(BUG-1021/1022)**:8 秒硬超时包住多跳串行请求、超时不补拉,独立的会话列表 provider 晚到 → 正常首页+报错条、模型「暂不可用」、新建对话灰、重问称呼。产品 09-24 拍板:推翻 8 秒硬超时,等齐再揭幕,20 秒才出错误屏且重试为局部重请求(不整页刷新);账单面板删无用 `/api/account`、预取 chunk、套餐缓存。先于 chart-ephemeris T3 合入 | 已验收(经修复单,Claude 09-25);待部署核对 | `0e4dd0f4`…`824647f2` |
|
||||
|
||||
+91
-524
@@ -1,21 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { loadBillingPanel } from "@/lib/billing-panel-loader";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { ProfilePanel } from "@/components/profile-panel"; import { ThemePreferencePanel } from "@/components/theme-preference-menu";
|
||||
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
import { ProfilePanel } from "@/components/profile-panel";
|
||||
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
|
||||
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
|
||||
import { HomeBootstrapError } from "@/components/home-bootstrap-error";
|
||||
import { HomeOnboardingShell } from "@/components/home-onboarding-shell";
|
||||
import {
|
||||
entrySummaryFromResponse,
|
||||
rectificationEntryLabels,
|
||||
resolveRectificationEntryAction,
|
||||
} from "@/lib/rectification-entry";
|
||||
import {
|
||||
composerLocksAsRectification, dropRectificationStoredPending,
|
||||
composerLocksAsRectification,
|
||||
ordinaryComposerPlaceholder,
|
||||
rectificationOrdinaryComposerReopen,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
@@ -39,9 +42,6 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration";
|
||||
import { useSessionList } from "@/hooks/use-session-list";
|
||||
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
|
||||
import { BirthLocationFields } from "@/components/birth-location-fields";
|
||||
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
|
||||
import { ChartLibraryPanel } from "@/components/chart-library-panel";
|
||||
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
|
||||
import {
|
||||
@@ -50,13 +50,7 @@ import {
|
||||
isRectificationHandoffQuestion,
|
||||
type ConsultationEntrypoint,
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import {
|
||||
applyBirthTimeDraftPatch,
|
||||
assistantIntentCopy,
|
||||
hydrateDeclaredWindowDraft,
|
||||
isBirthTimeDraftReady,
|
||||
birthTimeDraftReadyHint,
|
||||
} from "@/lib/birth-time-intake-model";
|
||||
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
|
||||
import {
|
||||
clearBirthTimeConsultationConsent,
|
||||
createLatestAccountRequestGuard,
|
||||
@@ -82,12 +76,6 @@ import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-ann
|
||||
import {
|
||||
type JourneyClientResponse,
|
||||
} from "@/lib/birth-time-journey-client";
|
||||
import {
|
||||
guidedBirthTimePreview,
|
||||
isGuidedBirthTimePreview,
|
||||
previewRectificationJourney,
|
||||
} from "@/lib/birth-time-guided-preview";
|
||||
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import {
|
||||
BALANCE_SYNC_KEY,
|
||||
@@ -123,16 +111,8 @@ import {
|
||||
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
|
||||
CONSULTATION_LOADING_METHOD_LABEL,
|
||||
} from "@/lib/consultation-activity-labels";
|
||||
import {
|
||||
SESSION_MISSING_NOTICE,
|
||||
clearLoginSessionReturn,
|
||||
writeSessionUrl,
|
||||
} from "@/lib/chat-session-url";
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import {
|
||||
createStartGreeting,
|
||||
createStartGreetingParts,
|
||||
} from "@/lib/starter-greeting";
|
||||
import { createStartGreetingParts } from "@/lib/starter-greeting";
|
||||
import {
|
||||
calendarDateInTimeZone,
|
||||
dailyStarlanguageProfileKey,
|
||||
@@ -149,18 +129,11 @@ import {
|
||||
accountDialogClasses,
|
||||
accountDialogTitles,
|
||||
dailyStarlanguageRetryDelayMs,
|
||||
emptyProfile,
|
||||
pendingConsultationStorageKey,
|
||||
presetOnboardingMessage,
|
||||
previewModelCatalog,
|
||||
timestamp,
|
||||
type Account,
|
||||
type AccountDialog,
|
||||
type ChartLibraryRecord,
|
||||
type ChatSession,
|
||||
type ConsultationStatus,
|
||||
type DailyStarlanguageState,
|
||||
type Message,
|
||||
type OnboardingStep,
|
||||
type OpenAccountDialogOptions,
|
||||
type PendingConsultation,
|
||||
@@ -174,67 +147,39 @@ import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
|
||||
import { useChatProfileCatalog } from "@/hooks/use-chat-profile-catalog";
|
||||
import {
|
||||
birthQuestion,
|
||||
chartSnapshotForSession,
|
||||
completedOnboardingMessage,
|
||||
completedOnboardingTranscript,
|
||||
formatBirthMoment,
|
||||
isProfileComplete,
|
||||
missingProfileStep,
|
||||
placeQuestion,
|
||||
readProfile,
|
||||
selectedBirthPlace,
|
||||
sessionChartLabel, upsertSelfChart,
|
||||
} from "@/lib/home-profile";
|
||||
import {
|
||||
activeChartStorageKey,
|
||||
applyLegacySessionControls,
|
||||
CancellationResponseError,
|
||||
ConsultationResponseError,
|
||||
ConsultationStatusError,
|
||||
createSession,
|
||||
discardLegacyCloudMirrorKeys,
|
||||
fetchActiveConsultationStatus,
|
||||
fetchCloudChartLibrary,
|
||||
fetchCloudSynastryHistory,
|
||||
fetchConsultationStatus,
|
||||
fetchDailyStarlanguage,
|
||||
fetchModelCatalog,
|
||||
fetchSessionDetail,
|
||||
friendlyError,
|
||||
LoginRedirectError,
|
||||
mergeHydratedSession,
|
||||
patchSessionModel,
|
||||
resolveLookupBootstrap,
|
||||
payloadCode,
|
||||
payloadMessage,
|
||||
readSessions,
|
||||
readStoredDailyStarlanguage,
|
||||
readStoredPendingConsultation,
|
||||
redirectToLogin,
|
||||
waitForUndoWindow,
|
||||
writeStoredDailyStarlanguage,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import { runHomeBootstrap } from "@/lib/home-bootstrap-run";
|
||||
import { runConsultationRecoveryPoll } from "@/lib/consultation-recovery-poll";
|
||||
import {
|
||||
bootstrapLoadingCopy,
|
||||
bootstrapPrepareSettled,
|
||||
bootstrapRevealDelayMs,
|
||||
resolveStarterHomeLandingSessionId,
|
||||
retryHomeBootstrap,
|
||||
sessionIdsToPrefetch,
|
||||
shouldAutoOpenRectificationSession,
|
||||
starterHomeLandingNeedsConsultation,
|
||||
HOME_BOOTSTRAP_SLOW_MS,
|
||||
type BootstrapPhase,
|
||||
} from "@/lib/home-bootstrap";
|
||||
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
|
||||
},
|
||||
);
|
||||
const BillingPanel = dynamic(loadBillingPanel, {
|
||||
ssr: false,
|
||||
loading: () => <p className="secondary-page-waiting">账户信息还没拿到。</p>,
|
||||
@@ -760,7 +705,6 @@ export default function Home() {
|
||||
&& pendingSessionId !== activeSession.id,
|
||||
);
|
||||
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
|
||||
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
|
||||
const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id);
|
||||
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions);
|
||||
const conversationAnchor = useConversationScrollAnchor(
|
||||
@@ -842,287 +786,36 @@ export default function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const bootstrapTimeout = window.setTimeout(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort();
|
||||
setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。");
|
||||
setHydrated(true);
|
||||
}, HOME_BOOTSTRAP_SLOW_MS);
|
||||
|
||||
async function loadCloudData() {
|
||||
let redirectedToLogin = false;
|
||||
let bootstrapFailed = false;
|
||||
try {
|
||||
const previewMode = process.env.NODE_ENV === "development"
|
||||
? new URLSearchParams(window.location.search).get("preview")
|
||||
: null;
|
||||
if (previewMode) {
|
||||
uiPreview.current = true;
|
||||
uiPreviewMode.current = previewMode;
|
||||
setGuidedJourneyPreview(true);
|
||||
if (previewMode === "error") {
|
||||
setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。");
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading";
|
||||
const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete";
|
||||
const isRectificationPreview = isGuidedBirthTimePreview(previewMode);
|
||||
const previewJourney = isRectificationPreview
|
||||
? guidedBirthTimePreview(previewMode)
|
||||
: previewRectificationJourney;
|
||||
const previewProfile: Profile = previewMode === "onboarding"
|
||||
? emptyProfile
|
||||
: {
|
||||
...emptyProfile,
|
||||
name: "林遥",
|
||||
date: "1990-06-15",
|
||||
time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30",
|
||||
reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30",
|
||||
birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import",
|
||||
...hydrateDeclaredWindowDraft({
|
||||
period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
|
||||
}),
|
||||
birthTimeClue: "",
|
||||
uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null,
|
||||
uncertaintyAfterMinutes: isRectificationPreview ? 30 : null,
|
||||
birthTimeStatus: isCompletedCandidatePreview
|
||||
? "candidate"
|
||||
: isAssessmentLoadingPreview
|
||||
? "rectifying"
|
||||
: previewJourney.snapshot.state === "candidate"
|
||||
|| previewJourney.snapshot.state === "confirming"
|
||||
|| previewJourney.snapshot.state === "ready"
|
||||
? "candidate"
|
||||
: isRectificationPreview ? "rectifying" : "confirmed",
|
||||
rectificationCaseId: isRectificationPreview ? previewJourney.caseId : "",
|
||||
countryCode: "CN",
|
||||
provinceCode: "110000",
|
||||
cityCode: "110000-city",
|
||||
districtCode: "110101",
|
||||
};
|
||||
const previewMessages: Message[] = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial"
|
||||
? [
|
||||
{ role: "user", text: "未来半年是否适合换工作?" },
|
||||
{ role: "assistant", text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" },
|
||||
]
|
||||
: [];
|
||||
const previewSession: ChatSession = {
|
||||
id: "preview-session",
|
||||
title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话",
|
||||
theme: "career",
|
||||
modelId: previewModelCatalog.defaultModelId,
|
||||
messages: previewMessages,
|
||||
updatedAt: timestamp(),
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: "self",
|
||||
chartProfileName: previewProfile.name.trim() || "我",
|
||||
chartProfileRole: "self",
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
};
|
||||
setAccount({
|
||||
user: { id: "preview-user", email: "preview@local.test" },
|
||||
avatar: null,
|
||||
credits: 8,
|
||||
isAdmin: false,
|
||||
adminUrl: null,
|
||||
rectificationPriceCredits: 1,
|
||||
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
|
||||
hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed",
|
||||
activeSubscription: null,
|
||||
profile: previewProfile,
|
||||
});
|
||||
setModelCatalog(previewModelCatalog);
|
||||
setProfile(previewProfile);
|
||||
setProfileDraft(previewProfile);
|
||||
if (isRectificationPreview) {
|
||||
setBirthTimeJourney(previewJourney);
|
||||
}
|
||||
if (isAssessmentLoadingPreview) {
|
||||
setBirthTimeAssessmentPhase("assessing");
|
||||
}
|
||||
setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name");
|
||||
setSessions([previewSession]);
|
||||
setActiveSessionId(previewSession.id);
|
||||
const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : "";
|
||||
setStartGreeting(previewGreeting);
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start independent reads before waiting for the provider so slow-network
|
||||
// latency is spent in parallel, not as a serial account -> model chain.
|
||||
const modelCatalogPromise = fetchModelCatalog(controller.signal)
|
||||
.then((catalog) => ({ catalog, error: null as unknown }))
|
||||
.catch((error: unknown) => ({ catalog: null, error }));
|
||||
const activeStatusPromise = fetchActiveConsultationStatus(controller.signal).catch((caught: unknown) => {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
return null;
|
||||
});
|
||||
await sessionListReady;
|
||||
if (controller.signal.aborted) return;
|
||||
const listBoot = sessionListBoot();
|
||||
if (listBoot?.signedOut || !listBoot?.account) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
const nextAccount = listBoot.account;
|
||||
const modelCatalogResult = await modelCatalogPromise;
|
||||
if (modelCatalogResult.error) throw modelCatalogResult.error;
|
||||
const nextModelCatalog = modelCatalogResult.catalog;
|
||||
const nextProfile = readProfile(nextAccount.profile);
|
||||
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
|
||||
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
|
||||
if (nextSessions.length === 0) {
|
||||
nextSessions = [createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
)];
|
||||
}
|
||||
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
|
||||
|
||||
let reservedConsultation: ConsultationStatus | null = await activeStatusPromise;
|
||||
const storedPending = dropRectificationStoredPending(
|
||||
readStoredPendingConsultation(sessionStorage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
|
||||
nextSessions,
|
||||
);
|
||||
if (!storedPending) sessionStorage.removeItem(pendingConsultationStorageKey);
|
||||
|
||||
if (storedPending) {
|
||||
try {
|
||||
const status = await fetchConsultationStatus(
|
||||
storedPending.sessionId,
|
||||
storedPending.requestId,
|
||||
controller.signal,
|
||||
);
|
||||
if (status.status === "reserved") {
|
||||
consultationStatusMissingCount.current = 0;
|
||||
reservedConsultation = status;
|
||||
} else {
|
||||
sessionStorage.removeItem(pendingConsultationStorageKey);
|
||||
reservedConsultation = null;
|
||||
}
|
||||
} catch (caught) {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
|
||||
reservedConsultation = {
|
||||
sessionId: storedPending.sessionId,
|
||||
requestId: storedPending.requestId,
|
||||
status: "reserved",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
const lookedUp = await resolveLookupBootstrap({
|
||||
sessions: nextSessions, catalog: nextModelCatalog,
|
||||
defaultSessionId: nextSessions[0].id, signal: controller.signal,
|
||||
});
|
||||
nextSessions = lookedUp.sessions;
|
||||
const bootstrapSelection = lookedUp.selection;
|
||||
let landingSessionId = resolveStarterHomeLandingSessionId(
|
||||
nextSessions,
|
||||
bootstrapSelection.sessionId,
|
||||
bootstrapSelection.urlAction,
|
||||
);
|
||||
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
|
||||
const homeSession = createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
);
|
||||
nextSessions = [homeSession, ...nextSessions];
|
||||
landingSessionId = homeSession.id;
|
||||
}
|
||||
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
|
||||
?? nextSessions[0];
|
||||
if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") {
|
||||
try {
|
||||
const detailed = await fetchSessionDetail(activeListed.id, nextModelCatalog, controller.signal);
|
||||
if (detailed) {
|
||||
nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session);
|
||||
}
|
||||
} catch (caught) {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
}
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
clearStaleClientReload(sessionStorage);
|
||||
setSessionsCursor(listBoot.cursor);
|
||||
setAccount(nextAccount);
|
||||
setModelCatalog(nextModelCatalog);
|
||||
setProfile(nextProfile);
|
||||
setProfileDraft(nextProfile);
|
||||
setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : "");
|
||||
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
|
||||
setSessions(nextSessions);
|
||||
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
|
||||
setActiveSessionId(landingSessionId);
|
||||
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
|
||||
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
|
||||
if (bootstrapSelection.urlAction === "replace-selected") {
|
||||
const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId);
|
||||
if (!selected || !isUnsavedEmptyConsultation(selected)) {
|
||||
writeSessionUrl(bootstrapSelection.sessionId, "replace");
|
||||
}
|
||||
}
|
||||
if (reservedConsultation?.status === "reserved") {
|
||||
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
|
||||
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
|
||||
if (bootstrapSelection.urlAction === "new-chat") {
|
||||
setActiveSessionId(landingSessionId);
|
||||
setComposerNotice("");
|
||||
}
|
||||
}
|
||||
if (parsedSessions.fallbackSessionIds.length > 0) {
|
||||
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
|
||||
} else if (lookedUp.notice) {
|
||||
setComposerNotice(lookedUp.notice);
|
||||
} else if (bootstrapSelection.missing) {
|
||||
setComposerNotice(SESSION_MISSING_NOTICE);
|
||||
}
|
||||
setAccountError("");
|
||||
|
||||
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
|
||||
try {
|
||||
await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) =>
|
||||
patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal),
|
||||
));
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
if (caught instanceof LoginRedirectError) {
|
||||
redirectedToLogin = true;
|
||||
return;
|
||||
}
|
||||
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
|
||||
bootstrapFailed = true;
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
|
||||
}
|
||||
} finally {
|
||||
if (redirectedToLogin) return;
|
||||
window.clearTimeout(bootstrapTimeout);
|
||||
if (!controller.signal.aborted) {
|
||||
if (bootstrapFailed) setHydrated(true);
|
||||
else setBootstrapPhase("prepare");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCloudData();
|
||||
return () => {
|
||||
window.clearTimeout(bootstrapTimeout);
|
||||
controller.abort();
|
||||
};
|
||||
void runHomeBootstrap({
|
||||
sessionListReady,
|
||||
sessionListBoot,
|
||||
uiPreview,
|
||||
uiPreviewMode,
|
||||
consultationStatusMissingCount,
|
||||
isDevelopment: process.env.NODE_ENV === "development",
|
||||
readSearch: () => window.location.search,
|
||||
storage: sessionStorage,
|
||||
restoreConsultationRecovery,
|
||||
setComposerNotice,
|
||||
commit: {
|
||||
setAccount,
|
||||
setAccountError,
|
||||
setActiveSessionId,
|
||||
setBirthTimeAssessmentPhase,
|
||||
setBirthTimeJourney,
|
||||
setBootstrapPhase,
|
||||
setGuidedJourneyPreview,
|
||||
setHydrated,
|
||||
setModelCatalog,
|
||||
setOnboardingStep,
|
||||
setProfile,
|
||||
setProfileDraft,
|
||||
setSessions,
|
||||
setSessionsCursor,
|
||||
setStartGreeting,
|
||||
},
|
||||
}, controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [sessionListReady]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1144,98 +837,26 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (consultationPhase !== "recovering" || !pendingSessionId || !pendingRequestId || uiPreview.current) return;
|
||||
const controller = new AbortController();
|
||||
let timer = 0;
|
||||
let polling = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (polling || controller.signal.aborted) return;
|
||||
polling = true;
|
||||
try {
|
||||
if (!navigator.onLine) {
|
||||
setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
|
||||
return;
|
||||
}
|
||||
const status = await fetchConsultationStatus(pendingSessionId, pendingRequestId, controller.signal);
|
||||
if (status.status === "reserved") {
|
||||
consultationStatusMissingCount.current = 0;
|
||||
setComposerNotice("回答仍在后台生成,正在自动恢复。");
|
||||
return;
|
||||
}
|
||||
if (status.status === "completed") {
|
||||
const detailed = await fetchSessionDetail(pendingSessionId, modelCatalog, controller.signal);
|
||||
if (detailed) {
|
||||
setSessions((current) => mergeHydratedSession(current, detailed));
|
||||
setActiveSessionId((current) => current || detailed.id);
|
||||
}
|
||||
pendingConsultation.current = null;
|
||||
setPendingSessionId(null);
|
||||
setPendingRequestId(null);
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setRequestError(null);
|
||||
setComposerNotice("回答已恢复,已显示在对话区末尾。");
|
||||
void refreshAccount();
|
||||
return;
|
||||
}
|
||||
pendingConsultation.current = null;
|
||||
setPendingSessionId(null);
|
||||
setPendingRequestId(null);
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setRequestError(null);
|
||||
setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。");
|
||||
} catch (caught) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (caught instanceof ConsultationStatusError && caught.status === 404) {
|
||||
consultationStatusMissingCount.current += 1;
|
||||
if (consultationStatusMissingCount.current === 1) {
|
||||
setComposerNotice("正在确认本次咨询请求是否已开始…");
|
||||
return;
|
||||
}
|
||||
if (consultationReplayStarted.current !== pendingRequestId) {
|
||||
consultationReplay.current();
|
||||
return;
|
||||
}
|
||||
if (consultationStatusMissingCount.current >= 4) {
|
||||
pendingConsultation.current = null;
|
||||
setPendingSessionId(null);
|
||||
setPendingRequestId(null);
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setRequestError({
|
||||
sessionId: pendingSessionId,
|
||||
message: "后台未找到本次咨询请求,请重新发送。",
|
||||
});
|
||||
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
|
||||
return;
|
||||
}
|
||||
setComposerNotice("正在重新发起本次咨询…");
|
||||
return;
|
||||
}
|
||||
consultationStatusMissingCount.current = 0;
|
||||
setComposerNotice(navigator.onLine
|
||||
? "回答仍在后台生成,正在自动恢复。"
|
||||
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
|
||||
} finally {
|
||||
polling = false;
|
||||
if (!controller.signal.aborted && pendingConsultation.current?.phase === "recovering") {
|
||||
timer = window.setTimeout(() => void poll(), 1_750);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
consultationRecoveryWakeup.current = () => {
|
||||
window.clearTimeout(timer);
|
||||
void poll();
|
||||
};
|
||||
if (consultationStatusMissingCount.current > 0) {
|
||||
timer = window.setTimeout(() => void poll(), 1_750);
|
||||
} else {
|
||||
void poll();
|
||||
}
|
||||
consultationRecoveryWakeup.current = runConsultationRecoveryPoll({
|
||||
pendingSessionId,
|
||||
pendingRequestId,
|
||||
modelCatalog,
|
||||
consultationStatusMissingCount,
|
||||
consultationReplayStarted,
|
||||
consultationReplay,
|
||||
pendingConsultation,
|
||||
setComposerNotice,
|
||||
setSessions,
|
||||
setActiveSessionId,
|
||||
setPendingSessionId,
|
||||
setPendingRequestId,
|
||||
setConsultationPhase,
|
||||
setStreamingReply,
|
||||
setRequestError,
|
||||
refreshAccount,
|
||||
}, controller.signal);
|
||||
return () => {
|
||||
consultationRecoveryWakeup.current = () => undefined;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId]);
|
||||
@@ -1534,18 +1155,16 @@ export default function Home() {
|
||||
// after the bootstrap controller was aborted; never reveal without a catalog.
|
||||
if (!account || !modelCatalog) {
|
||||
return (
|
||||
<main className="app-loading app-loading-error">
|
||||
<div className="app-loading-content" role="alert">
|
||||
<strong>暂时无法进入 Jyotisha</strong>
|
||||
<span>{accountError}</span>
|
||||
<div className="app-loading-actions">
|
||||
<button className="button-primary" type="button" onClick={() => retryHomeBootstrap(
|
||||
setAccountError, setHydrated, setBootstrapPhase, prepareStartedAt, reloadSessionList,
|
||||
)}>重试</button>
|
||||
<Link className="button-secondary" href="/login">返回登录</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<HomeBootstrapError
|
||||
accountError={accountError}
|
||||
onRetry={() => retryHomeBootstrap(
|
||||
setAccountError,
|
||||
setHydrated,
|
||||
setBootstrapPhase,
|
||||
prepareStartedAt,
|
||||
reloadSessionList,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1646,80 +1265,28 @@ export default function Home() {
|
||||
{!activeSession?.messages.length ? (
|
||||
<div className="welcome">
|
||||
{!profileComplete ? (
|
||||
<>
|
||||
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
|
||||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
|
||||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
|
||||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
|
||||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
|
||||
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
|
||||
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
|
||||
</>
|
||||
<HomeOnboardingShell
|
||||
profileComplete={profileComplete}
|
||||
profileDraft={profileDraft}
|
||||
setProfileDraft={setProfileDraft}
|
||||
onboardingStep={onboardingStep}
|
||||
presetMessageLength={presetMessageLength}
|
||||
currentOnboardingMessage={currentOnboardingMessage}
|
||||
onboardingJustCompleted={onboardingJustCompleted}
|
||||
onboardingCardReady={onboardingCardReady}
|
||||
presetMessageFinished={presetMessageFinished}
|
||||
birthTimeJourney={birthTimeJourney}
|
||||
birthTimeAssessmentPhase={birthTimeAssessmentPhase}
|
||||
birthTimeError={birthTimeError}
|
||||
birthTimeGuided={birthTimeGuided}
|
||||
accountError={accountError}
|
||||
profileSaving={profileSaving}
|
||||
saveOnboardingBirth={saveOnboardingBirth}
|
||||
saveOnboardingPlace={saveOnboardingPlace}
|
||||
retryBirthTimeAssessment={() => { void retryBirthTimeAssessment(); }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading">
|
||||
<div className="onboarding-card-heading-copy">
|
||||
<b>出生日期与时间</b>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions">
|
||||
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
|
||||
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<BirthTimeRectification
|
||||
journey={birthTimeJourney}
|
||||
controller={birthTimeGuided}
|
||||
externalError={birthTimeError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
|
||||
<div className="onboarding-card birth-time-retry-card" role="status">
|
||||
<b>出生时间尚未完成评估</b>
|
||||
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
|
||||
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
|
||||
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
|
||||
<StarterGreeting starterGreeting={starterGreeting} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export function HomeBootstrapError({
|
||||
accountError,
|
||||
onRetry,
|
||||
}: {
|
||||
readonly accountError: string;
|
||||
readonly onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="app-loading app-loading-error">
|
||||
<div className="app-loading-content" role="alert">
|
||||
<strong>暂时无法进入 Jyotisha</strong>
|
||||
<span>{accountError}</span>
|
||||
<div className="app-loading-actions">
|
||||
<button className="button-primary" type="button" onClick={onRetry}>重试</button>
|
||||
<Link className="button-secondary" href="/login">返回登录</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { FormEvent } from "react";
|
||||
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import { BirthLocationFields } from "@/components/birth-location-fields";
|
||||
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
|
||||
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
|
||||
import {
|
||||
applyBirthTimeDraftPatch,
|
||||
birthTimeDraftReadyHint,
|
||||
isBirthTimeDraftReady,
|
||||
} from "@/lib/birth-time-intake-model";
|
||||
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
|
||||
import {
|
||||
birthQuestion,
|
||||
formatBirthMoment,
|
||||
placeQuestion,
|
||||
selectedBirthPlace,
|
||||
} from "@/lib/home-profile";
|
||||
import { presetOnboardingMessage, type OnboardingStep, type Profile } from "@/lib/home-types";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <p className="birth-time-assistant-intent" role="status">正在加载出生时间评估...</p>,
|
||||
},
|
||||
);
|
||||
|
||||
export type HomeOnboardingShellProps = {
|
||||
readonly profileComplete: boolean;
|
||||
readonly profileDraft: Profile;
|
||||
readonly setProfileDraft: (update: Profile | ((current: Profile) => Profile)) => void;
|
||||
readonly onboardingStep: OnboardingStep;
|
||||
readonly presetMessageLength: number;
|
||||
readonly currentOnboardingMessage: string;
|
||||
readonly onboardingJustCompleted: boolean;
|
||||
readonly onboardingCardReady: boolean;
|
||||
readonly presetMessageFinished: boolean;
|
||||
readonly birthTimeJourney: JourneyClientResponse | null;
|
||||
readonly birthTimeAssessmentPhase: BirthTimeAssessmentPhase | null;
|
||||
readonly birthTimeError: string;
|
||||
readonly birthTimeGuided: BirthTimeGuidedController;
|
||||
readonly accountError: string;
|
||||
readonly profileSaving: boolean;
|
||||
readonly saveOnboardingBirth: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly saveOnboardingPlace: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly retryBirthTimeAssessment: () => void;
|
||||
};
|
||||
|
||||
export function HomeOnboardingShell({
|
||||
profileComplete,
|
||||
profileDraft,
|
||||
setProfileDraft,
|
||||
onboardingStep,
|
||||
presetMessageLength,
|
||||
currentOnboardingMessage,
|
||||
onboardingJustCompleted,
|
||||
onboardingCardReady,
|
||||
presetMessageFinished,
|
||||
birthTimeJourney,
|
||||
birthTimeAssessmentPhase,
|
||||
birthTimeError,
|
||||
birthTimeGuided,
|
||||
accountError,
|
||||
profileSaving,
|
||||
saveOnboardingBirth,
|
||||
saveOnboardingPlace,
|
||||
retryBirthTimeAssessment,
|
||||
}: HomeOnboardingShellProps) {
|
||||
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
|
||||
return (
|
||||
<>
|
||||
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
|
||||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={profileDraft.name.trim()} />}
|
||||
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={birthQuestion(profileDraft.name.trim())} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
|
||||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
|
||||
{(onboardingStep === "place" || onboardingStep === "rectification" || profileComplete) && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
|
||||
{!profileComplete && onboardingStep === "rectification" && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{!profileComplete && onboardingStep === "rectification" && birthTimeJourney && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} phraseSafe />}
|
||||
{profileComplete && onboardingJustCompleted && selectedBirthPlace(profileDraft) && <OnboardingChatMessage role="user" text={selectedBirthPlace(profileDraft)?.label ?? ""} />}
|
||||
{profileComplete && onboardingJustCompleted && <OnboardingChatMessage role="assistant" text={currentOnboardingMessage} streaming length={presetMessageLength} />}
|
||||
|
||||
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading">
|
||||
<div className="onboarding-card-heading-copy">
|
||||
<b>出生日期与时间</b>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions">
|
||||
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
|
||||
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
|
||||
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
|
||||
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
|
||||
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
|
||||
</fieldset>
|
||||
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && (
|
||||
<div className="onboarding-card-reveal">
|
||||
<div className="onboarding-card-reveal-inner">
|
||||
<BirthTimeRectification
|
||||
journey={birthTimeJourney}
|
||||
controller={birthTimeGuided}
|
||||
externalError={birthTimeError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && (
|
||||
<div className="onboarding-card birth-time-retry-card" role="status">
|
||||
<b>出生时间尚未完成评估</b>
|
||||
<p>{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}</p>
|
||||
<button className="button-primary" type="button" disabled={profileSaving} onClick={() => void retryBirthTimeAssessment()}>{profileSaving ? "评估中" : "重新评估"}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* One recovering consultation's status loop. No React hooks: Home keeps the
|
||||
* effect, the abort signal, and the wakeup ref.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConsultationStatusError,
|
||||
fetchConsultationStatus,
|
||||
fetchSessionDetail,
|
||||
mergeHydratedSession,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import type { ChatSession, RequestError, StreamingReply } from "@/lib/home-types";
|
||||
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
|
||||
export const CONSULTATION_RECOVERY_POLL_MS = 1_750;
|
||||
|
||||
export type ConsultationRecoveryPollDeps = {
|
||||
pendingSessionId: string;
|
||||
pendingRequestId: string;
|
||||
modelCatalog: PublicLanguageModelCatalog | null;
|
||||
consultationStatusMissingCount: { current: number };
|
||||
consultationReplayStarted: { current: string | null };
|
||||
consultationReplay: { current: () => void };
|
||||
pendingConsultation: { current: { phase: string } | null };
|
||||
setComposerNotice: (notice: string) => void;
|
||||
setSessions: (update: ChatSession[] | ((current: ChatSession[]) => ChatSession[])) => void;
|
||||
setActiveSessionId: (update: string | ((current: string) => string)) => void;
|
||||
setPendingSessionId: (id: string | null) => void;
|
||||
setPendingRequestId: (id: string | null) => void;
|
||||
setConsultationPhase: (phase: "undo" | "streaming" | "recovering" | null) => void;
|
||||
setStreamingReply: (value: StreamingReply | null) => void;
|
||||
setRequestError: (value: RequestError | null) => void;
|
||||
refreshAccount: () => void | Promise<void>;
|
||||
online?: () => boolean;
|
||||
fetchConsultationStatus?: typeof fetchConsultationStatus;
|
||||
fetchSessionDetail?: typeof fetchSessionDetail;
|
||||
};
|
||||
|
||||
export function runConsultationRecoveryPoll(
|
||||
deps: ConsultationRecoveryPollDeps,
|
||||
signal: AbortSignal,
|
||||
): () => void {
|
||||
const online = deps.online ?? (() => navigator.onLine);
|
||||
const fetchStatus = deps.fetchConsultationStatus ?? fetchConsultationStatus;
|
||||
const fetchDetail = deps.fetchSessionDetail ?? fetchSessionDetail;
|
||||
let timer = 0;
|
||||
let polling = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (polling || signal.aborted) return;
|
||||
polling = true;
|
||||
try {
|
||||
if (!online()) {
|
||||
deps.setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
|
||||
return;
|
||||
}
|
||||
const status = await fetchStatus(deps.pendingSessionId, deps.pendingRequestId, signal);
|
||||
if (status.status === "reserved") {
|
||||
deps.consultationStatusMissingCount.current = 0;
|
||||
deps.setComposerNotice("回答仍在后台生成,正在自动恢复。");
|
||||
return;
|
||||
}
|
||||
if (status.status === "completed") {
|
||||
const detailed = await fetchDetail(deps.pendingSessionId, deps.modelCatalog, signal);
|
||||
if (detailed) {
|
||||
deps.setSessions((current) => mergeHydratedSession(current, detailed));
|
||||
deps.setActiveSessionId((current) => current || detailed.id);
|
||||
}
|
||||
deps.pendingConsultation.current = null;
|
||||
deps.setPendingSessionId(null);
|
||||
deps.setPendingRequestId(null);
|
||||
deps.setConsultationPhase(null);
|
||||
deps.setStreamingReply(null);
|
||||
deps.setRequestError(null);
|
||||
deps.setComposerNotice("回答已恢复,已显示在对话区末尾。");
|
||||
void deps.refreshAccount();
|
||||
return;
|
||||
}
|
||||
deps.pendingConsultation.current = null;
|
||||
deps.setPendingSessionId(null);
|
||||
deps.setPendingRequestId(null);
|
||||
deps.setConsultationPhase(null);
|
||||
deps.setStreamingReply(null);
|
||||
deps.setRequestError(null);
|
||||
deps.setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。");
|
||||
} catch (caught) {
|
||||
if (signal.aborted) return;
|
||||
if (caught instanceof ConsultationStatusError && caught.status === 404) {
|
||||
deps.consultationStatusMissingCount.current += 1;
|
||||
if (deps.consultationStatusMissingCount.current === 1) {
|
||||
deps.setComposerNotice("正在确认本次咨询请求是否已开始…");
|
||||
return;
|
||||
}
|
||||
if (deps.consultationReplayStarted.current !== deps.pendingRequestId) {
|
||||
deps.consultationReplay.current();
|
||||
return;
|
||||
}
|
||||
if (deps.consultationStatusMissingCount.current >= 4) {
|
||||
deps.pendingConsultation.current = null;
|
||||
deps.setPendingSessionId(null);
|
||||
deps.setPendingRequestId(null);
|
||||
deps.setConsultationPhase(null);
|
||||
deps.setStreamingReply(null);
|
||||
deps.setRequestError({
|
||||
sessionId: deps.pendingSessionId,
|
||||
message: "后台未找到本次咨询请求,请重新发送。",
|
||||
});
|
||||
deps.setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
|
||||
return;
|
||||
}
|
||||
deps.setComposerNotice("正在重新发起本次咨询…");
|
||||
return;
|
||||
}
|
||||
deps.consultationStatusMissingCount.current = 0;
|
||||
deps.setComposerNotice(online()
|
||||
? "回答仍在后台生成,正在自动恢复。"
|
||||
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
|
||||
} finally {
|
||||
polling = false;
|
||||
if (!signal.aborted && deps.pendingConsultation.current?.phase === "recovering") {
|
||||
timer = window.setTimeout(() => void poll(), 1_750);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const wakeup = () => {
|
||||
window.clearTimeout(timer);
|
||||
void poll();
|
||||
};
|
||||
const clearOnAbort = () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
if (signal.aborted) clearOnAbort();
|
||||
else signal.addEventListener("abort", clearOnAbort);
|
||||
if (deps.consultationStatusMissingCount.current > 0) {
|
||||
timer = window.setTimeout(() => void poll(), 1_750);
|
||||
} else {
|
||||
void poll();
|
||||
}
|
||||
return wakeup;
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Home cloud bootstrap. No React hooks: Home owns one effect and passes setters.
|
||||
*
|
||||
* The 20s timeout aborts this run and reveals the fatal screen. A login redirect
|
||||
* leaves that timer running so a failed navigation can still escape the spinner.
|
||||
*/
|
||||
|
||||
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import {
|
||||
guidedBirthTimePreview,
|
||||
isGuidedBirthTimePreview,
|
||||
previewRectificationJourney,
|
||||
} from "@/lib/birth-time-guided-preview";
|
||||
import { hydrateDeclaredWindowDraft } from "@/lib/birth-time-intake-model";
|
||||
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
|
||||
import {
|
||||
SESSION_MISSING_NOTICE,
|
||||
clearLoginSessionReturn,
|
||||
writeSessionUrl,
|
||||
type BootstrapSessionSelection,
|
||||
} from "@/lib/chat-session-url";
|
||||
import { createStartGreeting } from "@/lib/starter-greeting";
|
||||
import {
|
||||
HOME_BOOTSTRAP_SLOW_MS,
|
||||
resolveStarterHomeLandingSessionId,
|
||||
starterHomeLandingNeedsConsultation,
|
||||
type BootstrapPhase,
|
||||
} from "@/lib/home-bootstrap";
|
||||
import {
|
||||
ConsultationStatusError,
|
||||
LoginRedirectError,
|
||||
applyLegacySessionControls,
|
||||
createSession,
|
||||
fetchActiveConsultationStatus,
|
||||
fetchConsultationStatus,
|
||||
fetchModelCatalog,
|
||||
fetchSessionDetail,
|
||||
friendlyError,
|
||||
patchSessionModel,
|
||||
readSessions,
|
||||
readStoredPendingConsultation,
|
||||
redirectToLogin,
|
||||
resolveLookupBootstrap,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile";
|
||||
import {
|
||||
emptyProfile,
|
||||
pendingConsultationStorageKey,
|
||||
previewModelCatalog,
|
||||
timestamp,
|
||||
type Account,
|
||||
type ChatSession,
|
||||
type ConsultationStatus,
|
||||
type OnboardingStep,
|
||||
type Profile,
|
||||
type SessionReadResult,
|
||||
type StoredPendingConsultation,
|
||||
} from "@/lib/home-types";
|
||||
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard";
|
||||
import type { SessionListBoot } from "@/lib/session-list-context";
|
||||
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
|
||||
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
|
||||
|
||||
export const HOME_BOOTSTRAP_TIMEOUT_MESSAGE = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。";
|
||||
|
||||
export type HomeBootstrapStorage = Pick<Storage, "getItem" | "removeItem">;
|
||||
|
||||
export type HomeBootstrapCommit = {
|
||||
setAccount: (account: Account) => void;
|
||||
setAccountError: (value: string) => void;
|
||||
setActiveSessionId: (id: string) => void;
|
||||
setBirthTimeAssessmentPhase: (phase: BirthTimeAssessmentPhase | null) => void;
|
||||
setBirthTimeJourney: (journey: JourneyClientResponse | null) => void;
|
||||
setBootstrapPhase: (phase: BootstrapPhase) => void;
|
||||
setGuidedJourneyPreview: (value: boolean) => void;
|
||||
setHydrated: (value: boolean) => void;
|
||||
setModelCatalog: (catalog: PublicLanguageModelCatalog | null) => void;
|
||||
setOnboardingStep: (step: OnboardingStep) => void;
|
||||
setProfile: (profile: Profile) => void;
|
||||
setProfileDraft: (profile: Profile) => void;
|
||||
setSessions: (sessions: ChatSession[]) => void;
|
||||
setSessionsCursor: (cursor: string | null) => void;
|
||||
setStartGreeting: (value: string) => void;
|
||||
};
|
||||
|
||||
export type HomeBootstrapIo = {
|
||||
fetchModelCatalog: typeof fetchModelCatalog;
|
||||
fetchActiveConsultationStatus: typeof fetchActiveConsultationStatus;
|
||||
fetchConsultationStatus: typeof fetchConsultationStatus;
|
||||
fetchSessionDetail: typeof fetchSessionDetail;
|
||||
resolveLookupBootstrap: typeof resolveLookupBootstrap;
|
||||
patchSessionModel: typeof patchSessionModel;
|
||||
redirectToLogin: typeof redirectToLogin;
|
||||
clearStaleClientReload: typeof clearStaleClientReload;
|
||||
writeSessionUrl: typeof writeSessionUrl;
|
||||
clearLoginSessionReturn: typeof clearLoginSessionReturn;
|
||||
restoreConsultationRecovery: (
|
||||
session: ChatSession,
|
||||
requestId: string,
|
||||
stored?: StoredPendingConsultation | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type HomeBootstrapDeps = {
|
||||
sessionListReady: Promise<void>;
|
||||
sessionListBoot: () => SessionListBoot | null;
|
||||
uiPreview: { current: boolean };
|
||||
uiPreviewMode: { current: string | null };
|
||||
consultationStatusMissingCount: { current: number };
|
||||
isDevelopment: boolean;
|
||||
readSearch: () => string;
|
||||
storage: HomeBootstrapStorage;
|
||||
restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"];
|
||||
setComposerNotice: (notice: string) => void;
|
||||
commit: HomeBootstrapCommit;
|
||||
io?: Partial<HomeBootstrapIo>;
|
||||
};
|
||||
|
||||
type AccountPhaseReady = {
|
||||
kind: "ready";
|
||||
nextAccount: Account;
|
||||
nextModelCatalog: PublicLanguageModelCatalog | null;
|
||||
nextProfile: Profile;
|
||||
nextSessions: ChatSession[];
|
||||
parsedSessions: SessionReadResult;
|
||||
reservedConsultation: ConsultationStatus | null;
|
||||
storedPending: StoredPendingConsultation | null;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
type AccountPhaseResult = AccountPhaseReady | { kind: "stop" };
|
||||
|
||||
const defaultIo: Omit<HomeBootstrapIo, "restoreConsultationRecovery"> = {
|
||||
fetchModelCatalog,
|
||||
fetchActiveConsultationStatus,
|
||||
fetchConsultationStatus,
|
||||
fetchSessionDetail,
|
||||
resolveLookupBootstrap,
|
||||
patchSessionModel,
|
||||
redirectToLogin,
|
||||
clearStaleClientReload,
|
||||
writeSessionUrl,
|
||||
clearLoginSessionReturn,
|
||||
};
|
||||
|
||||
function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo {
|
||||
return {
|
||||
...defaultIo,
|
||||
...deps.io,
|
||||
restoreConsultationRecovery: deps.io?.restoreConsultationRecovery ?? deps.restoreConsultationRecovery,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAccountPhase(
|
||||
deps: HomeBootstrapDeps,
|
||||
signal: AbortSignal,
|
||||
): Promise<AccountPhaseResult> {
|
||||
const io = resolveIo(deps);
|
||||
const previewMode = deps.isDevelopment
|
||||
? new URLSearchParams(deps.readSearch()).get("preview")
|
||||
: null;
|
||||
if (previewMode) {
|
||||
deps.uiPreview.current = true;
|
||||
deps.uiPreviewMode.current = previewMode;
|
||||
deps.commit.setGuidedJourneyPreview(true);
|
||||
if (previewMode === "error") {
|
||||
deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
|
||||
deps.commit.setHydrated(true);
|
||||
return { kind: "stop" };
|
||||
}
|
||||
const isAssessmentLoadingPreview = previewMode === "birth-time-assessment-loading";
|
||||
const isCompletedCandidatePreview = previewMode === "birth-time-candidate-complete";
|
||||
const isRectificationPreview = isGuidedBirthTimePreview(previewMode);
|
||||
const previewJourney = isRectificationPreview
|
||||
? guidedBirthTimePreview(previewMode)
|
||||
: previewRectificationJourney;
|
||||
const previewProfile: Profile = previewMode === "onboarding"
|
||||
? emptyProfile
|
||||
: {
|
||||
...emptyProfile,
|
||||
name: "林遥",
|
||||
date: "1990-06-15",
|
||||
time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30",
|
||||
reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30",
|
||||
birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import",
|
||||
...hydrateDeclaredWindowDraft({
|
||||
period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
|
||||
}),
|
||||
birthTimeClue: "",
|
||||
uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null,
|
||||
uncertaintyAfterMinutes: isRectificationPreview ? 30 : null,
|
||||
birthTimeStatus: isCompletedCandidatePreview
|
||||
? "candidate"
|
||||
: isAssessmentLoadingPreview
|
||||
? "rectifying"
|
||||
: previewJourney.snapshot.state === "candidate"
|
||||
|| previewJourney.snapshot.state === "confirming"
|
||||
|| previewJourney.snapshot.state === "ready"
|
||||
? "candidate"
|
||||
: isRectificationPreview ? "rectifying" : "confirmed",
|
||||
rectificationCaseId: isRectificationPreview ? previewJourney.caseId : "",
|
||||
countryCode: "CN",
|
||||
provinceCode: "110000",
|
||||
cityCode: "110000-city",
|
||||
districtCode: "110101",
|
||||
};
|
||||
const previewMessages = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial"
|
||||
? [
|
||||
{ role: "user" as const, text: "未来半年是否适合换工作?" },
|
||||
{ role: "assistant" as const, text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" },
|
||||
]
|
||||
: [];
|
||||
const previewSession: ChatSession = {
|
||||
id: "preview-session",
|
||||
title: previewMessages.length > 0 ? "未来半年是否适合换工作" : "新对话",
|
||||
theme: "career",
|
||||
modelId: previewModelCatalog.defaultModelId,
|
||||
messages: previewMessages,
|
||||
updatedAt: timestamp(),
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: "self",
|
||||
chartProfileName: previewProfile.name.trim() || "我",
|
||||
chartProfileRole: "self",
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
};
|
||||
deps.commit.setAccount({
|
||||
user: { id: "preview-user", email: "preview@local.test" },
|
||||
avatar: null,
|
||||
credits: 8,
|
||||
isAdmin: false,
|
||||
adminUrl: null,
|
||||
rectificationPriceCredits: 1,
|
||||
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
|
||||
hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed",
|
||||
activeSubscription: null,
|
||||
profile: previewProfile,
|
||||
});
|
||||
deps.commit.setModelCatalog(previewModelCatalog);
|
||||
deps.commit.setProfile(previewProfile);
|
||||
deps.commit.setProfileDraft(previewProfile);
|
||||
if (isRectificationPreview) {
|
||||
deps.commit.setBirthTimeJourney(previewJourney);
|
||||
}
|
||||
if (isAssessmentLoadingPreview) {
|
||||
deps.commit.setBirthTimeAssessmentPhase("assessing");
|
||||
}
|
||||
deps.commit.setOnboardingStep(isAssessmentLoadingPreview ? "birth" : missingProfileStep(previewProfile) ?? "name");
|
||||
deps.commit.setSessions([previewSession]);
|
||||
deps.commit.setActiveSessionId(previewSession.id);
|
||||
const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : "";
|
||||
deps.commit.setStartGreeting(previewGreeting);
|
||||
deps.commit.setHydrated(true);
|
||||
return { kind: "stop" };
|
||||
}
|
||||
|
||||
// Start independent reads before waiting for the provider so slow-network
|
||||
// latency is spent in parallel, not as a serial account -> model chain.
|
||||
const modelCatalogPromise = io.fetchModelCatalog(signal)
|
||||
.then((catalog) => ({ catalog, error: null as unknown }))
|
||||
.catch((error: unknown) => ({ catalog: null, error }));
|
||||
const activeStatusPromise = io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
return null;
|
||||
});
|
||||
await deps.sessionListReady;
|
||||
if (signal.aborted) return { kind: "stop" };
|
||||
const listBoot = deps.sessionListBoot();
|
||||
if (listBoot?.signedOut || !listBoot?.account) {
|
||||
io.redirectToLogin();
|
||||
return { kind: "stop" };
|
||||
}
|
||||
const nextAccount = listBoot.account;
|
||||
const modelCatalogResult = await modelCatalogPromise;
|
||||
if (modelCatalogResult.error) throw modelCatalogResult.error;
|
||||
const nextModelCatalog = modelCatalogResult.catalog;
|
||||
const nextProfile = readProfile(nextAccount.profile);
|
||||
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
|
||||
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
|
||||
if (nextSessions.length === 0) {
|
||||
nextSessions = [createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
)];
|
||||
}
|
||||
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
|
||||
|
||||
let reservedConsultation: ConsultationStatus | null = await activeStatusPromise;
|
||||
const storedPending = dropRectificationStoredPending(
|
||||
readStoredPendingConsultation(deps.storage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
|
||||
nextSessions,
|
||||
);
|
||||
if (!storedPending) deps.storage.removeItem(pendingConsultationStorageKey);
|
||||
|
||||
if (storedPending) {
|
||||
try {
|
||||
const status = await io.fetchConsultationStatus(
|
||||
storedPending.sessionId,
|
||||
storedPending.requestId,
|
||||
signal,
|
||||
);
|
||||
if (status.status === "reserved") {
|
||||
deps.consultationStatusMissingCount.current = 0;
|
||||
reservedConsultation = status;
|
||||
} else {
|
||||
deps.storage.removeItem(pendingConsultationStorageKey);
|
||||
reservedConsultation = null;
|
||||
}
|
||||
} catch (caught) {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
deps.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
|
||||
reservedConsultation = {
|
||||
sessionId: storedPending.sessionId,
|
||||
requestId: storedPending.requestId,
|
||||
status: "reserved",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (signal.aborted) return { kind: "stop" };
|
||||
return {
|
||||
kind: "ready",
|
||||
nextAccount,
|
||||
nextModelCatalog,
|
||||
nextProfile,
|
||||
nextSessions,
|
||||
parsedSessions,
|
||||
reservedConsultation,
|
||||
storedPending,
|
||||
cursor: listBoot.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveLanding(
|
||||
deps: HomeBootstrapDeps,
|
||||
signal: AbortSignal,
|
||||
ready: AccountPhaseReady,
|
||||
): Promise<void> {
|
||||
const io = resolveIo(deps);
|
||||
const { writeSessionUrl, clearLoginSessionReturn, restoreConsultationRecovery } = io;
|
||||
const lookedUp = await io.resolveLookupBootstrap({
|
||||
sessions: ready.nextSessions,
|
||||
catalog: ready.nextModelCatalog,
|
||||
defaultSessionId: ready.nextSessions[0].id,
|
||||
signal,
|
||||
});
|
||||
let nextSessions = lookedUp.sessions;
|
||||
const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection;
|
||||
let landingSessionId = resolveStarterHomeLandingSessionId(
|
||||
nextSessions,
|
||||
bootstrapSelection.sessionId,
|
||||
bootstrapSelection.urlAction,
|
||||
);
|
||||
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
|
||||
const homeSession = createSession(
|
||||
ready.nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], ready.nextProfile),
|
||||
);
|
||||
nextSessions = [homeSession, ...nextSessions];
|
||||
landingSessionId = homeSession.id;
|
||||
}
|
||||
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
|
||||
?? nextSessions[0];
|
||||
if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") {
|
||||
try {
|
||||
const detailed = await io.fetchSessionDetail(activeListed.id, ready.nextModelCatalog, signal);
|
||||
if (detailed) {
|
||||
nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session);
|
||||
}
|
||||
} catch (caught) {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
}
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
io.clearStaleClientReload(deps.storage);
|
||||
deps.commit.setSessionsCursor(ready.cursor);
|
||||
deps.commit.setAccount(ready.nextAccount);
|
||||
deps.commit.setModelCatalog(ready.nextModelCatalog);
|
||||
deps.commit.setProfile(ready.nextProfile);
|
||||
deps.commit.setProfileDraft(ready.nextProfile);
|
||||
deps.commit.setStartGreeting(ready.nextProfile.name.trim() ? createStartGreeting(ready.nextProfile.name) : "");
|
||||
deps.commit.setOnboardingStep(missingProfileStep(ready.nextProfile) ?? "name");
|
||||
deps.commit.setSessions(nextSessions);
|
||||
if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");
|
||||
deps.commit.setActiveSessionId(landingSessionId);
|
||||
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
|
||||
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
|
||||
if (bootstrapSelection.urlAction === "replace-selected") {
|
||||
const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId);
|
||||
if (!selected || !isUnsavedEmptyConsultation(selected)) {
|
||||
writeSessionUrl(bootstrapSelection.sessionId, "replace");
|
||||
}
|
||||
}
|
||||
const reservedConsultation = ready.reservedConsultation;
|
||||
const storedPending = ready.storedPending;
|
||||
if (reservedConsultation?.status === "reserved") {
|
||||
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
|
||||
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
|
||||
if (bootstrapSelection.urlAction === "new-chat") {
|
||||
deps.commit.setActiveSessionId(landingSessionId);
|
||||
deps.setComposerNotice("");
|
||||
}
|
||||
}
|
||||
if (ready.parsedSessions.fallbackSessionIds.length > 0) {
|
||||
deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
|
||||
} else if (lookedUp.notice) {
|
||||
deps.setComposerNotice(lookedUp.notice);
|
||||
} else if (bootstrapSelection.missing) {
|
||||
deps.setComposerNotice(SESSION_MISSING_NOTICE);
|
||||
}
|
||||
deps.commit.setAccountError("");
|
||||
|
||||
const fallbackCatalog = ready.nextModelCatalog;
|
||||
if (fallbackCatalog && ready.parsedSessions.fallbackSessionIds.length > 0) {
|
||||
try {
|
||||
await Promise.all(ready.parsedSessions.fallbackSessionIds.map((sessionId) =>
|
||||
io.patchSessionModel(sessionId, fallbackCatalog.defaultModelId, signal),
|
||||
));
|
||||
} catch {
|
||||
if (!signal.aborted) {
|
||||
deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHomeBootstrap(deps: HomeBootstrapDeps, signal: AbortSignal): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const onParentAbort = () => controller.abort();
|
||||
if (signal.aborted) onParentAbort();
|
||||
else signal.addEventListener("abort", onParentAbort);
|
||||
|
||||
const bootstrapTimeout = window.setTimeout(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
controller.abort();
|
||||
deps.commit.setAccountError(HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
|
||||
deps.commit.setHydrated(true);
|
||||
}, HOME_BOOTSTRAP_SLOW_MS);
|
||||
const clearTimeoutOnParentAbort = () => {
|
||||
window.clearTimeout(bootstrapTimeout);
|
||||
};
|
||||
if (signal.aborted) clearTimeoutOnParentAbort();
|
||||
else signal.addEventListener("abort", clearTimeoutOnParentAbort);
|
||||
|
||||
let redirectedToLogin = false;
|
||||
let bootstrapFailed = false;
|
||||
try {
|
||||
const ready = await runAccountPhase(deps, controller.signal);
|
||||
if (ready.kind === "ready") await resolveLanding(deps, controller.signal, ready);
|
||||
} catch (caught) {
|
||||
if (caught instanceof LoginRedirectError) {
|
||||
redirectedToLogin = true;
|
||||
return;
|
||||
}
|
||||
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
|
||||
bootstrapFailed = true;
|
||||
deps.commit.setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
|
||||
}
|
||||
} finally {
|
||||
if (redirectedToLogin) return;
|
||||
signal.removeEventListener("abort", onParentAbort);
|
||||
signal.removeEventListener("abort", clearTimeoutOnParentAbort);
|
||||
window.clearTimeout(bootstrapTimeout);
|
||||
if (!controller.signal.aborted) {
|
||||
if (bootstrapFailed) deps.commit.setHydrated(true);
|
||||
else deps.commit.setBootstrapPhase("prepare");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,13 +197,23 @@ test("toast-owned and alert-owned events are deliberately silent in the status r
|
||||
});
|
||||
|
||||
test("the recovered background reply tells the reader where it landed", () => {
|
||||
assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2);
|
||||
// 原值: homeSurface 里两处「回答已恢复,已显示在对话区末尾。」
|
||||
// 新值: use-consultation-run 与 consultation-recovery-poll 各一处。
|
||||
// 原因: 恢复轮询搬出 page.tsx,两处文案都还在,没有改成更短的「回答已恢复。」
|
||||
const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
|
||||
assert.equal(poll.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 1);
|
||||
assert.equal(hook.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 1);
|
||||
assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/);
|
||||
assert.doesNotMatch(poll, /setComposerNotice\("回答已恢复。"\)/);
|
||||
});
|
||||
|
||||
test("assertive announcement is reserved for genuine errors", () => {
|
||||
// Given: the bootstrap failure screen is the only assertive surface on the page.
|
||||
assert.match(pageSource, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
|
||||
// 原值: page.tsx / homeSurface 内联 `<main className="app-loading app-loading-error">`。
|
||||
// 新值: 同一段在 home-bootstrap-error.tsx。
|
||||
// 原因: 错误屏抽成组件后仍是唯一的 alert,页面没有 assertive 直播区。
|
||||
const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8");
|
||||
assert.match(bootstrapError, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
|
||||
assert.doesNotMatch(pageSource, /aria-live="assertive"/);
|
||||
|
||||
// And: the still-loading screen stays polite and marks itself busy.
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../src/lib/chat-session-url.ts";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
const login = readFileSync(new URL("../src/components/email-otp-login.tsx", import.meta.url), "utf8");
|
||||
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
|
||||
const sessionA = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -71,7 +72,10 @@ test("persisting a new chat replaces the intent with the saved session id", () =
|
||||
});
|
||||
|
||||
test("new chat bootstrap consumes the URL before activation and reuses local-only creation", () => {
|
||||
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
|
||||
// 原值: 从 homeSurface 里切 `async function loadCloudData()`。
|
||||
// 新值: 切 home-bootstrap-run.ts 的 resolveLanding。
|
||||
// 原因: 落点代码原样搬家,清 URL 仍在激活会话之前,且只本地创建一条咨询。
|
||||
const bootstrap = sourceBetween(bootstrapRun, "export async function resolveLanding(", "export async function runHomeBootstrap(");
|
||||
const clearIntent = 'if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");';
|
||||
assert.ok(bootstrap.includes(clearIntent));
|
||||
assert.ok(bootstrap.indexOf(clearIntent) < bootstrap.indexOf("setActiveSessionId(landingSessionId)"));
|
||||
@@ -173,8 +177,11 @@ test("default bootstrap selection does not write a session URL", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
|
||||
assert.match(bootstrap, /defaultSessionId: nextSessions\[0\]\.id/);
|
||||
// 原值: 从 homeSurface 里切 `async function loadCloudData()`。
|
||||
// 新值: 切 resolveLanding。
|
||||
// 原因: 默认落点不再写 push URL,缺失会话仍提示 SESSION_MISSING_NOTICE。
|
||||
const bootstrap = sourceBetween(bootstrapRun, "export async function resolveLanding(", "export async function runHomeBootstrap(");
|
||||
assert.match(bootstrap, /defaultSessionId: ready\.nextSessions\[0\]\.id/);
|
||||
assert.match(bootstrap, /setActiveSessionId\(landingSessionId\)/);
|
||||
assert.match(bootstrap, /urlAction === "replace-clear"/);
|
||||
assert.match(bootstrap, /urlAction === "replace-selected"/);
|
||||
@@ -281,16 +288,22 @@ test("home stays a client-read query on a static route", () => {
|
||||
// 新值:`await sessionListReady` 后读 `sessionListBoot()`,page.tsx 不再 `fetchSessions(`
|
||||
// 原因:T2 列表由 layout provider 拉一次;本条仍守「`/` 是静态路由上的客户端读」
|
||||
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
// 原值: 从 page.tsx 切 `if (previewMode)` 到 `await sessionListReady`。
|
||||
// 新值: 预览分支在 home-bootstrap-run.ts;首页把 sessionListReady / sessionListBoot 交给它。
|
||||
// 原因: 预览仍不写会话 URL,`/` 仍是静态路由上的客户端读,不在首页里 fetchSessions。
|
||||
const preview = sourceBetween(
|
||||
pageFile,
|
||||
bootstrapRun,
|
||||
"if (previewMode) {",
|
||||
"await sessionListReady;",
|
||||
"await deps.sessionListReady;",
|
||||
);
|
||||
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
|
||||
assert.match(pageFile, /await sessionListReady;/);
|
||||
assert.match(pageFile, /sessionListBoot\(\)/);
|
||||
assert.match(bootstrapRun, /await deps\.sessionListReady;/);
|
||||
assert.match(bootstrapRun, /deps\.sessionListBoot\(\)/);
|
||||
assert.match(pageFile, /sessionListReady,/);
|
||||
assert.match(pageFile, /sessionListBoot,/);
|
||||
assert.doesNotMatch(pageFile, /fetchSessions\(/);
|
||||
assert.match(page, /SESSION_MISSING_NOTICE/);
|
||||
assert.doesNotMatch(bootstrapRun, /fetchSessions\(/);
|
||||
assert.match(bootstrapRun, /SESSION_MISSING_NOTICE/);
|
||||
assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { runConsultationRecoveryPoll, type ConsultationRecoveryPollDeps } from "../src/lib/consultation-recovery-poll.ts";
|
||||
import { ConsultationStatusError } from "../src/lib/home-cloud-sync.ts";
|
||||
import { previewModelCatalog } from "../src/lib/home-types.ts";
|
||||
import { withFakeTimers } from "./home-bootstrap-fake-timers.ts";
|
||||
|
||||
const sessionId = "11111111-1111-4111-8111-111111111111";
|
||||
const requestId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
async function flush() {
|
||||
for (let step = 0; step < 8; step += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function createPoll(overrides: {
|
||||
fetchConsultationStatus: ConsultationRecoveryPollDeps["fetchConsultationStatus"];
|
||||
missing?: number;
|
||||
}) {
|
||||
const state = {
|
||||
notice: "",
|
||||
phase: "recovering" as "recovering" | null,
|
||||
pendingSession: sessionId as string | null,
|
||||
pendingRequest: requestId as string | null,
|
||||
replay: 0,
|
||||
error: "",
|
||||
refreshed: 0,
|
||||
};
|
||||
const deps: ConsultationRecoveryPollDeps = {
|
||||
pendingSessionId: sessionId,
|
||||
pendingRequestId: requestId,
|
||||
modelCatalog: previewModelCatalog,
|
||||
consultationStatusMissingCount: { current: overrides.missing ?? 0 },
|
||||
consultationReplayStarted: { current: null },
|
||||
consultationReplay: {
|
||||
current: () => {
|
||||
state.replay += 1;
|
||||
deps.consultationReplayStarted.current = requestId;
|
||||
},
|
||||
},
|
||||
pendingConsultation: { current: { phase: "recovering" } },
|
||||
setComposerNotice: (notice) => { state.notice = notice; },
|
||||
setSessions: () => undefined,
|
||||
setActiveSessionId: () => undefined,
|
||||
setPendingSessionId: (id) => { state.pendingSession = id; },
|
||||
setPendingRequestId: (id) => { state.pendingRequest = id; },
|
||||
setConsultationPhase: (phase) => { state.phase = phase === "recovering" ? "recovering" : null; },
|
||||
setStreamingReply: () => undefined,
|
||||
setRequestError: (value) => { state.error = value?.message ?? ""; },
|
||||
refreshAccount: () => { state.refreshed += 1; },
|
||||
online: () => true,
|
||||
fetchConsultationStatus: overrides.fetchConsultationStatus,
|
||||
fetchSessionDetail: async () => null,
|
||||
};
|
||||
return { deps, state };
|
||||
}
|
||||
|
||||
test("the first 404 confirms the request and the next 404 replays it once", async () => {
|
||||
await withFakeTimers(async (tick) => {
|
||||
const { deps, state } = createPoll({
|
||||
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
|
||||
});
|
||||
runConsultationRecoveryPoll(deps, new AbortController().signal);
|
||||
await flush();
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 1);
|
||||
assert.equal(state.notice, "正在确认本次咨询请求是否已开始…");
|
||||
assert.equal(state.replay, 0);
|
||||
assert.equal(state.phase, "recovering");
|
||||
|
||||
tick(1_750);
|
||||
await flush();
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 2);
|
||||
assert.equal(state.replay, 1);
|
||||
assert.equal(state.phase, "recovering");
|
||||
});
|
||||
});
|
||||
|
||||
test("the fourth missing status gives up and stops polling", async () => {
|
||||
await withFakeTimers(async (tick) => {
|
||||
const { deps, state } = createPoll({
|
||||
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
|
||||
});
|
||||
runConsultationRecoveryPoll(deps, new AbortController().signal);
|
||||
await flush();
|
||||
tick(1_750);
|
||||
await flush();
|
||||
tick(1_750);
|
||||
await flush();
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 3);
|
||||
assert.equal(state.notice, "正在重新发起本次咨询…");
|
||||
assert.equal(state.replay, 1);
|
||||
|
||||
tick(1_750);
|
||||
await flush();
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 4);
|
||||
assert.equal(state.pendingSession, null);
|
||||
assert.equal(state.pendingRequest, null);
|
||||
assert.equal(state.phase, null);
|
||||
assert.equal(state.error, "后台未找到本次咨询请求,请重新发送。");
|
||||
assert.equal(state.notice, "后台未找到本次咨询请求,已停止恢复,请重新发送。");
|
||||
const timersBefore = state.notice;
|
||||
tick(1_750);
|
||||
await flush();
|
||||
assert.equal(state.notice, timersBefore);
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 4);
|
||||
});
|
||||
});
|
||||
|
||||
test("a reserved status clears the missing count", async () => {
|
||||
await withFakeTimers(async (tick) => {
|
||||
const { deps, state } = createPoll({
|
||||
missing: 2,
|
||||
fetchConsultationStatus: async () => ({
|
||||
sessionId,
|
||||
requestId,
|
||||
status: "reserved",
|
||||
}),
|
||||
});
|
||||
runConsultationRecoveryPoll(deps, new AbortController().signal);
|
||||
await flush();
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 2);
|
||||
tick(1_750);
|
||||
await flush();
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 0);
|
||||
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
|
||||
assert.equal(state.phase, "recovering");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { runConsultationRecoveryPoll } from "../src/lib/consultation-recovery-poll.ts";
|
||||
import { ConsultationStatusError, createSession } from "../src/lib/home-cloud-sync.ts";
|
||||
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
|
||||
import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
|
||||
import { homeSurface as source } from "./home-surface.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
@@ -64,73 +68,174 @@ test("explicit consultation HTTP failures unlock instead of entering recovery",
|
||||
});
|
||||
|
||||
test("reserved consultations recover through the status endpoint", () => {
|
||||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /\/api\/consult\/status\?sessionId=\$\{encodeURIComponent\(sessionId\)\}&requestId=\$\{encodeURIComponent\(requestId\)\}/);
|
||||
assert.match(source, /fetch\("\/api\/consult\/status", \{ signal, cache: "no-store" \}\)/);
|
||||
assert.match(source, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/);
|
||||
// 原值: 预留判断、1750ms 轮询和完成后的 fetchSessionDetail 都在 page.tsx。
|
||||
// 新值: 预留判断在 home-bootstrap-run;轮询间隔和详情读取在 consultation-recovery-poll。
|
||||
// 原因: 两段流程搬出首页。状态端点、1750ms、完成后拉详情都还在。
|
||||
assert.match(bootstrapRun, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/);
|
||||
assert.match(source, /status\?\.status !== "reserved"[\s\S]*sessions\.find\(\(item\) => item\.id === status\.sessionId\)/);
|
||||
assert.match(source, /status: "reserved" \| "completed" \| "cancelled"/);
|
||||
assert.match(source, /readonly responseMessage\?: unknown/);
|
||||
assert.match(source, /phase: "recovering"/);
|
||||
assert.match(source, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
|
||||
assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessionDetail\(pendingSessionId, modelCatalog, controller\.signal\)/);
|
||||
assert.match(poll, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
|
||||
assert.match(poll, /status\.status === "completed"[\s\S]*?fetchDetail\(deps\.pendingSessionId, deps\.modelCatalog, signal\)/);
|
||||
assert.match(source, /window\.addEventListener\("online", onOnline\)/);
|
||||
assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/);
|
||||
assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/);
|
||||
});
|
||||
|
||||
test("a missing reservation is replayed once instead of being polled until the question is lost", () => {
|
||||
test("a missing reservation is replayed once instead of being polled until the question is lost", async () => {
|
||||
// 原值: 按 page.tsx 恢复 effect 的源码切片匹配 404、重放和第 4 次放弃。
|
||||
// 新值: 直接调用 runConsultationRecoveryPoll。逐步假计时器在 consultation-recovery-poll.test.ts。
|
||||
// 原因: 轮询已搬出;这里确认第一次 404 只确认、不立刻丢掉问题。
|
||||
assert.match(source, /class ConsultationStatusError extends Error[\s\S]*readonly status: number/);
|
||||
assert.match(source, /throw new ConsultationStatusError\([\s\S]*response\.status/);
|
||||
const recoveryEffect = source.slice(
|
||||
source.indexOf('if (consultationPhase !== "recovering"'),
|
||||
source.indexOf("}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId])"),
|
||||
);
|
||||
assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/);
|
||||
assert.match(source, /const consultationReplayStarted = useRef<string \| null>\(null\)/);
|
||||
assert.match(recoveryEffect, /status\.status === "reserved"[\s\S]*consultationStatusMissingCount\.current = 0/);
|
||||
assert.match(recoveryEffect, /caught instanceof ConsultationStatusError && caught\.status === 404[\s\S]*consultationStatusMissingCount\.current \+= 1/);
|
||||
assert.match(recoveryEffect, /consultationStatusMissingCount\.current === 1[\s\S]*正在确认本次咨询请求是否已开始/);
|
||||
assert.match(recoveryEffect, /consultationReplayStarted\.current !== pendingRequestId[\s\S]*consultationReplay\.current\(\)/);
|
||||
assert.match(recoveryEffect, /consultationStatusMissingCount\.current >= 4[\s\S]*setPendingSessionId\(null\)[\s\S]*setPendingRequestId\(null\)[\s\S]*setConsultationPhase\(null\)/);
|
||||
assert.match(recoveryEffect, /后台未找到本次咨询请求,已停止恢复,请重新发送。/);
|
||||
assert.match(recoveryEffect, /consultationStatusMissingCount\.current = 0;[\s\S]*回答仍在后台生成,正在自动恢复。/);
|
||||
assert.match(recoveryEffect, /consultationStatusMissingCount\.current > 0[\s\S]*window\.setTimeout\(\(\) => void poll\(\), 1_750\)[\s\S]*else \{[\s\S]*void poll\(\)/);
|
||||
const host = globalThis as { window?: unknown };
|
||||
if (host.window === undefined) host.window = globalThis;
|
||||
const missing = { current: 0 };
|
||||
let notice = "";
|
||||
let replayed = 0;
|
||||
const controller = new AbortController();
|
||||
runConsultationRecoveryPoll({
|
||||
pendingSessionId: "11111111-1111-4111-8111-111111111111",
|
||||
pendingRequestId: "22222222-2222-4222-8222-222222222222",
|
||||
modelCatalog: previewModelCatalog,
|
||||
consultationStatusMissingCount: missing,
|
||||
consultationReplayStarted: { current: null },
|
||||
consultationReplay: { current: () => { replayed += 1; } },
|
||||
pendingConsultation: { current: { phase: "recovering" } },
|
||||
setComposerNotice: (value) => { notice = value; },
|
||||
setSessions: () => undefined,
|
||||
setActiveSessionId: () => undefined,
|
||||
setPendingSessionId: () => undefined,
|
||||
setPendingRequestId: () => undefined,
|
||||
setConsultationPhase: () => undefined,
|
||||
setStreamingReply: () => undefined,
|
||||
setRequestError: () => undefined,
|
||||
refreshAccount: () => undefined,
|
||||
online: () => true,
|
||||
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
|
||||
}, controller.signal);
|
||||
try {
|
||||
for (let step = 0; step < 8; step += 1) await Promise.resolve();
|
||||
assert.equal(missing.current, 1);
|
||||
assert.equal(replayed, 0);
|
||||
assert.equal(notice, "正在确认本次咨询请求是否已开始…");
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab-local pending ids drive strict bootstrap recovery before the global fallback", () => {
|
||||
// 原值:切片从 `let reservedConsultation = null` 开始,依赖初始赋值形状。
|
||||
// 新值:以 recovery bootstrap 的稳定 pending-storage读取锚点切片。
|
||||
// 原因:T1/D2 把全局状态预取并行化,保留行为但改变初始化右值。
|
||||
const bootstrapStart = source.indexOf("const storedPending = dropRectificationStoredPending(");
|
||||
const bootstrap = source.slice(
|
||||
bootstrapStart,
|
||||
source.indexOf("if (controller.signal.aborted) return;", bootstrapStart),
|
||||
);
|
||||
// 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片,
|
||||
// 边界可能落在不同源码文件,得到空字符串并掩盖真实行为。
|
||||
// 新值:直接读取 page.tsx,并以 pending storage effect 自身的稳定边界切片。
|
||||
// 原因:该行为只属于 page.tsx;测试应锁定 pendingSessionId/pendingRequestId 写入
|
||||
// sessionId/requestId/question 与无 pending 时清除,而不依赖文件拼接顺序。
|
||||
test("tab-local pending ids drive strict bootstrap recovery before the global fallback", async () => {
|
||||
// 原值:从 page.tsx 切 storedPending 分支,并用源码顺序断言它先于全局状态。
|
||||
// 新值:调用 runHomeBootstrap。pending 仍覆盖全局 reserved;无 pending 时清除 storage key。
|
||||
// 原因:启动流程搬出后,源码切片不再落在 page.tsx;优先级改由结果锁定。
|
||||
const host = globalThis as { window?: unknown; localStorage?: Storage };
|
||||
if (host.window === undefined) host.window = globalThis;
|
||||
if (!host.localStorage) {
|
||||
const data = new Map<string, string>();
|
||||
host.localStorage = {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, String(value)); },
|
||||
removeItem: (key) => { data.delete(key); },
|
||||
clear: () => data.clear(),
|
||||
key: (index) => Array.from(data.keys())[index] ?? null,
|
||||
get length() { return data.size; },
|
||||
};
|
||||
}
|
||||
const older = createSession(previewModelCatalog.defaultModelId);
|
||||
const pendingSession = createSession(previewModelCatalog.defaultModelId);
|
||||
const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const removed: string[] = [];
|
||||
let restored = "";
|
||||
const reader: Account = {
|
||||
user: { id: "account-1", email: "reader@example.com" },
|
||||
avatar: null,
|
||||
credits: 1,
|
||||
isAdmin: false,
|
||||
adminUrl: null,
|
||||
rectificationPriceCredits: 1,
|
||||
activeSubscription: null,
|
||||
hasConfirmedBirthTime: false,
|
||||
hasUsableBirthTime: false,
|
||||
profile: emptyProfile,
|
||||
};
|
||||
const deps: HomeBootstrapDeps = {
|
||||
sessionListReady: Promise.resolve(),
|
||||
sessionListBoot: () => ({
|
||||
sessions: [older, pendingSession],
|
||||
rawRows: [],
|
||||
cursor: null,
|
||||
account: reader,
|
||||
signedOut: false,
|
||||
}),
|
||||
uiPreview: { current: false },
|
||||
uiPreviewMode: { current: null },
|
||||
consultationStatusMissingCount: { current: 0 },
|
||||
isDevelopment: false,
|
||||
readSearch: () => "",
|
||||
storage: {
|
||||
getItem: (key) => key === pendingConsultationStorageKey
|
||||
? JSON.stringify({ sessionId: pendingSession.id, requestId: storedRequestId, question: "继续" })
|
||||
: null,
|
||||
removeItem: (key) => { removed.push(key); },
|
||||
},
|
||||
restoreConsultationRecovery: (session, requestId) => { restored = `${session.id}:${requestId}`; },
|
||||
setComposerNotice: () => undefined,
|
||||
commit: {
|
||||
setAccount: () => undefined,
|
||||
setAccountError: () => undefined,
|
||||
setActiveSessionId: () => undefined,
|
||||
setBirthTimeAssessmentPhase: () => undefined,
|
||||
setBirthTimeJourney: () => undefined,
|
||||
setBootstrapPhase: () => undefined,
|
||||
setGuidedJourneyPreview: () => undefined,
|
||||
setHydrated: () => undefined,
|
||||
setModelCatalog: () => undefined,
|
||||
setOnboardingStep: () => undefined,
|
||||
setProfile: () => undefined,
|
||||
setProfileDraft: () => undefined,
|
||||
setSessions: () => undefined,
|
||||
setSessionsCursor: () => undefined,
|
||||
setStartGreeting: () => undefined,
|
||||
},
|
||||
io: {
|
||||
fetchModelCatalog: async () => previewModelCatalog,
|
||||
fetchActiveConsultationStatus: async () => ({
|
||||
sessionId: older.id,
|
||||
requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
status: "reserved",
|
||||
}),
|
||||
fetchConsultationStatus: async () => ({
|
||||
sessionId: pendingSession.id,
|
||||
requestId: storedRequestId,
|
||||
status: "reserved",
|
||||
}),
|
||||
resolveLookupBootstrap: async (input) => ({
|
||||
selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false },
|
||||
sessions: input.sessions,
|
||||
notice: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
await runHomeBootstrap(deps, new AbortController().signal);
|
||||
assert.equal(restored, `${pendingSession.id}:${storedRequestId}`);
|
||||
assert.equal(removed.includes(pendingConsultationStorageKey), false);
|
||||
|
||||
// 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片。
|
||||
// 新值:直接读取 page.tsx 的 pending storage effect。
|
||||
// 原因:写入 sessionId/requestId/question 仍留在首页,不随启动流程搬走。
|
||||
const pendingStorageMarker = " if (pendingSessionId && pendingRequestId) {";
|
||||
const storageEffectStart = pageSource.lastIndexOf(" useEffect(() => {", pageSource.indexOf(pendingStorageMarker));
|
||||
const storageSync = pageSource.slice(
|
||||
storageEffectStart,
|
||||
pageSource.indexOf("\n useEffect(() => {", pageSource.indexOf(pendingStorageMarker)),
|
||||
);
|
||||
|
||||
assert.match(bootstrap, /readStoredPendingConsultation\([\s\S]*pendingConsultationStorageKey[\s\S]*nextSessions\.map\(\(session\) => session\.id\)/);
|
||||
// 原值:`if (!storedPending && sessionStorage.getItem(...)) { sessionStorage.removeItem(...) }`
|
||||
// 新值:`if (!storedPending) sessionStorage.removeItem(...)`,读存储前先 `dropRectificationStoredPending`
|
||||
// 原因:校正会话上的 pending 不得进普通咨询恢复;无 pending 时直接清 key,不必再 getItem。
|
||||
assert.match(bootstrap, /dropRectificationStoredPending\([\s\S]*readStoredPendingConsultation/);
|
||||
assert.match(bootstrap, /if \(!storedPending\) sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
|
||||
assert.ok(bootstrap.indexOf("if (storedPending)") >= 0);
|
||||
// 原值:`storedPending` 分支必须先于 `fetchActiveConsultationStatus`。
|
||||
// 新值:两者现在并行预取;pending 仍优先覆盖全局结果,不能再用源码顺序断言。
|
||||
// 原因:T1/D2 慢网下并行读取,减少首页串行等待,不改变恢复优先级。
|
||||
assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId/);
|
||||
assert.match(bootstrap, /status\.status === "reserved"[\s\S]*reservedConsultation = status;[\s\S]*else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
|
||||
assert.match(bootstrap, /caught instanceof ConsultationStatusError && caught\.status === 404 \? 1 : 0/);
|
||||
assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/);
|
||||
assert.match(storageSync, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
|
||||
});
|
||||
@@ -139,7 +244,11 @@ test("pending storage keeps the question so a refresh during the undo window can
|
||||
assert.match(source, /function readStoredPendingConsultation\(/);
|
||||
assert.match(source, /typeof parsedPending\.question === "string" \? parsedPending\.question : ""/);
|
||||
assert.match(sendSource, /sessionStorage\.setItem\(pendingConsultationStorageKey, JSON\.stringify\(\{[\s\S]*sessionId,[\s\S]*requestId,[\s\S]*question: originalQuestion/);
|
||||
assert.match(source, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/);
|
||||
// 原值: homeSurface 里 `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)`。
|
||||
// 新值: 同一调用在 home-bootstrap-run.ts。
|
||||
// 原因: 启动恢复搬出首页,仍把 stored pending 交给真实 restore。
|
||||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
assert.match(bootstrapRun, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/);
|
||||
assert.match(source, /stored\?\.question\?\.trim\(\)/);
|
||||
assert.match(sendSource, /resumeRequestId/);
|
||||
assert.match(sendSource, /questionAlreadyPresent/);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export function installWindow() {
|
||||
const host = globalThis as { window?: unknown };
|
||||
if (host.window === undefined) host.window = globalThis;
|
||||
}
|
||||
|
||||
export async function withFakeTimers(run: (tick: (ms: number) => void) => Promise<void>): Promise<void> {
|
||||
const originalSet = globalThis.setTimeout;
|
||||
const originalClear = globalThis.clearTimeout;
|
||||
const host = globalThis as { window?: unknown };
|
||||
const originalWindow = host.window;
|
||||
host.window = globalThis;
|
||||
let now = 0;
|
||||
let sequence = 1;
|
||||
const queue = new Map<number, { at: number; fn: () => void }>();
|
||||
globalThis.setTimeout = ((fn: TimerHandler, ms?: number) => {
|
||||
const id = sequence;
|
||||
sequence += 1;
|
||||
queue.set(id, {
|
||||
at: now + (typeof ms === "number" ? ms : 0),
|
||||
fn: () => { if (typeof fn === "function") fn(); },
|
||||
});
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout;
|
||||
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
|
||||
if (typeof id === "number") queue.delete(id);
|
||||
}) as unknown as typeof clearTimeout;
|
||||
const tick = (ms: number) => {
|
||||
now += ms;
|
||||
const due = [...queue.entries()]
|
||||
.filter((entry) => entry[1].at <= now)
|
||||
.sort((left, right) => left[1].at - right[1].at);
|
||||
for (const [id, item] of due) {
|
||||
if (!queue.has(id)) continue;
|
||||
queue.delete(id);
|
||||
item.fn();
|
||||
}
|
||||
};
|
||||
try {
|
||||
await run(tick);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSet;
|
||||
globalThis.clearTimeout = originalClear;
|
||||
if (originalWindow === undefined) delete host.window;
|
||||
else host.window = originalWindow;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { homeSurface } from "./home-surface.ts";
|
||||
// being loaded.
|
||||
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8");
|
||||
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||||
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
|
||||
@@ -80,9 +82,12 @@ test("default landing replaces a latest rectification session with an empty cons
|
||||
starterHomeLandingNeedsConsultation(sessions, rectificationId, "keep"),
|
||||
false,
|
||||
);
|
||||
assert.match(page, /resolveStarterHomeLandingSessionId\(/);
|
||||
// 原值: page.tsx 直接调用 resolveStarterHomeLandingSessionId / starterHomeLandingNeedsConsultation。
|
||||
// 新值: 这两个调用在 home-bootstrap-run.ts;page.tsx 仍调用 shouldAutoOpenRectificationSession。
|
||||
// 原因: 启动落点搬进 runHomeBootstrap,揭幕阶段的校正会话判断留在首页。
|
||||
assert.match(bootstrapRun, /resolveStarterHomeLandingSessionId\(/);
|
||||
assert.match(page, /shouldAutoOpenRectificationSession\(/);
|
||||
assert.match(page, /starterHomeLandingNeedsConsultation\(/);
|
||||
assert.match(bootstrapRun, /starterHomeLandingNeedsConsultation\(/);
|
||||
});
|
||||
|
||||
test("new chat landing requests a fresh consultation even with an existing ordinary chat", () => {
|
||||
@@ -124,8 +129,12 @@ test("home bootstrap keeps one slow-network budget before the fatal screen", ()
|
||||
// 新值: 账户、模型与状态等待共享 20000ms,超时后只显示错误屏。
|
||||
// 原因: 慢网串行链需要一次等待一次揭幕,不能半揭幕或提前放弃。
|
||||
assert.equal(HOME_BOOTSTRAP_SLOW_MS, 20_000);
|
||||
assert.match(page, /HOME_BOOTSTRAP_SLOW_MS/);
|
||||
// 原值: page.tsx 引用 HOME_BOOTSTRAP_SLOW_MS。
|
||||
// 新值: 20 秒预算在 runHomeBootstrap 里。
|
||||
// 原因: 超时属于启动流程,首页只调用这个函数。
|
||||
assert.match(bootstrapRun, /HOME_BOOTSTRAP_SLOW_MS/);
|
||||
assert.doesNotMatch(page, /\}, 8000\);/);
|
||||
assert.doesNotMatch(bootstrapRun, /\}, 8000\);/);
|
||||
});
|
||||
|
||||
test("reveal delay is zero once settled and counts down from the phase start otherwise", () => {
|
||||
@@ -146,7 +155,10 @@ test("loading copy changes per phase so the single loading screen narrates progr
|
||||
});
|
||||
|
||||
test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => {
|
||||
assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/);
|
||||
// 原值: `if (bootstrapFailed) setHydrated(true); else setBootstrapPhase("prepare");` 写在 page.tsx。
|
||||
// 新值: 同一分支写在 runHomeBootstrap,setter 经 deps.commit 注入。
|
||||
// 原因: 失败仍揭幕错误屏,成功仍进入 prepare,不半揭幕。
|
||||
assert.match(bootstrapRun, /if \(bootstrapFailed\) deps\.commit\.setHydrated\(true\);\n\s*else deps\.commit\.setBootstrapPhase\("prepare"\);/);
|
||||
// 原值: 3 个 prepare 守卫(onboarding / 今日星语 / 入口摘要)。
|
||||
// 新值: 2 个(今日星语 / 入口摘要)。
|
||||
// 原因: 建议问题请求已删除。
|
||||
@@ -188,7 +200,10 @@ test("recent sessions are prefetched after reveal so switches do not wait", () =
|
||||
|
||||
test("the loading screen DOM contract stale-client recovery depends on is untouched", () => {
|
||||
assert.match(page, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
|
||||
assert.match(page, /<main className="app-loading app-loading-error">/);
|
||||
// 原值: 错误屏 main 写在 page.tsx。
|
||||
// 新值: 同一段 main 在 home-bootstrap-error.tsx。
|
||||
// 原因: 错误屏抽成组件,class 与 role 不变,僵死恢复脚本仍能找到它。
|
||||
assert.match(bootstrapError, /<main className="app-loading app-loading-error">/);
|
||||
assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/);
|
||||
assert.match(staleClientRecovery, /main\.app-loading-error/);
|
||||
assert.ok(homeSurface.includes("bootstrapPrepareSettled("));
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { ConsultationStatusError, createSession, LoginRedirectError } from "../src/lib/home-cloud-sync.ts";
|
||||
import {
|
||||
HOME_BOOTSTRAP_TIMEOUT_MESSAGE,
|
||||
runHomeBootstrap,
|
||||
type HomeBootstrapDeps,
|
||||
type HomeBootstrapIo,
|
||||
} from "../src/lib/home-bootstrap-run.ts";
|
||||
import { HOME_BOOTSTRAP_SLOW_MS } from "../src/lib/home-bootstrap.ts";
|
||||
import {
|
||||
emptyProfile,
|
||||
pendingConsultationStorageKey,
|
||||
previewModelCatalog,
|
||||
type Account,
|
||||
type ChatSession,
|
||||
type ConsultationStatus,
|
||||
} from "../src/lib/home-types.ts";
|
||||
import type { SessionListBoot } from "../src/lib/session-list-context.tsx";
|
||||
import { installWindow, withFakeTimers } from "./home-bootstrap-fake-timers.ts";
|
||||
|
||||
function installBrowserHosts() {
|
||||
installWindow();
|
||||
const host = globalThis as { localStorage?: Storage };
|
||||
if (!host.localStorage) {
|
||||
const data = new Map<string, string>();
|
||||
host.localStorage = {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, String(value)); },
|
||||
removeItem: (key) => { data.delete(key); },
|
||||
clear: () => data.clear(),
|
||||
key: (index) => Array.from(data.keys())[index] ?? null,
|
||||
get length() { return data.size; },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function memoryStorage() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
removeItem: (key: string) => { data.delete(key); },
|
||||
setItem: (key: string, value: string) => { data.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
function account(): Account {
|
||||
return {
|
||||
user: { id: "account-1", email: "reader@example.com" },
|
||||
avatar: null,
|
||||
credits: 3,
|
||||
isAdmin: false,
|
||||
adminUrl: null,
|
||||
rectificationPriceCredits: 1,
|
||||
activeSubscription: null,
|
||||
hasConfirmedBirthTime: false,
|
||||
hasUsableBirthTime: false,
|
||||
profile: emptyProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(options: {
|
||||
sessions: ChatSession[];
|
||||
signedOut?: boolean;
|
||||
io?: Partial<HomeBootstrapIo>;
|
||||
restoreConsultationRecovery?: HomeBootstrapIo["restoreConsultationRecovery"];
|
||||
} ) {
|
||||
installBrowserHosts();
|
||||
const state: {
|
||||
account: Account | null;
|
||||
error: string;
|
||||
hydrated: boolean;
|
||||
phase: string;
|
||||
sessions: ChatSession[];
|
||||
active: string;
|
||||
notice: string;
|
||||
profileSet: boolean;
|
||||
catalogSet: boolean;
|
||||
} = {
|
||||
account: null,
|
||||
error: "",
|
||||
hydrated: false,
|
||||
phase: "",
|
||||
sessions: [] as ChatSession[],
|
||||
active: "",
|
||||
notice: "",
|
||||
profileSet: false,
|
||||
catalogSet: false,
|
||||
};
|
||||
const commits: string[] = [];
|
||||
const storage = memoryStorage();
|
||||
const boot: SessionListBoot = {
|
||||
sessions: options.sessions,
|
||||
rawRows: [],
|
||||
cursor: "cursor-1",
|
||||
account: options.signedOut ? null : account(),
|
||||
signedOut: options.signedOut ?? false,
|
||||
};
|
||||
const note = (name: string) => { commits.push(name); };
|
||||
const deps: HomeBootstrapDeps = {
|
||||
sessionListReady: Promise.resolve(),
|
||||
sessionListBoot: () => boot,
|
||||
uiPreview: { current: false },
|
||||
uiPreviewMode: { current: null },
|
||||
consultationStatusMissingCount: { current: 0 },
|
||||
isDevelopment: false,
|
||||
readSearch: () => "",
|
||||
storage,
|
||||
restoreConsultationRecovery: options.restoreConsultationRecovery ?? (() => undefined),
|
||||
setComposerNotice: (notice) => { state.notice = notice; note("notice"); },
|
||||
commit: {
|
||||
setAccount: (value) => { state.account = value; note("account"); },
|
||||
setAccountError: (value) => { state.error = value; note("error"); },
|
||||
setActiveSessionId: (id) => { state.active = id; note("active"); },
|
||||
setBirthTimeAssessmentPhase: () => note("assessment"),
|
||||
setBirthTimeJourney: () => note("journey"),
|
||||
setBootstrapPhase: (phase) => { state.phase = phase; note("phase"); },
|
||||
setGuidedJourneyPreview: () => note("preview"),
|
||||
setHydrated: (value) => { state.hydrated = value; note("hydrated"); },
|
||||
setModelCatalog: () => { state.catalogSet = true; note("catalog"); },
|
||||
setOnboardingStep: () => note("onboarding"),
|
||||
setProfile: () => { state.profileSet = true; note("profile"); },
|
||||
setProfileDraft: () => note("draft"),
|
||||
setSessions: (sessions) => { state.sessions = sessions; note("sessions"); },
|
||||
setSessionsCursor: () => note("cursor"),
|
||||
setStartGreeting: () => note("greeting"),
|
||||
},
|
||||
io: {
|
||||
fetchModelCatalog: async () => previewModelCatalog,
|
||||
fetchActiveConsultationStatus: async () => null,
|
||||
fetchConsultationStatus: async () => {
|
||||
throw new Error("unexpected status read");
|
||||
},
|
||||
resolveLookupBootstrap: async (input) => ({
|
||||
selection: {
|
||||
sessionId: input.defaultSessionId,
|
||||
urlAction: "none",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
},
|
||||
sessions: input.sessions,
|
||||
notice: null,
|
||||
}),
|
||||
...options.io,
|
||||
},
|
||||
};
|
||||
return { deps, state, commits, storage };
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(predicate(), true);
|
||||
}
|
||||
|
||||
test("a successful bootstrap fills the home once, after every read has settled", async () => {
|
||||
const session = createSession(previewModelCatalog.defaultModelId);
|
||||
let lookupStarted = false;
|
||||
let releaseLookup: () => void = () => undefined;
|
||||
const lookupGate = new Promise<void>((resolve) => { releaseLookup = resolve; });
|
||||
const { deps, state, commits } = createHarness({
|
||||
sessions: [session],
|
||||
io: {
|
||||
resolveLookupBootstrap: async (input) => {
|
||||
lookupStarted = true;
|
||||
await lookupGate;
|
||||
return {
|
||||
selection: {
|
||||
sessionId: input.defaultSessionId,
|
||||
urlAction: "none",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
},
|
||||
sessions: input.sessions,
|
||||
notice: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const pending = runHomeBootstrap(deps, new AbortController().signal);
|
||||
await waitUntil(() => lookupStarted);
|
||||
assert.equal(state.account === null, true);
|
||||
assert.equal(state.sessions.length, 0);
|
||||
assert.equal(state.phase, "");
|
||||
assert.equal(state.hydrated, false);
|
||||
releaseLookup();
|
||||
await pending;
|
||||
assert.equal(state.account?.user.id, "account-1");
|
||||
assert.equal(state.sessions[0]?.id, session.id);
|
||||
assert.equal(state.active, session.id);
|
||||
assert.equal(state.profileSet, true);
|
||||
assert.equal(state.catalogSet, true);
|
||||
assert.equal(state.phase, "prepare");
|
||||
assert.equal(state.hydrated, false);
|
||||
assert.equal(state.error, "");
|
||||
assert.ok(commits.indexOf("account") < commits.indexOf("phase"));
|
||||
});
|
||||
|
||||
test("a model catalog failure stays on the fatal screen and does not half-reveal", async () => {
|
||||
const { deps, state } = createHarness({
|
||||
sessions: [createSession(previewModelCatalog.defaultModelId)],
|
||||
io: {
|
||||
fetchModelCatalog: async () => { throw new Error("模型列表读取失败"); },
|
||||
},
|
||||
});
|
||||
await runHomeBootstrap(deps, new AbortController().signal);
|
||||
assert.equal(state.account, null);
|
||||
assert.equal(state.sessions.length, 0);
|
||||
assert.equal(state.catalogSet, false);
|
||||
assert.equal(state.phase, "");
|
||||
assert.equal(state.hydrated, true);
|
||||
assert.equal(state.error, "模型列表读取失败");
|
||||
});
|
||||
|
||||
test("a stored pending reservation wins over the global active status", async () => {
|
||||
const older = createSession(previewModelCatalog.defaultModelId);
|
||||
const pendingSession = createSession(previewModelCatalog.defaultModelId);
|
||||
const globalStatus: ConsultationStatus = {
|
||||
sessionId: older.id,
|
||||
requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
status: "reserved",
|
||||
};
|
||||
const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
let restored: { sessionId: string; requestId: string } | null = null;
|
||||
const { deps, storage } = createHarness({
|
||||
sessions: [older, pendingSession],
|
||||
restoreConsultationRecovery: (session, requestId) => {
|
||||
restored = { sessionId: session.id, requestId };
|
||||
},
|
||||
io: {
|
||||
fetchActiveConsultationStatus: async () => globalStatus,
|
||||
fetchConsultationStatus: async (sessionId, requestId) => ({
|
||||
sessionId,
|
||||
requestId,
|
||||
status: "reserved",
|
||||
}),
|
||||
},
|
||||
});
|
||||
storage.setItem(pendingConsultationStorageKey, JSON.stringify({
|
||||
sessionId: pendingSession.id,
|
||||
requestId: storedRequestId,
|
||||
question: "稍后继续",
|
||||
}));
|
||||
await runHomeBootstrap(deps, new AbortController().signal);
|
||||
assert.deepEqual(restored, { sessionId: pendingSession.id, requestId: storedRequestId });
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 0);
|
||||
});
|
||||
|
||||
test("a stored 404 still reserves that request and counts the first miss", async () => {
|
||||
const session = createSession(previewModelCatalog.defaultModelId);
|
||||
const requestId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
let restored: string | null = null;
|
||||
const { deps, storage } = createHarness({
|
||||
sessions: [session],
|
||||
restoreConsultationRecovery: (_session, restoredRequestId) => { restored = restoredRequestId; },
|
||||
io: {
|
||||
fetchActiveConsultationStatus: async () => null,
|
||||
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
|
||||
},
|
||||
});
|
||||
storage.setItem(pendingConsultationStorageKey, JSON.stringify({
|
||||
sessionId: session.id,
|
||||
requestId,
|
||||
question: "",
|
||||
}));
|
||||
await runHomeBootstrap(deps, new AbortController().signal);
|
||||
assert.equal(deps.consultationStatusMissingCount.current, 1);
|
||||
assert.equal(restored, requestId);
|
||||
});
|
||||
|
||||
test("the slow-network timeout aborts the run and does not enter prepare", async () => {
|
||||
installBrowserHosts();
|
||||
await withFakeTimers(async (tick) => {
|
||||
const { deps, state } = createHarness({
|
||||
sessions: [createSession(previewModelCatalog.defaultModelId)],
|
||||
io: {
|
||||
fetchModelCatalog: (signal) => new Promise((_resolve, reject) => {
|
||||
const fail = () => {
|
||||
const error = new Error("Aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
};
|
||||
if (signal?.aborted) fail();
|
||||
else signal?.addEventListener("abort", fail, { once: true });
|
||||
}),
|
||||
},
|
||||
});
|
||||
const pending = runHomeBootstrap(deps, new AbortController().signal);
|
||||
await Promise.resolve();
|
||||
tick(HOME_BOOTSTRAP_SLOW_MS);
|
||||
await pending;
|
||||
assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
|
||||
assert.equal(state.hydrated, true);
|
||||
assert.equal(state.phase, "");
|
||||
assert.equal(state.account, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("a login redirect leaves the bootstrap timeout running", async () => {
|
||||
installBrowserHosts();
|
||||
await withFakeTimers(async (tick) => {
|
||||
const { deps, state } = createHarness({
|
||||
sessions: [],
|
||||
signedOut: true,
|
||||
io: {
|
||||
redirectToLogin: () => { throw new LoginRedirectError(); },
|
||||
},
|
||||
});
|
||||
await runHomeBootstrap(deps, new AbortController().signal);
|
||||
assert.equal(state.error, "");
|
||||
assert.equal(state.hydrated, false);
|
||||
tick(HOME_BOOTSTRAP_SLOW_MS);
|
||||
await Promise.resolve();
|
||||
assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
|
||||
assert.equal(state.hydrated, true);
|
||||
assert.equal(state.phase, "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { HomeBootstrapError } from "../src/components/home-bootstrap-error.tsx";
|
||||
import { HomeOnboardingShell } from "../src/components/home-onboarding-shell.tsx";
|
||||
import type { BirthTimeGuidedController } from "../src/hooks/use-birth-time-guided-journey.ts";
|
||||
import { emptyProfile, presetOnboardingMessage } from "../src/lib/home-types.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("the fatal screen keeps its copy and retries through retryHomeBootstrap", async () => {
|
||||
const accountError = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。";
|
||||
const markup = renderToStaticMarkup(
|
||||
<HomeBootstrapError accountError={accountError} onRetry={() => undefined} />,
|
||||
);
|
||||
assert.match(markup, /暂时无法进入 Jyotisha/);
|
||||
assert.match(markup, /连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。/);
|
||||
assert.match(markup, /重试/);
|
||||
assert.match(markup, /返回登录/);
|
||||
assert.match(markup, /app-loading app-loading-error/);
|
||||
assert.match(markup, /role="alert"/);
|
||||
|
||||
const harness = createClientLifecycleHarness();
|
||||
let retried = 0;
|
||||
try {
|
||||
await harness.render(<HomeBootstrapError accountError={accountError} onRetry={() => { retried += 1; }} />);
|
||||
const retry = harness.elements().find((node) => node.tagName === "BUTTON" && node.text.includes("重试"));
|
||||
assert.ok(retry);
|
||||
await harness.event(retry);
|
||||
assert.equal(retried, 1);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
|
||||
// 原值:错误屏按钮内联在 page.tsx,onClick 直接调用 retryHomeBootstrap。
|
||||
// 新值:按钮在 HomeBootstrapError,page.tsx 把 onRetry 接到同一个 retryHomeBootstrap(...reloadSessionList)。
|
||||
// 原因:错误屏抽成组件后,重试仍局部重跑,不整页刷新。
|
||||
assert.match(page, /<HomeBootstrapError[\s\S]*onRetry=\{\(\) => retryHomeBootstrap\([\s\S]*reloadSessionList,[\s\S]*\)\}/);
|
||||
assert.doesNotMatch(page, /window\.location\.reload\(\)/);
|
||||
});
|
||||
|
||||
test("onboarding keeps the name prompt, birth card, place card, and retry card", () => {
|
||||
const guided = {} as BirthTimeGuidedController;
|
||||
const nameMarkup = renderToStaticMarkup(
|
||||
<HomeOnboardingShell
|
||||
profileComplete={false}
|
||||
profileDraft={emptyProfile}
|
||||
setProfileDraft={() => undefined}
|
||||
onboardingStep="name"
|
||||
presetMessageLength={presetOnboardingMessage.length}
|
||||
currentOnboardingMessage={presetOnboardingMessage}
|
||||
onboardingJustCompleted={false}
|
||||
onboardingCardReady={false}
|
||||
presetMessageFinished
|
||||
birthTimeJourney={null}
|
||||
birthTimeAssessmentPhase={null}
|
||||
birthTimeError=""
|
||||
birthTimeGuided={guided}
|
||||
accountError=""
|
||||
profileSaving={false}
|
||||
saveOnboardingBirth={() => undefined}
|
||||
saveOnboardingPlace={() => undefined}
|
||||
retryBirthTimeAssessment={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(nameMarkup, /你好,我是 Jyotisha/);
|
||||
assert.match(nameMarkup, /请问我该怎么称呼你/);
|
||||
assert.doesNotMatch(nameMarkup, /出生日期与时间/);
|
||||
|
||||
const birthMarkup = renderToStaticMarkup(
|
||||
<HomeOnboardingShell
|
||||
profileComplete={false}
|
||||
profileDraft={{ ...emptyProfile, name: "林遥" }}
|
||||
setProfileDraft={() => undefined}
|
||||
onboardingStep="birth"
|
||||
presetMessageLength={80}
|
||||
currentOnboardingMessage="出生时间"
|
||||
onboardingJustCompleted={false}
|
||||
onboardingCardReady
|
||||
presetMessageFinished
|
||||
birthTimeJourney={null}
|
||||
birthTimeAssessmentPhase={null}
|
||||
birthTimeError=""
|
||||
birthTimeGuided={guided}
|
||||
accountError=""
|
||||
profileSaving={false}
|
||||
saveOnboardingBirth={() => undefined}
|
||||
saveOnboardingPlace={() => undefined}
|
||||
retryBirthTimeAssessment={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(birthMarkup, /出生日期与时间/);
|
||||
assert.match(birthMarkup, />继续</);
|
||||
|
||||
const placeMarkup = renderToStaticMarkup(
|
||||
<HomeOnboardingShell
|
||||
profileComplete={false}
|
||||
profileDraft={{ ...emptyProfile, name: "林遥" }}
|
||||
setProfileDraft={() => undefined}
|
||||
onboardingStep="place"
|
||||
presetMessageLength={80}
|
||||
currentOnboardingMessage="出生地点"
|
||||
onboardingJustCompleted={false}
|
||||
onboardingCardReady
|
||||
presetMessageFinished
|
||||
birthTimeJourney={null}
|
||||
birthTimeAssessmentPhase={null}
|
||||
birthTimeError=""
|
||||
birthTimeGuided={guided}
|
||||
accountError=""
|
||||
profileSaving={false}
|
||||
saveOnboardingBirth={() => undefined}
|
||||
saveOnboardingPlace={() => undefined}
|
||||
retryBirthTimeAssessment={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(placeMarkup, /出生地点/);
|
||||
assert.match(placeMarkup, /搜索全球城市或区县,系统会匹配出生当日的时区。/);
|
||||
assert.match(placeMarkup, />确定</);
|
||||
|
||||
const retryMarkup = renderToStaticMarkup(
|
||||
<HomeOnboardingShell
|
||||
profileComplete={false}
|
||||
profileDraft={{ ...emptyProfile, name: "林遥" }}
|
||||
setProfileDraft={() => undefined}
|
||||
onboardingStep="rectification"
|
||||
presetMessageLength={80}
|
||||
currentOnboardingMessage="校正"
|
||||
onboardingJustCompleted={false}
|
||||
onboardingCardReady
|
||||
presetMessageFinished
|
||||
birthTimeJourney={null}
|
||||
birthTimeAssessmentPhase={null}
|
||||
birthTimeError=""
|
||||
birthTimeGuided={guided}
|
||||
accountError=""
|
||||
profileSaving={false}
|
||||
saveOnboardingBirth={() => undefined}
|
||||
saveOnboardingPlace={() => undefined}
|
||||
retryBirthTimeAssessment={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(retryMarkup, /出生时间尚未完成评估/);
|
||||
assert.match(retryMarkup, /资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。/);
|
||||
assert.match(retryMarkup, />重新评估</);
|
||||
});
|
||||
@@ -4,16 +4,13 @@ import test from "node:test";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
// Measured 2026-09-16 after home-state-lowering batch 2 on origin/staging @ 37e6c519.
|
||||
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1846.
|
||||
// Measured 2026-09-25 after home-page-split on codex/home-page-split-20260925.
|
||||
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1417.
|
||||
// Hook counts use \buseState[<(] / \buseRef[<(] so `useState<Type>(` is not missed.
|
||||
// Former caps were useState 52 / useRef 39 / lines 1931 after batch 1 @ f8e607c2,
|
||||
// and useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92.
|
||||
// Batch 2 moved 4 synastry useStates into useSynastry, 6 profile useStates into
|
||||
// useProfileOnboarding and 6 session useStates into useSessionManagement, and
|
||||
// dropped the two guided-birth-time callback refs that the profile hook no longer
|
||||
// needs now that it runs before useBirthTimeGuidedJourney.
|
||||
const PAGE_LINE_COUNT_BASELINE = 1846;
|
||||
// Former line baseline was 1846 (cap 1996) after batch 2 @ 37e6c519.
|
||||
// This split moved bootstrap, recovery polling, and the error / onboarding
|
||||
// screens out of Home(). useState stayed 34 (cap 36) and useRef stayed 37.
|
||||
const PAGE_LINE_COUNT_BASELINE = 1417;
|
||||
const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150;
|
||||
const HOME_USE_STATE_CAP = 36;
|
||||
const HOME_USE_REF_CAP = 37;
|
||||
|
||||
@@ -5,9 +5,11 @@ import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
|
||||
import { createSession } from "../src/lib/home-cloud-sync.ts";
|
||||
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
|
||||
import { pendingConsultationStorageKey, previewModelCatalog, emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
|
||||
import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts";
|
||||
import type { SessionListBoot } from "../src/lib/session-list-context.tsx";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
|
||||
|
||||
function sourceBetween(source: string, start: string, end: string): string {
|
||||
@@ -17,63 +19,174 @@ function sourceBetween(source: string, start: string, end: string): string {
|
||||
return source.slice(from, to);
|
||||
}
|
||||
|
||||
function bootstrapWithRecovery(urlAction: "new-chat" | "none") {
|
||||
// Exercise the real recovery function and Home's activation block together.
|
||||
// The synthetic ordinary chat has no engine payload or private user data.
|
||||
function installBrowserHosts() {
|
||||
const host = globalThis as { window?: unknown; localStorage?: Storage };
|
||||
if (host.window === undefined) host.window = globalThis;
|
||||
if (!host.localStorage) {
|
||||
const data = new Map<string, string>();
|
||||
host.localStorage = {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, String(value)); },
|
||||
removeItem: (key) => { data.delete(key); },
|
||||
clear: () => data.clear(),
|
||||
key: (index) => Array.from(data.keys())[index] ?? null,
|
||||
get length() { return data.size; },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function bindRestore(sandbox: Record<string, unknown>) {
|
||||
const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current =");
|
||||
const compiled = ts.transpileModule(
|
||||
`${recovery}\n__boundRestore = restoreConsultationRecovery;`,
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
|
||||
).outputText;
|
||||
const box: Record<string, unknown> = { __boundRestore: undefined, ...sandbox };
|
||||
runInNewContext(compiled, box);
|
||||
return box.__boundRestore as (session: ChatSession, requestId: string, stored?: unknown) => void;
|
||||
}
|
||||
|
||||
function account(): Account {
|
||||
return {
|
||||
user: { id: "account-1", email: "reader@example.com" },
|
||||
avatar: null,
|
||||
credits: 3,
|
||||
isAdmin: false,
|
||||
adminUrl: null,
|
||||
rectificationPriceCredits: 1,
|
||||
activeSubscription: null,
|
||||
hasConfirmedBirthTime: false,
|
||||
hasUsableBirthTime: false,
|
||||
profile: emptyProfile,
|
||||
};
|
||||
}
|
||||
|
||||
async function bootstrapWithRecovery(urlAction: "new-chat" | "none") {
|
||||
// 原值:把 page.tsx 激活段和 hook 里的 restore 一起塞进 vm。
|
||||
// 新值:直接调用 runHomeBootstrap,restore 仍是 hook 里的真实函数。
|
||||
// 原因:启动流程已搬出 page.tsx;调用整段函数还覆盖 new-chat 会新建空咨询。
|
||||
installBrowserHosts();
|
||||
const oldSession = createSession("test-model");
|
||||
oldSession.messages = [{ role: "user", text: "测试问题" }];
|
||||
const localSession = createSession("test-model");
|
||||
const pending: { current: { sessionId: string; phase: string } | null } = { current: null };
|
||||
const state = { active: "", pendingSession: "", pendingRequest: "", phase: "", notice: "", storedCleared: false };
|
||||
const state = {
|
||||
active: "",
|
||||
pendingSession: "",
|
||||
pendingRequest: "",
|
||||
phase: "",
|
||||
notice: "",
|
||||
storedCleared: false,
|
||||
sessions: [] as ChatSession[],
|
||||
};
|
||||
const writes: unknown[][] = [];
|
||||
const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current =");
|
||||
// 原值:切到已删除的模型不可用分支作为源码边界。
|
||||
// 新值:以稳定的 fallback-session 分支作为边界;模型不可用现在直接走 fatal screen。
|
||||
// 原因:T1/D2 移除了已验收的半揭幕路径,测试不能再依赖被删除的语句。
|
||||
const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (parsedSessions.fallbackSessionIds.length > 0)");
|
||||
const compiled = ts.transpileModule(`${recovery}\n${activation}`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
runInNewContext(compiled, {
|
||||
const memory = new Map<string, string>();
|
||||
const sandbox = {
|
||||
AbortController,
|
||||
navigator: { onLine: true },
|
||||
pendingConsultation: pending,
|
||||
bootstrapSelection: { urlAction, clearStoredReturn: urlAction === "new-chat" },
|
||||
landingSessionId: localSession.id,
|
||||
nextSessions: [localSession, oldSession],
|
||||
reservedConsultation: { status: "reserved", sessionId: oldSession.id, requestId: "test-request" },
|
||||
storedPending: null,
|
||||
pendingConsultationStorageKey,
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => memory.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { memory.set(key, value); },
|
||||
removeItem: (key: string) => { memory.delete(key); },
|
||||
},
|
||||
sessions: [localSession, oldSession],
|
||||
consultSendBlockedByRectificationSession: () => false,
|
||||
isUnsavedEmptyConsultation,
|
||||
isGenericSessionTitle: () => false,
|
||||
resolveSessionTitle: () => "新对话",
|
||||
timestamp: () => 1,
|
||||
updateSession: () => { assert.fail("the existing user question must not be duplicated"); },
|
||||
setActiveSessionId: (id: string) => { state.active = id; },
|
||||
setPendingSessionId: (id: string) => { state.pendingSession = id; },
|
||||
setPendingRequestId: (id: string) => { state.pendingRequest = id; },
|
||||
setConsultationPhase: (phase: string) => { state.phase = phase; },
|
||||
setStreamingReply: () => undefined,
|
||||
setComposerNotice: (notice: string) => { state.notice = notice; },
|
||||
setStreamingReply: () => {},
|
||||
clearLoginSessionReturn: () => { state.storedCleared = true; },
|
||||
writeSessionUrl: (...args: unknown[]) => { writes.push(args); },
|
||||
updateSession: () => { assert.fail("the existing user question must not be duplicated"); },
|
||||
});
|
||||
};
|
||||
const restore = bindRestore(sandbox);
|
||||
const boot: SessionListBoot = {
|
||||
sessions: [localSession, oldSession],
|
||||
rawRows: [],
|
||||
cursor: null,
|
||||
account: account(),
|
||||
signedOut: false,
|
||||
};
|
||||
const deps: HomeBootstrapDeps = {
|
||||
sessionListReady: Promise.resolve(),
|
||||
sessionListBoot: () => boot,
|
||||
uiPreview: { current: false },
|
||||
uiPreviewMode: { current: null },
|
||||
consultationStatusMissingCount: { current: 0 },
|
||||
isDevelopment: false,
|
||||
readSearch: () => "",
|
||||
storage: {
|
||||
getItem: () => null,
|
||||
removeItem: () => undefined,
|
||||
},
|
||||
restoreConsultationRecovery: restore,
|
||||
setComposerNotice: sandbox.setComposerNotice,
|
||||
commit: {
|
||||
setAccount: () => undefined,
|
||||
setAccountError: () => undefined,
|
||||
setActiveSessionId: sandbox.setActiveSessionId,
|
||||
setBirthTimeAssessmentPhase: () => undefined,
|
||||
setBirthTimeJourney: () => undefined,
|
||||
setBootstrapPhase: () => undefined,
|
||||
setGuidedJourneyPreview: () => undefined,
|
||||
setHydrated: () => undefined,
|
||||
setModelCatalog: () => undefined,
|
||||
setOnboardingStep: () => undefined,
|
||||
setProfile: () => undefined,
|
||||
setProfileDraft: () => undefined,
|
||||
setSessions: (sessions) => { state.sessions = sessions; },
|
||||
setSessionsCursor: () => undefined,
|
||||
setStartGreeting: () => undefined,
|
||||
},
|
||||
io: {
|
||||
fetchModelCatalog: async () => previewModelCatalog,
|
||||
fetchActiveConsultationStatus: async () => ({
|
||||
status: "reserved",
|
||||
sessionId: oldSession.id,
|
||||
requestId: "33333333-3333-4333-8333-333333333333",
|
||||
}),
|
||||
resolveLookupBootstrap: async (input) => ({
|
||||
selection: {
|
||||
sessionId: localSession.id,
|
||||
urlAction,
|
||||
missing: false,
|
||||
clearStoredReturn: urlAction === "new-chat",
|
||||
},
|
||||
sessions: input.sessions,
|
||||
notice: null,
|
||||
}),
|
||||
writeSessionUrl: (...args: unknown[]) => { writes.push(args); },
|
||||
clearLoginSessionReturn: () => { state.storedCleared = true; },
|
||||
},
|
||||
};
|
||||
await runHomeBootstrap(deps, new AbortController().signal);
|
||||
return { state, pending, writes, localSession, oldSession };
|
||||
}
|
||||
|
||||
test("new chat keeps its local landing while a reserved older consultation recovers", () => {
|
||||
const { state, pending, writes, localSession, oldSession } = bootstrapWithRecovery("new-chat");
|
||||
assert.equal(state.active, localSession.id);
|
||||
test("new chat keeps its local landing while a reserved older consultation recovers", async () => {
|
||||
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("new-chat");
|
||||
const landing = state.sessions[0];
|
||||
assert.ok(landing);
|
||||
assert.equal(state.active, landing.id);
|
||||
assert.notEqual(landing.id, oldSession.id);
|
||||
assert.equal(isUnsavedEmptyConsultation(landing), true);
|
||||
assert.equal(state.notice, "");
|
||||
assert.equal(state.storedCleared, true);
|
||||
assert.deepEqual(writes, [[null, "replace"]]);
|
||||
assert.equal(isUnsavedEmptyConsultation(localSession), true);
|
||||
assert.equal(state.pendingSession, oldSession.id);
|
||||
assert.equal(state.pendingRequest, "test-request");
|
||||
assert.equal(state.pendingRequest, "33333333-3333-4333-8333-333333333333");
|
||||
assert.equal(state.phase, "recovering");
|
||||
assert.equal(pending.current?.sessionId, oldSession.id);
|
||||
assert.equal(pending.current?.phase, "recovering");
|
||||
});
|
||||
|
||||
test("ordinary home landing still activates and announces the reserved consultation", () => {
|
||||
const { state, pending, writes, oldSession } = bootstrapWithRecovery("none");
|
||||
test("ordinary home landing still activates and announces the reserved consultation", async () => {
|
||||
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("none");
|
||||
assert.equal(state.active, oldSession.id);
|
||||
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
|
||||
assert.equal(state.storedCleared, false);
|
||||
|
||||
@@ -20,7 +20,12 @@ test("the app layout owns one SessionListProvider and one SidebarProvider", () =
|
||||
test("the provider fetches the session list once and home does not fetch it again", () => {
|
||||
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
|
||||
assert.match(provider, /fetch\("\/api\/account"/);
|
||||
assert.match(page, /await sessionListReady;/);
|
||||
// 原值: page.tsx 里 `await sessionListReady;`
|
||||
// 新值: page.tsx 把 sessionListReady 交给 runHomeBootstrap,函数里 `await deps.sessionListReady`。
|
||||
// 原因: 启动流程搬出首页后,会话列表仍只由 provider 拉一次。
|
||||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
assert.match(page, /sessionListReady,/);
|
||||
assert.match(bootstrapRun, /await deps\.sessionListReady;/);
|
||||
assert.doesNotMatch(page, /fetchSessions\(/);
|
||||
assert.doesNotMatch(page, /fetchAccount\(/);
|
||||
assert.match(page, /useHomeShellRegistration\(/);
|
||||
|
||||
@@ -89,14 +89,19 @@ test("edge and layout recover a stale client without looping forever", () => {
|
||||
assert.match(recoverySource, /window\.location\.reload\(\)/);
|
||||
assert.match(recoverySource, /pageshow/);
|
||||
assert.match(recoverySource, /main\.app-loading\[aria-busy="true"\]/);
|
||||
assert.match(pageSource, /clearStaleClientReload\(sessionStorage\)/);
|
||||
// 原值: page.tsx 里 `clearStaleClientReload(sessionStorage)`。
|
||||
// 新值: 首页把 `storage: sessionStorage` 交给 runHomeBootstrap,函数内 `clearStaleClientReload(deps.storage)`。
|
||||
// 原因: 清的仍是会话存储,不是别的 storage。
|
||||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
assert.match(pageSource, /storage: sessionStorage/);
|
||||
assert.match(bootstrapRun, /clearStaleClientReload\(deps\.storage\)/);
|
||||
});
|
||||
|
||||
test("a login redirect leaves the bootstrap timeout running so a failed navigation can escape the spinner", () => {
|
||||
const bootstrap = pageSource.slice(
|
||||
pageSource.indexOf("async function loadCloudData()"),
|
||||
pageSource.indexOf("void loadCloudData();"),
|
||||
);
|
||||
// 原值: 从 page.tsx 的 loadCloudData 切片。
|
||||
// 新值: 同一段 try/finally 在 home-bootstrap-run.ts。
|
||||
// 原因: 登录跳转仍不清除 20 秒超时,失败的导航还能离开加载屏。
|
||||
const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||||
assert.match(bootstrap, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/);
|
||||
assert.match(bootstrap, /if \(redirectedToLogin\) return;/);
|
||||
assert.match(bootstrap, /window\.clearTimeout\(bootstrapTimeout\)/);
|
||||
|
||||
Reference in New Issue
Block a user