refactor(home): split bootstrap, recovery polling, and shells
Move the home startup sequence and consultation recovery poll into plain functions, and the error and onboarding screens into components. Landing rules, the 20s fatal screen, retry, and the chart settings deep link stay the same.
This commit is contained in:
@@ -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`。
|
||||
- 实现提交短 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。
|
||||
+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