From 54269fcfcce6cf470c56bba0a515ff607456c5e7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 22:58:02 +0800 Subject: [PATCH] refactor(chat): extract home helpers, chart library, and starter surfaces page.tsx still owns the chat main chain, but the first product surfaces now live in their own modules so later splits can land without editing the 4k-line Home. Source-lock tests follow the moved tokens; the orphan user-data contract is aligned and added to the quick gate. Co-authored-by: Cursor --- PROGRESS-home-split-20260901.md | 103 ++ frontend/src/app/page.tsx | 1551 ++--------------- .../src/components/birth-location-fields.tsx | 40 + .../src/components/chart-library-panel.tsx | 269 +++ .../components/onboarding-chat-message.tsx | 28 + frontend/src/components/profile-fields.tsx | 38 + frontend/src/components/starter-home.tsx | 159 ++ frontend/src/lib/home-cloud-sync.ts | 499 ++++++ frontend/src/lib/home-profile.ts | 323 ++++ frontend/src/lib/home-types.ts | 254 +++ frontend/tests/birth-place-picker.test.ts | 2 +- .../birth-time-consultation-consent.test.ts | 9 +- .../character-remaining-contract.test.ts | 2 +- .../tests/chart-library-other-profile.test.ts | 3 +- .../chat-navigation-a11y-contract.test.ts | 2 +- frontend/tests/chat-session-authority.test.ts | 2 +- frontend/tests/chat-session-url.test.ts | 2 +- frontend/tests/chat-session-write.test.ts | 5 +- .../tests/composer-isolation-contract.test.ts | 2 +- .../tests/consultation-entrypoint.test.ts | 37 +- frontend/tests/consultation-recovery.test.ts | 3 +- frontend/tests/daily-starlanguage.test.ts | 5 +- frontend/tests/home-surface.ts | 17 + frontend/tests/membership-page.test.ts | 2 +- .../tests/onboarding-presentation.test.ts | 3 +- .../tests/rectification-agentic-entry.test.ts | 2 +- frontend/tests/settings-mvp-contract.test.ts | 4 +- frontend/tests/starter-questions.test.ts | 2 +- scripts/run_quality_gate.py | 2 + tests/test_birth_time_journey_contract.py | 8 +- ...est_daily_and_rectification_entrypoints.py | 6 +- tests/test_supabase_user_data_contract.py | 69 +- 32 files changed, 1987 insertions(+), 1466 deletions(-) create mode 100644 PROGRESS-home-split-20260901.md create mode 100644 frontend/src/components/birth-location-fields.tsx create mode 100644 frontend/src/components/chart-library-panel.tsx create mode 100644 frontend/src/components/onboarding-chat-message.tsx create mode 100644 frontend/src/components/profile-fields.tsx create mode 100644 frontend/src/components/starter-home.tsx create mode 100644 frontend/src/lib/home-cloud-sync.ts create mode 100644 frontend/src/lib/home-profile.ts create mode 100644 frontend/src/lib/home-types.ts create mode 100644 frontend/tests/home-surface.ts diff --git a/PROGRESS-home-split-20260901.md b/PROGRESS-home-split-20260901.md new file mode 100644 index 00000000..fe399da6 --- /dev/null +++ b/PROGRESS-home-split-20260901.md @@ -0,0 +1,103 @@ +# PROGRESS · 拆分首页巨石组件·第一批(2026-09-01) + +工作树:`.worktrees/home-split-20260901` +分支:`codex/home-split-20260901`(跟踪 `origin/staging`) +基线:`origin/staging` @ `85589005`(任务书提交;产品代码与 P2a `ce6a8a7e` 相同) + +不要写成 `PROGRESS.md`。本轮未提交、未推送。 + +## 度量 + +| 项 | 拆前 `85589005` | 拆后 | 说明 | +| --- | ---: | ---: | --- | +| `frontend/src/app/page.tsx` 行数 | 4766 | 3505 | 目标 ≤3000,未达标 | +| `useState(` | 26 | 26 | 见下方 overlay 说明 | +| `useEffect(` | 20 | 20 | 拉取/重试 effect 仍在 Home | +| `/` 路由 | `○ Static` | `○ Static` | webpack `next build` | +| 首屏 JS gzip -9 | 500262 B = 488.5 KB | 501315 B = 489.6 KB | +1053 B / **+0.21%**(±2% 内) | +| 前端测试 | 2427 | 2427 | fail=0 skipped=0(见验证) | + +未达标原因:聊天主链路(composer / transcript / `send` / 停止与恢复 / 引导对话)按任务书本批不动。抽走的是 helpers + 星盘库/合盘面板 + starter/每日星语。剩余约 505 行要等第二批。 + +`useState` 未下降:`otherProfileDraft` / `editingChartId` / `otherChartRelationship` / `editingSelfChart` 仍留在 `Home`。`AccountDialogOverlay` 在关闭时 `return null`,把独占 state 下移进面板会在关弹窗时丢掉未保存草稿,属于行为变化,本轮禁止。handlers(`saveOtherChart` 等)已随 JSX 进面板,state 经显式 props 传入。 + +## 任务 0 · 孤儿契约测试 + +`tests/test_supabase_user_data_contract.py` 对齐现状源码(性质保留:浏览器不直写 `profiles` / `chat_sessions`,账户 PATCH 仍拒数组,合盘仍走 `postPython("/api/chart")`)。任务书写的三处之外,同一文件里还有过期 token,一并换成现码并在上方注释原值: + +- 首页不再 `.from("profiles")` / `.from("chat_sessions")`,锁 `fetch("/api/account"` / `fetch("/api/sessions"` +- 账户 PATCH:`accountProfilePatchSchema.safeParse` + `z.object({`(`account-profile-patch.ts`) +- 文案「添加其他人的星盘」 +- 合盘:`postPython("/api/chart", selfPayload)` / `partnerPayload` +- 用户消息:`messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }]` +- 中断路径:源码已无 `await persistSession(interruptedSession)`;停止文案「正在停止回答并申请退回本次点数…」 + +搬家后该文件改读 `_home_surface()`(`page.tsx` + 抽出文件拼接),正则/token 未改。 + +`scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS` 已列入此文件,注释写明前端搬家轮必须带着它跑。 + +`pytest tests/test_supabase_user_data_contract.py tests/test_daily_and_rectification_entrypoints.py -q` → 12 passed。 + +## 任务 1–3 · 搬家 + +新文件(实现从 `page.tsx` 原样搬出,加 `export`): + +| 文件 | 行 | 内容 | +| --- | ---: | --- | +| `frontend/src/lib/home-types.ts` | 254 | 类型、空资料、themes、storage 键常量 | +| `frontend/src/lib/home-profile.ts` | 323 | 地点/资料/标签/`readProfile`/`upsertSelfChart` | +| `frontend/src/lib/home-cloud-sync.ts` | 499 | 云端 fetch、登录跳转、每日星语读写 | +| `frontend/src/components/birth-location-fields.tsx` | 40 | `BirthLocationFields` | +| `frontend/src/components/profile-fields.tsx` | 38 | `ProfileFields` | +| `frontend/src/components/onboarding-chat-message.tsx` | 28 | `OnboardingChatMessage` | +| `frontend/src/components/chart-library-panel.tsx` | 269 | 星盘库 + 合盘卡/历史/他人表单 | +| `frontend/src/components/starter-home.tsx` | 159 | starter hero / 每日星语 / 生时入口 / 主题卡 | + +`page.tsx` 仍 `"use client"`、`import "@/app/site-styles"`、`const BirthTimeRectification = dynamic(...)`。新组件未 import 全局样式。无 context/store、无手写 `useCallback`/`useMemo`、未重开 React Compiler。 + +本人星盘表单仍 `onSubmit={saveProfile}`(面板 prop 名就是 `saveProfile`)。starter 回调 prop 保持原标识符:`startDailyStarlanguageConsultation`、`openRectificationFromHomepage`、`startSuggestedConsultation`。 + +## 合同测试路径(同一 token,只换读取面) + +共用拼接件 `frontend/tests/home-surface.ts`:`page.tsx` + 上表抽出文件。Python 侧 `_home_surface()` / 显式 concat 同理。 + +| 测试 | 原路径 | 新路径 | +| --- | --- | --- | +| `birth-place-picker.test.ts` | `src/app/page.tsx` | `homeSurface`(`BirthPlacePicker` 在 `birth-location-fields.tsx`) | +| `birth-time-consultation-consent.test.ts` | `page.tsx` | `homeSurface`(`readProfile` 在 `home-profile.ts`) | +| `character-remaining-contract.test.ts` | `page.tsx` | `homeSurface`(姓名字数 `aria-describedby` 在 `profile-fields.tsx`) | +| `chart-library-other-profile.test.ts` | `page.tsx` | `homeSurface` | +| `chat-navigation-a11y-contract.test.ts` | `page.tsx` | `homeSurface` | +| `chat-session-authority.test.ts` | `page.tsx` | `homeSurface` | +| `chat-session-url.test.ts` | `page.tsx` | `homeSurface` | +| `chat-session-write.test.ts` | `page.tsx` | `homeSurface`(`fetch("/api/sessions"` 在 `home-cloud-sync.ts`) | +| `composer-isolation-contract.test.ts` | `page.tsx` | `homeSurface`(`pendingConsultationStorageKey` 在 `home-types.ts`) | +| `consultation-entrypoint.test.ts` | `page.tsx` | `homeSurface`(starter 卡在 `starter-home.tsx`) | +| `consultation-recovery.test.ts` | `page.tsx` | `homeSurface` | +| `daily-starlanguage.test.ts` | `page.tsx` | `homeSurface` | +| `membership-page.test.ts`(home 段) | `page.tsx` | `homeSurface`(`LoginRedirectError` 在 `home-cloud-sync.ts`) | +| `onboarding-presentation.test.ts` | `page.tsx` | `homeSurface` | +| `rectification-agentic-entry.test.ts` | `page.tsx` | `homeSurface` | +| `settings-mvp-contract.test.ts` `renderProfile` | `page.tsx` 切片 | `homeSurface`(切片标记仍在 `page.tsx`) | +| `settings-mvp-contract.test.ts` 星盘库表单 | `page.tsx` 的 `renderChartLibrary`→`renderGeneral` 切片 | `chart-library-panel.tsx`(切片里只剩 ``,token 已搬走) | +| `starter-questions.test.ts` | `page.tsx` | `homeSurface` | +| `tests/test_supabase_user_data_contract.py` | `PAGE.read_text()` | `_home_surface()` | +| `tests/test_daily_and_rectification_entrypoints.py` | `PAGE` | `PAGE` + `starter-home.tsx` + `home-cloud-sync.ts` | +| `tests/test_birth_time_journey_contract.py` 网页引导条 | `page.tsx` | `page.tsx` + `home-profile.ts`(`reported_birth_time` / `active_birth_time`) | + +未改断言语义。锁聊天主链路、且 token 仍在 `page.tsx` 的测试继续只读 `page.tsx`(如 `site-style-isolation-contract`、composer/sidebar 壳、`rectification-lazy-loading`)。 + +## 验证 + +- `./node_modules/.bin/tsc --noEmit`:通过。 +- `npm test`:2427 条。并发下 2 条 Docker 库测 flake(`admin-database` / `model-configuration-security`,`database migration failed` / `Connection terminated unexpectedly`)。`--test-concurrency=1` 重跑这 8 条:pass 8 fail 0。与产品搬家无关。skipped=0。 +- `./node_modules/.bin/next build --webpack`:`┌ ○ /`。本树 `node_modules` 仍是指向 `session-url-20260901` 的符号链接,默认 Turbopack 会拒。gzip 口径:预渲染 `index.html` 引用的 `/_next/static/**.js` 去重后 `gzipSync` level 9。基线用同 SHA 干净 worktree 同口径对照。 +- 行为抽查:无登录态,未在浏览器点星盘库增删改、合盘、starter 入口、每日星语。由合同测试覆盖。 + +## 明确未做 / 未修 + +- 聊天主链路第二批再拆。 +- popstate 回默认会话未走 `selectSession`(任务书决策 4)。 +- `tests/test_session_management_entrypoints.py::test_archiving_never_calls_the_delete_endpoint` 仍锁已删除的 `setArchivedSessionIds`(P2a 遗留,不在 `CORE_PYTEST_TARGETS`,本轮不顺手修)。 +- `tests/test_birth_time_journey_contract.py::test_web_onboarding_uses_the_deterministic_free_journey` 在 mastra 断言 `'entry_mode: entryMode'` 上仍红(与搬家无关、不在 quick gate);本轮只补了 `page.tsx` 侧 token 路径。 +- 未改 `.gitea/workflows/**`。未提升 `main`。 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index bb1121ec..be8183af 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,7 +4,7 @@ import "@/app/site-styles"; import Link from "next/link"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { ArrowDown, ArrowUpRight, Sparkles } from "lucide-react"; +import { ArrowDown, Sparkles } from "lucide-react"; import { InlineSpinner } from "@/components/inline-spinner"; import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; @@ -28,8 +28,6 @@ import { type RectificationEntrySummary, } from "@/lib/rectification-entry"; import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; -import { ChatMessageContent } from "@/components/chat-message-content"; -import { AgentAvatar } from "@/components/chat-message-row"; import { toggleChatMessageFeedback, type ChatMessageFeedback, @@ -37,20 +35,19 @@ import { import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript"; import { ModelSelector } from "@/components/model-selector"; import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; -import { BirthPlacePicker } from "@/components/birth-place-picker"; import { ChatComposer } from "@/components/chat-composer"; import { ComposerCharacterRemaining } from "@/components/composer-character-remaining"; -import { CharacterRemaining } from "@/components/character-remaining"; -import { characterRemainingVisible } from "@/lib/character-remaining"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; -import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; -import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply"; +import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply"; +import { BirthLocationFields } from "@/components/birth-location-fields"; +import { OnboardingChatMessage } from "@/components/onboarding-chat-message"; +import { ChartLibraryPanel } from "@/components/chart-library-panel"; +import { StarterHome } from "@/components/starter-home"; import { beamAvatarPalettes, beamAvatarSchema, - type BeamAvatar, type BeamAvatarPatch, } from "@/lib/beam-avatar"; import { @@ -64,15 +61,9 @@ import { applyPersistedBirthTime, assistantIntentCopy, birthTimePersistenceValues, - declaredBirthInputChanged, - describeBirthTimeDraft, hydrateDeclaredWindowDraft, - isDeclaredBirthProfileComplete, isBirthTimeDraftReady, birthTimeDraftReadyHint, - normalizePersistedBirthDate, - type BirthTimeDraft, - type BirthTimeSource, } from "@/lib/birth-time-intake-model"; import { birthTimeConsultationOptionsCopy, @@ -97,15 +88,14 @@ import { isGuidedBirthTimePreview, previewRectificationJourney, } from "@/lib/birth-time-guided-preview"; -import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; -import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry"; +import { generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; import { keepFocusWithin } from "@/lib/focus-trap"; import { BALANCE_SYNC_KEY, BALANCE_CHANGED_EVENT, membershipHref, } from "@/lib/membership"; -import { nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view"; +import { nextActivityView, activityCompletedTrail, type AgentActivityView } from "@/lib/chat-message-view"; import { chartLibraryFromCloudOthers, chartLibraryOnCloudFailure, @@ -123,7 +113,6 @@ import { } from "@/lib/consultation-agent-events"; import { applyThinkingSectionProgress, - parsePublicThinkingSections, upsertThinkingSection, type PublicThinkingSection, } from "@/lib/consultation-thinking-plan"; @@ -140,7 +129,6 @@ import { SESSION_MISSING_NOTICE, clearLoginSessionReturn, parseSessionUrlQuery, - persistLoginSessionReturn, readLoginSessionReturn, resolveBootstrapSessionSelection, writeSessionUrl, @@ -156,7 +144,6 @@ import { onboardingRequestIdentity, requestOnboardingWithRecovery, } from "@/lib/onboarding-client"; -import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey, @@ -167,11 +154,88 @@ import { persistSessionModelSelection, } from "@/lib/session-model-persistence"; import { - parsePublicModelCatalog, - resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; import { selfHostedOtpActions } from "@/modules/identity/client"; +import { + accountDialogClasses, + accountDialogTitles, + dailyStarlanguageRetryDelayMs, + emptyProfile, + pendingConsultationStorageKey, + presetOnboardingMessage, + previewModelCatalog, + themes, + timestamp, + type Account, + type AccountDialog, + type ChartLibraryRecord, + type ChartRelationship, + type ChatSession, + type ConsultationStatus, + type DailyStarlanguageState, + type Message, + type OnboardingStep, + type PendingConsultation, + type Profile, + type ReplyOutcome, + type RequestError, + type StoredPendingConsultation, + type StreamingReply, + type SynastryRelationshipType, + type SynastryReportCard, + type Theme, +} from "@/lib/home-types"; +import { + birthProfileDeclarationChanged, + birthQuestion, + buildSelfChartRecord, + buildSynastryQuestion, + chartSnapshotForSession, + completedOnboardingMessage, + completedOnboardingTranscript, + formatBirthMoment, + isProfileComplete, + missingProfileStep, + placeQuestion, + readProfile, + selectedBirthPlace, + sessionChartLabel, + sessionSidebarTitle, + upsertSelfChart, +} from "@/lib/home-profile"; +import { + activeChartStorageKey, + applyLegacySessionControls, + CancellationResponseError, + ConsultationResponseError, + ConsultationStatusError, + createSession, + discardLegacyCloudMirrorKeys, + fetchAccount, + fetchActiveConsultationStatus, + fetchCloudChartLibrary, + fetchCloudSynastryHistory, + fetchConsultationStatus, + fetchDailyStarlanguage, + fetchModelCatalog, + fetchSessionDetail, + fetchSessions, + friendlyError, + LoginRedirectError, + mergeHydratedSession, + patchSessionModel, + payloadCode, + payloadMessage, + readSessions, + readStoredDailyStarlanguage, + readStoredPendingConsultation, + redirectToLogin, + saveCloudChartProfile, + saveCloudSynastryReport, + waitForUndoWindow, + writeStoredDailyStarlanguage, +} from "@/lib/home-cloud-sync"; const BirthTimeRectification = dynamic( () => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification), @@ -181,1117 +245,6 @@ const BirthTimeRectification = dynamic( }, ); -type Theme = ReplyTheme; -type Message = ChatMessage; -type Profile = BirthTimeDraft & { - name: string; - countryCode: string; - provinceCode: string; - cityCode: string; - districtCode: string; - birthPlaceLabel: string; - birthPlaceType: string; - birthPlaceProvider: string; - birthPlaceProviderId: string; - timezoneId: string; - timezoneSource: string; - latitude: number | null; - longitude: number | null; - timezoneOffset: number | null; - rectificationCaseId: string; - chartRelationship?: ChartRelationship; -}; -type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other"; -type ChartLibraryRecord = { - id: string; - role: "self" | "other"; - profile: Profile; - relationship: ChartRelationship; - updatedAt: number; -}; -type SynastryRelationshipType = "romance" | "business" | "family" | "general"; -type ChartLibraryApiRecord = { - id: string; - role: "self" | "other"; - profile: Profile; - updated_at?: string; -}; -type SynastryReportCard = { - id: string; - partnerName: string; - score?: number; - maxScore?: number; - assessment?: string; - headline?: string; - scoreBand?: string; - strengths?: string[]; - risks?: string[]; - nextEvidence?: string[]; - createdAt: number; -}; -type SynastryReportApiRecord = { - id: string; - partner_name?: string; - report?: SynastryReportCard; - created_at?: string; -}; -type ChatSessionType = "consultation" | "birth_time_rectification"; -type ChatProfileBinding = { - chartProfileId: string | null; - chartProfileName: string | null; - chartProfileRole: "self" | "other" | null; -}; -type ChatSession = { - id: string; - title: string; - theme: Theme; - modelId: string; - messages: Message[]; - updatedAt: number; - sessionType: ChatSessionType; - rectificationCaseId: string | null; - chartProfileId: string | null; - chartProfileName: string | null; - chartProfileRole: "self" | "other" | null; - pinned: boolean; - archivedAt: string | null; - messagesHydrated: boolean; -}; - -type RequestError = { sessionId: string; message: string }; -type ReplyOutcome = { - readonly sessionId: string; - readonly phase: Extract; - readonly replyOrdinal: number; -}; -type StreamingReply = { - sessionId: string; - text: string; - activity?: AgentActivityView; - thinkingText?: string; - thinkingSections?: PublicThinkingSection[]; - timeline?: readonly ConsultationTimelineRow[]; -}; -type BirthPlace = { - label: string; - lat: number; - lon: number; - tz: number | null; - timezoneId: string; -}; -type Account = { - user: { id: string; email: string | null }; - avatar: BeamAvatar | null; - credits: number; - isAdmin: boolean; - adminUrl: string | null; - rectificationPriceCredits: number; - activeSubscription: { - id: string; - status: string; - startsAt: string; - endsAt: string; - productCode: string; - productVersion: number; - product: { name?: string; productType?: string } | null; - entitlements: unknown; - } | null; - hasConfirmedBirthTime: boolean; - hasUsableBirthTime: boolean; - profile: unknown; -}; -type OnboardingStep = "name" | "birth" | "place" | "rectification"; -type AccountDialog = "profile" | "chart-library" | "general" | "logout"; -type DailyStarlanguageCard = { trend: string; action: string; caution: string }; -type DailyStarlanguageApiResponse = { - status?: "ok" | "unavailable" | "unauthenticated"; - card?: DailyStarlanguageCard; - source?: "engine_evidence" | "engine_evidence_cache" | "agent" | "agent_cache"; - claim_status?: "exploratory_unvalidated"; - boundary?: "not_deterministic_prediction"; -}; -type DailyStarlanguageState = - | { kind: "pending" } - | { kind: "ready"; card: DailyStarlanguageCard } - | { kind: "unavailable" }; -type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; -type ConsultationStatus = { - readonly requestId: string; - readonly sessionId: string; - readonly status: "reserved" | "completed" | "cancelled"; - readonly responseMessage?: unknown; - readonly updatedAt?: string; -}; -type PendingConsultation = { - readonly requestId: string; - readonly sessionId: string; - readonly question: string; - readonly entrypoint: ConsultationEntrypoint | null; - readonly theme: Theme; - readonly previousSession: ChatSession; - readonly optimisticSession: ChatSession; - readonly previousOnboardingState: boolean; - readonly controller: AbortController; - readonly cancelled: boolean; - readonly phase: "undo" | "streaming" | "recovering"; - readonly partialReply: string; -}; -const undoWindowMs = 2_500; -const pendingConsultationStorageKey = "jyotisha.pending-consultation"; -const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -type StoredPendingConsultation = { - readonly sessionId: string; - readonly requestId: string; - readonly question: string; - readonly theme: Theme | null; - readonly entrypoint: ConsultationEntrypoint | null; -}; - -function readStoredPendingConsultation( - raw: string | null, - sessionIds: Iterable, -): StoredPendingConsultation | null { - if (!raw) return null; - try { - const parsedPending = JSON.parse(raw) as Record; - if (typeof parsedPending.sessionId !== "string" - || typeof parsedPending.requestId !== "string" - || !uuidPattern.test(parsedPending.sessionId) - || !uuidPattern.test(parsedPending.requestId)) { - return null; - } - let sessionKnown = false; - for (const sessionId of sessionIds) { - if (sessionId === parsedPending.sessionId) { - sessionKnown = true; - break; - } - } - if (!sessionKnown) return null; - return { - sessionId: parsedPending.sessionId, - requestId: parsedPending.requestId, - question: typeof parsedPending.question === "string" ? parsedPending.question : "", - theme: normalizeConsultationDomain(parsedPending.theme), - entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null, - }; - } catch { - return null; - } -} -const china = chinaLocations.country; - -const themes = defaultGuidedJyotishTopics; - -const accountDialogTitles = { - profile: "个人资料", - "chart-library": "星盘资料", - general: "通用设置", - logout: "退出登录?", -} as const satisfies Record; - -const accountDialogClasses = { - profile: "profile-modal", - "chart-library": "chart-library-modal", - general: "general-modal", - logout: "logout-modal", -} as const satisfies Record; - -const previewModelCatalog = parsePublicModelCatalog({ - defaultModelId: "deepseek-pro", - models: [ - { id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "更适合复杂分析", creditCost: 1, isDefault: true }, - { id: "gpt-5-mini", label: "ChatGPT 5 Mini", description: "响应稳定、速度均衡", creditCost: 1, isDefault: false }, - ], -}); - -const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; - -const emptyProfile: Profile = { - name: "", - date: "", - time: "", - reportedTime: "", - birthTimeSource: "", - birthTimePeriod: "", - declaredWindowStart: "", - declaredWindowEnd: "", - birthTimeClue: "", - uncertaintyBeforeMinutes: null, - uncertaintyAfterMinutes: null, - birthTimeStatus: "", - rectificationCaseId: "", - countryCode: "CN", - provinceCode: "", - cityCode: "", - districtCode: "", - birthPlaceLabel: "", - birthPlaceType: "", - birthPlaceProvider: "", - birthPlaceProviderId: "", - timezoneId: "", - timezoneSource: "", - latitude: null, - longitude: null, - timezoneOffset: null, -}; - -function timestamp() { - return Date.now(); -} - -function createSession( - modelId: string, - sessionType: ChatSessionType = "consultation", - chartBinding: ChatProfileBinding = { chartProfileId: null, chartProfileName: null, chartProfileRole: null }, -): ChatSession { - return { - id: globalThis.crypto.randomUUID(), - title: sessionType === "birth_time_rectification" ? "生时校正" : "新对话", - theme: "general", - modelId, - messages: [], - updatedAt: timestamp(), - sessionType, - rectificationCaseId: null, - pinned: false, - archivedAt: null, - messagesHydrated: true, - ...chartBinding, - }; -} - -function findProvince(code: string) { - return china.provinces.find((province) => province.code === code); -} - -function findCity(province: ProvinceNode | undefined, code: string) { - return province?.cities.find((city) => city.code === code); -} - -function selectedBirthPlace(profile: Profile): BirthPlace | null { - if (profile.birthPlaceLabel - && Number.isFinite(profile.latitude) - && Number.isFinite(profile.longitude) - && profile.timezoneId.trim() - && (profile.timezoneOffset === null || Number.isFinite(profile.timezoneOffset))) { - return { - label: profile.birthPlaceLabel, - lat: profile.latitude as number, - lon: profile.longitude as number, - tz: profile.timezoneOffset, - timezoneId: profile.timezoneId, - }; - } - - const province = findProvince(profile.provinceCode); - const city = findCity(province, profile.cityCode); - if (!province || !city) return null; - - const district = city.districts.find((item) => item.code === profile.districtCode); - if (city.districts.length > 0 && !district) return null; - - const location = district ?? city; - const label = [china.name, province.name, city.name, district?.name] - .filter((name, index, names) => Boolean(name) && names.indexOf(name) === index) - .join(" · "); - - return { - label, - lat: location.center[1], - lon: location.center[0], - tz: china.timezone, - timezoneId: "Asia/Shanghai", - }; -} - -function activeChartStorageKey(accountId: string) { - return `jyotisha_active_chart:${accountId}`; -} -function dailyStarlanguageStorageKey(accountId: string) { - return `jyotisha_daily_starlanguage:${accountId}`; -} - -function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") { - return `jyotisha-session-controls:${accountId}:${kind}`; -} - -function discardLegacyCloudMirrorKeys(accountId: string) { - localStorage.removeItem(`jyotisha_chart_library:${accountId}`); - localStorage.removeItem(`jyotisha_synastry_history:${accountId}`); -} - -function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] { - try { - const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown; - return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; - } catch { - return []; - } -} - -function clearLegacySessionControlKeys(accountId: string) { - localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned")); - localStorage.removeItem(sessionControlsStorageKey(accountId, "archived")); -} - -function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] { - const pinnedKey = sessionControlsStorageKey(accountId, "pinned"); - const archivedKey = sessionControlsStorageKey(accountId, "archived"); - if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) { - return sessions; - } - const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned")); - const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived")); - const next = sessions.map((session) => ({ - ...session, - pinned: session.pinned || pinnedIds.has(session.id), - archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null), - })); - void Promise.allSettled(next.flatMap((session, index) => { - const previous = sessions[index]; - if (!previous) return []; - const patch: { pinned?: boolean; archived_at?: string | null } = {}; - if (session.pinned !== previous.pinned) patch.pinned = session.pinned; - if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt; - if (patch.pinned === undefined && patch.archived_at === undefined) return []; - return [writeChatSession(session.id, patch, "update")]; - })); - clearLegacySessionControlKeys(accountId); - return next; -} - -type StoredDailyStarlanguage = { - readonly day: string; - readonly fingerprint: string; - readonly card: DailyStarlanguageCard; -}; - -function readStoredDailyStarlanguage(accountId: string): StoredDailyStarlanguage | null { - try { - const parsed = JSON.parse(localStorage.getItem(dailyStarlanguageStorageKey(accountId)) || "null") as StoredDailyStarlanguage | null; - if (!parsed?.day || !parsed.fingerprint || !parsed.card?.trend || !parsed.card?.action) return null; - return parsed; - } catch { - return null; - } -} - -function writeStoredDailyStarlanguage(accountId: string, stored: StoredDailyStarlanguage) { - localStorage.setItem(dailyStarlanguageStorageKey(accountId), JSON.stringify(stored)); -} - -function profileReadyForLibrary(profile: Profile) { - return !missingProfileStep(profile); -} - -function buildSelfChartRecord(profile: Profile): ChartLibraryRecord { - return { id: "self", role: "self", profile: { ...profile, chartRelationship: "self" }, relationship: "self", updatedAt: timestamp() }; -} - -function chartSnapshotForSession( - chartId: string, - library: readonly ChartLibraryRecord[], - fallbackProfile: Profile, -): ChatProfileBinding { - const record = library.find((item) => item.id === chartId); - if (record) { - return { - chartProfileId: record.id, - chartProfileName: record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料"), - chartProfileRole: record.role, - }; - } - if (chartId === "self") { - return { chartProfileId: "self", chartProfileName: fallbackProfile.name.trim() || "我", chartProfileRole: "self" }; - } - return { chartProfileId: chartId || null, chartProfileName: "未命名资料", chartProfileRole: chartId ? "other" : null }; -} - -function sessionChartLabel(session: ChatSession, library: readonly ChartLibraryRecord[]) { - if (!session.chartProfileId) return "未关联资料"; - const current = session.chartProfileId === "self" || library.some((record) => record.id === session.chartProfileId); - const name = session.chartProfileName?.trim() || (session.chartProfileRole === "self" ? "我" : "未命名资料"); - return current ? name : `资料已删除 · ${name}`; -} - -function sessionSidebarTitle(session: ChatSession, library: readonly ChartLibraryRecord[]) { - return `${sessionChartLabel(session, library)} · ${session.title || "新对话"}`; -} - -function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) { - if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self"); - const others = library.filter((record) => record.role !== "self"); - return [buildSelfChartRecord(profile), ...others]; -} - -function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null { - if (!record.report || typeof record.report !== "object") return null; - return { - ...record.report, - id: record.id, - partnerName: record.partner_name || record.report.partnerName || "对方", - createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(), - }; -} - -async function fetchCloudSynastryHistory() { - const response = await fetch("/api/synastry-reports", { cache: "no-store" }); - if (!response.ok) throw new Error("cloud_synastry_history_unavailable"); - const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null; - return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[]; -} - -async function saveCloudSynastryReport(report: SynastryReportCard) { - const response = await fetch("/api/synastry-reports", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ partnerName: report.partnerName, report }), - }); - if (!response.ok) throw new Error("cloud_synastry_report_save_failed"); - const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null; - return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report; -} - -function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord { - const relationship = record.role === "self" ? "self" : record.profile.chartRelationship || "other"; - return { - id: record.role === "self" ? "self" : record.id, - role: record.role, - profile: { ...record.profile, chartRelationship: relationship }, - relationship, - updatedAt: Date.parse(record.updated_at || "") || timestamp(), - }; -} - -async function fetchCloudChartLibrary() { - const response = await fetch("/api/chart-profiles", { cache: "no-store" }); - if (!response.ok) throw new Error("cloud_chart_library_unavailable"); - const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null; - return (payload?.profiles || []).map(normalizeChartLibraryApiRecord); -} - -async function saveCloudChartProfile(record: ChartLibraryRecord) { - const response = await fetch("/api/chart-profiles", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - role: record.role, - profile: record.profile, - }), - }); - const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null; - if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_save_failed"); - return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; -} - -async function updateCloudChartProfile(record: ChartLibraryRecord) { - const response = await fetch(`/api/chart-profiles/${encodeURIComponent(record.id)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile: record.profile }), - }); - const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null; - if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_update_failed"); - return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; -} - -async function deleteCloudChartProfile(recordId: string) { - const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" }); - if (!response.ok) throw new Error("cloud_chart_profile_delete_failed"); -} - -function profilePlaceLabel(profile: Profile) { - return selectedBirthPlace(profile)?.label || "地点未完整"; -} - -function profileBirthTimeLabel(profile: Profile) { - if (profile.time) return profile.time; - if (profile.birthTimePeriod) return `${profile.birthTimePeriod}(时分待确认)`; - return "出生时间待补全"; -} - -function profileBirthTimeStatusLabel(profile: Profile) { - if (profile.birthTimeStatus === "confirmed") return "时间已确认"; - if (profile.birthTimeStatus === "candidate" || profile.birthTimeStatus === "accepted") return "时间为候选"; - if (profile.birthTimeStatus === "rectifying" || profile.birthTimeStatus === "assessing") return "正在评估时间"; - return "时间待确认"; -} - -function chartRelationshipLabel(relationship: ChartRelationship) { - return relationship === "partner" ? "伴侣" : relationship === "family" ? "家人" : relationship === "friend" ? "朋友" : relationship === "client" ? "客户" : relationship === "self" ? "本人" : "其他"; -} - -function formatChartUpdatedAt(updatedAt: number) { - if (!Number.isFinite(updatedAt) || updatedAt <= 0) return "刚刚更新"; - return `更新于 ${new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(new Date(updatedAt))}`; -} - -function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) { - const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋"; - const evidenceRequest = relationshipType === "business" - ? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。" - : relationshipType === "romance" - ? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。" - : "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。"; - return [ - `请用印度占星分析我和${partnerProfile.name || "对方"}的${relationshipLabel}关系。`, - `我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`, - `对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`, - evidenceRequest, - ].join("\n"); -} - -const dailyStarlanguageRetryDelayMs = 5_000; - -async function fetchDailyStarlanguage(signal: AbortSignal): Promise { - const response = await fetch("/api/daily-starlanguage", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - signal, - }); - if (!response.ok) return { kind: "unavailable" }; - const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null; - if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" }; - return { kind: "ready", card: payload.card }; -} - -function missingProfileStep(profile: Profile): OnboardingStep | null { - if (!profile.name.trim()) return "name"; - if (!isDeclaredBirthProfileComplete(profile)) return "birth"; - if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place"; - return null; -} - -function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null { - if (!profile.name.trim()) return "name"; - if (!isBirthTimeDraftReady(profile)) return "birth"; - if (!selectedBirthPlace(profile)) return "place"; - return null; -} - -function birthQuestion(name: string) { - return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`; -} - -function formatBirthMoment(profile: Profile) { - return describeBirthTimeDraft(profile); -} - -function placeQuestion(profile: Profile) { - return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`; -} - -function completedOnboardingMessage(name: string) { - return `${name},我们可以开始了。你可以从下面三个方向选择,也可以直接告诉我现在最想问的事。`; -} - -function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] { - const name = profile.name.trim(); - const birthPlace = selectedBirthPlace(profile); - if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return []; - - return [ - { role: "assistant", text: presetOnboardingMessage }, - { role: "user", text: name }, - { role: "assistant", text: birthQuestion(name) }, - { role: "user", text: formatBirthMoment(profile) }, - { role: "assistant", text: placeQuestion(profile) }, - { role: "user", text: birthPlace.label }, - { role: "assistant", text: greeting || completedOnboardingMessage(name) }, - ]; -} - -function readProfile(value: unknown): Profile { - if (!value || typeof value !== "object") return emptyProfile; - const profile = value as Partial & { - birth_date?: unknown; - birth_time?: unknown; - reported_birth_time?: unknown; - active_birth_time?: unknown; - birth_time_source?: unknown; - birth_time_period?: unknown; - declared_window_start?: unknown; - declared_window_end?: unknown; - birth_time_clue?: unknown; - uncertainty_before_minutes?: unknown; - uncertainty_after_minutes?: unknown; - birth_time_status?: unknown; - rectification_case_id?: unknown; - country_code?: unknown; - province_code?: unknown; - city_code?: unknown; - district_code?: unknown; - birth_place_label?: unknown; - birth_place_type?: unknown; - birth_place_provider?: unknown; - birth_place_provider_id?: unknown; - timezone_id?: unknown; - timezone_source?: unknown; - latitude?: unknown; - longitude?: unknown; - timezone_offset?: unknown; - chartRelationship?: unknown; - }; - const date = normalizePersistedBirthDate( - typeof profile.birth_date === "string" ? profile.birth_date : profile.date, - ); - const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time; - const time = typeof profile.active_birth_time === "string" - ? profile.active_birth_time.slice(0, 5) - : legacyTime; - const persistedReportedTime = typeof profile.reported_birth_time === "string" - ? profile.reported_birth_time.slice(0, 5) - : ""; - const knownSources: readonly BirthTimeSource[] = [ - "hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import", - ]; - const source = knownSources.find((item) => item === profile.birth_time_source) - ?? (time ? "legacy_import" : ""); - const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : ""); - const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; - const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? ""; - const windowStart = typeof profile.declared_window_start === "string" ? profile.declared_window_start.slice(0, 5) : ""; - const windowEnd = typeof profile.declared_window_end === "string" ? profile.declared_window_end.slice(0, 5) : ""; - const declaredWindow = hydrateDeclaredWindowDraft({ - period, - start: windowStart, - end: windowEnd, - }); - const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const; - const status = knownStatuses.find((item) => item === profile.birth_time_status) - ?? (time ? "confirmed" : ""); - const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode; - const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode; - const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode; - const countryCode = typeof profile.country_code === "string" ? profile.country_code : profile.countryCode; - const birthPlaceLabel = typeof profile.birth_place_label === "string" ? profile.birth_place_label : profile.birthPlaceLabel; - const birthPlaceType = typeof profile.birth_place_type === "string" ? profile.birth_place_type : profile.birthPlaceType; - const birthPlaceProvider = typeof profile.birth_place_provider === "string" ? profile.birth_place_provider : profile.birthPlaceProvider; - const birthPlaceProviderId = typeof profile.birth_place_provider_id === "string" ? profile.birth_place_provider_id : profile.birthPlaceProviderId; - const timezoneId = typeof profile.timezone_id === "string" ? profile.timezone_id : profile.timezoneId; - const timezoneSource = typeof profile.timezone_source === "string" ? profile.timezone_source : profile.timezoneSource; - const latitude = typeof profile.latitude === "number" && Number.isFinite(profile.latitude) ? profile.latitude : null; - const longitude = typeof profile.longitude === "number" && Number.isFinite(profile.longitude) ? profile.longitude : null; - const timezoneOffset = typeof profile.timezone_offset === "number" && Number.isFinite(profile.timezone_offset) - ? profile.timezone_offset - : typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset) - ? profile.timezoneOffset - : null; - const chartRelationships: readonly ChartRelationship[] = ["self", "partner", "family", "friend", "client", "other"]; - const chartRelationship = chartRelationships.find((item) => item === profile.chartRelationship); - - return { - name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "", - date: typeof date === "string" ? date : "", - time: typeof time === "string" ? time : "", - reportedTime: typeof reportedTime === "string" ? reportedTime : "", - birthTimeSource: source, - birthTimePeriod: declaredWindow.birthTimePeriod, - declaredWindowStart: declaredWindow.declaredWindowStart, - declaredWindowEnd: declaredWindow.declaredWindowEnd, - birthTimeClue: "", - uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null, - uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null, - birthTimeStatus: status, - rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "", - countryCode: typeof countryCode === "string" && countryCode ? countryCode : "CN", - provinceCode: typeof provinceCode === "string" ? provinceCode : "", - cityCode: typeof cityCode === "string" ? cityCode : "", - districtCode: typeof districtCode === "string" ? districtCode : "", - birthPlaceLabel: typeof birthPlaceLabel === "string" ? birthPlaceLabel : "", - birthPlaceType: typeof birthPlaceType === "string" ? birthPlaceType : "", - birthPlaceProvider: typeof birthPlaceProvider === "string" ? birthPlaceProvider : "", - birthPlaceProviderId: typeof birthPlaceProviderId === "string" ? birthPlaceProviderId : "", - timezoneId: typeof timezoneId === "string" ? timezoneId : "", - timezoneSource: typeof timezoneSource === "string" ? timezoneSource : "", - latitude, - longitude, - timezoneOffset, - ...(chartRelationship ? { chartRelationship } : {}), - }; -} - -function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult { - if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] }; - const fallbackSessionIds: string[] = []; - const sessions = value.flatMap((item): ChatSession[] => { - if (!item || typeof item !== "object") return []; - const session = item as Partial & { - model_id?: unknown; - rectification_case_id?: unknown; - chart_profile_id?: unknown; - chart_profile_name?: unknown; - chart_profile_role?: unknown; - session_type?: unknown; - updated_at?: unknown; - archived_at?: unknown; - }; - const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages"); - const messages: Message[] = Array.isArray(session.messages) - ? session.messages.flatMap((message) => { - if (!message || typeof message !== "object") return []; - const stored = message as Message; - if ((stored.role !== "user" && stored.role !== "assistant") || typeof stored.text !== "string") { - return []; - } - const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim() - ? stored.thinkingText.slice(0, 4000) - : undefined; - const thinkingSections = parsePublicThinkingSections(stored.thinkingSections); - return [{ - role: stored.role, - text: stored.text.slice(0, 12000), - ...(thinkingText ? { thinkingText } : {}), - ...(thinkingSections.length ? { thinkingSections } : {}), - ...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}), - ...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}), - ...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}), - }]; - }) - : []; - - if (typeof session.id !== "string") return []; - const savedModelId = session.model_id ?? session.modelId; - const selection = catalog - ? resolveSessionModelId(savedModelId, catalog) - : { modelId: typeof savedModelId === "string" ? savedModelId : "", fellBack: false }; - if (catalog && selection.fellBack) fallbackSessionIds.push(session.id); - return [{ - id: session.id, - title: typeof session.title === "string" ? session.title.slice(0, 48) : "新对话", - theme: normalizeConsultationDomain(session.theme) ?? "general", - modelId: selection.modelId, - messages, - sessionType: session.session_type === "birth_time_rectification" - ? "birth_time_rectification" - : "consultation", - rectificationCaseId: typeof session.rectification_case_id === "string" - ? session.rectification_case_id - : null, - chartProfileId: typeof session.chart_profile_id === "string" ? session.chart_profile_id : null, - chartProfileName: typeof session.chart_profile_name === "string" ? session.chart_profile_name : null, - chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other" - ? session.chart_profile_role - : null, - pinned: session.pinned === true, - archivedAt: typeof session.archived_at === "string" && session.archived_at - ? session.archived_at - : typeof session.archivedAt === "string" && session.archivedAt - ? session.archivedAt - : null, - updatedAt: typeof session.updatedAt === "number" - ? session.updatedAt - : typeof session.updated_at === "string" - ? Date.parse(session.updated_at) - : timestamp(), - messagesHydrated: messagesPresent, - }]; - }); - return { sessions, fallbackSessionIds }; -} - -function mergeHydratedSession(current: ChatSession[], detailed: ChatSession): ChatSession[] { - const next = { ...detailed, messagesHydrated: true }; - if (current.some((session) => session.id === next.id)) { - return current.map((session) => (session.id === next.id ? { ...session, ...next } : session)); - } - return [next, ...current]; -} - -function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { - return ( -
- 出生地点 - onChange({ - ...value, - countryCode: location?.countryCode || "CN", - provinceCode: location?.provinceCode || "", - cityCode: location?.cityCode || "", - districtCode: location?.districtCode || "", - birthPlaceLabel: location?.label || "", - birthPlaceType: location?.placeType || "", - birthPlaceProvider: location?.provider || "", - birthPlaceProviderId: location?.providerPlaceId || "", - timezoneId: location?.timezoneId || "", - timezoneSource: location ? "iana_historical" : "", - latitude: location?.latitude ?? null, - longitude: location?.longitude ?? null, - timezoneOffset: location?.timezoneOffset ?? null, - })} - /> -
- ); -} - -const birthLocationKeys = [ - "countryCode", - "provinceCode", - "cityCode", - "districtCode", - "birthPlaceLabel", - "birthPlaceProviderId", - "timezoneId", - "latitude", - "longitude", - "timezoneOffset", -] as const; - -function birthProfileDeclarationChanged(current: Profile, next: Profile) { - return declaredBirthInputChanged(current, next) - || birthLocationKeys.some((key) => current[key] !== next[key]); -} - -function invalidateCandidateAfterLocationChange(current: Profile, next: Profile): Profile { - const locationChanged = birthLocationKeys.some((key) => current[key] !== next[key]); - if (!locationChanged - || current.birthTimeStatus === "confirmed" - || (current.birthTimeStatus !== "candidate" && !current.time)) return next; - return { ...next, time: "", birthTimeStatus: "reported" }; -} - -function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) { - const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`; - return ( - <> - - onChange(applyBirthTimeDraftPatch(value, patch))} /> - onChange(invalidateCandidateAfterLocationChange(value, next))} - /> - - ); -} - -function OnboardingChatMessage({ role, text, streaming = false, length = text.length, phraseSafe = false }: { role: Message["role"]; text: string; streaming?: boolean; length?: number; phraseSafe?: boolean }) { - const visibleText = streaming ? text.slice(0, length) : text; - const protectedVisibleText = protectOnboardingPhrases(visibleText); - return ( -
- {role === "assistant" && } -
-
- {role === "assistant" ? ( - streaming ? ( - <> -
= text.length ? "is-complete" : ""}`} aria-hidden="true">
- {length >= text.length ? text : ""} - - ) : - ) :

{protectedVisibleText}

} -
-
-
- ); -} - -function isProfileComplete(profile: Profile) { - return missingProfileStep(profile) === null; -} - -function friendlyError(message: string) { - return ( - message.includes("数据库配置缺失") - || (message.includes("Supabase") && (message.includes("配置") || message.includes("environment") || message.includes("URL"))) - ) - ? "数据库尚未配置" - : message; -} - -function payloadMessage(payload: unknown, fallback: string) { - if (!payload || typeof payload !== "object") return fallback; - const data = payload as Record; - const message = [data.recovery, data.message, data.error].find((value) => typeof value === "string") as string | undefined; - return friendlyError(message || fallback); -} - -function payloadCode(payload: unknown): string | undefined { - if (!payload || typeof payload !== "object") return undefined; - const code = (payload as { code?: unknown }).code; - return typeof code === "string" ? code : undefined; -} - -class CancellationResponseError extends Error { - readonly status: number; - - constructor(status: number, message: string) { - super(message); - this.name = "CancellationResponseError"; - this.status = status; - } -} - -class ConsultationResponseError extends Error { - readonly status: number; - readonly code?: string; - - constructor(status: number, message: string, code?: string) { - super(message); - this.name = "ConsultationResponseError"; - this.status = status; - this.code = code; - } -} - -class ConsultationStatusError extends Error { - readonly status: number; - - constructor(status: number, message: string) { - super(message); - this.name = "ConsultationStatusError"; - this.status = status; - } -} - -class LoginRedirectError extends Error { - constructor() { - super("Redirecting to login"); - this.name = "LoginRedirectError"; - } -} - -function redirectToLogin(): never { - persistLoginSessionReturn(); - window.location.replace("/login"); - throw new LoginRedirectError(); -} - -function waitForUndoWindow(signal: AbortSignal) { - return new Promise((resolve) => { - const finish = () => { - window.clearTimeout(timer); - signal.removeEventListener("abort", finish); - resolve(); - }; - const timer = window.setTimeout(finish, undoWindowMs); - signal.addEventListener("abort", finish, { once: true }); - }); -} - -async function fetchAccount(signal?: AbortSignal): Promise { - const response = await fetch("/api/account", { signal, cache: "no-store" }); - if (response.status === 401) redirectToLogin(); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息")); - return payload as Account; -} - -async function fetchModelCatalog(signal?: AbortSignal) { - const response = await fetch("/api/models", { signal, cache: "no-store" }); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型")); - return parsePublicModelCatalog(payload); -} - -async function fetchSessions(signal?: AbortSignal): Promise { - const response = await fetch("/api/sessions", { signal, cache: "no-store" }); - if (response.status === 401) redirectToLogin(); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); - return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; -} - -async function fetchSessionDetail( - sessionId: string, - catalog: PublicLanguageModelCatalog | null, - signal?: AbortSignal, -): Promise { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { - signal, - cache: "no-store", - }); - if (response.status === 401) redirectToLogin(); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); - const sessionValue = payload && typeof payload === "object" - ? (payload as { session?: unknown }).session - : null; - return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null; -} - -function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus { - if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效"); - const status = payload as Partial; - if (typeof status.requestId !== "string" - || typeof status.sessionId !== "string" - || (requestId && status.requestId !== requestId) - || (status.status !== "reserved" && status.status !== "completed" && status.status !== "cancelled")) { - throw new Error("后台回答状态无效"); - } - return status as ConsultationStatus; -} - -async function fetchConsultationStatus(sessionId: string, requestId: string, signal?: AbortSignal): Promise { - const response = await fetch(`/api/consult/status?sessionId=${encodeURIComponent(sessionId)}&requestId=${encodeURIComponent(requestId)}`, { - signal, - cache: "no-store", - }); - const payload: unknown = await response.json().catch(() => null); - if (!response.ok) { - throw new ConsultationStatusError( - response.status, - payloadMessage(payload, "暂时无法恢复后台回答"), - ); - } - const status = parseConsultationStatus(payload, requestId); - if (status.sessionId !== sessionId) throw new Error("后台回答状态无效"); - return status; -} - -async function fetchActiveConsultationStatus(signal?: AbortSignal): Promise { - const response = await fetch("/api/consult/status", { signal, cache: "no-store" }); - const payload: unknown = await response.json().catch(() => null); - if (response.status === 404) return null; - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法恢复后台回答")); - const status = parseConsultationStatus(payload); - if (status.status !== "reserved") throw new Error("后台回答状态无效"); - return status; -} - -async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ model_id: modelId }), - signal, - }); - if (response.status === 401) { - window.location.assign("/login"); - throw new Error("请先登录"); - } - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。")); -} - export default function Home() { const router = useRouter(); const [profile, setProfile] = useState(emptyProfile); @@ -2715,82 +1668,7 @@ export default function Home() { return savedProfile; } - async function saveOtherChart(event: FormEvent) { - event.preventDefault(); - const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship }; - - if (missingOtherProfileStep(nextProfile)) { - setAccountError("请补全其他星盘的称呼、出生时间和出生地点。"); - return; - } - if (!accountId) return; - const record: ChartLibraryRecord = { - id: editingChartId || globalThis.crypto.randomUUID(), - role: "other", - profile: nextProfile, - relationship: otherChartRelationship, - updatedAt: timestamp(), - }; - try { - const saved = editingChartId - ? await updateCloudChartProfile(record) - : await saveCloudChartProfile(record); - const selfProfile = account ? readProfile(account.profile) : profile; - setChartLibrary((current) => { - const others = editingChartId - ? current.map((item) => item.id === saved.id ? saved : item) - : [...current, saved]; - return upsertSelfChart(others, selfProfile); - }); - setOtherProfileDraft(emptyProfile); - setOtherChartRelationship("other"); - setEditingChartId(null); - setAccountError(""); - setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); - } catch { - setProfileNotice("保存失败,请重试"); - setAccountError(""); - } - } - - function editOtherChart(record: ChartLibraryRecord) { - if (record.role !== "other") return; - setOtherProfileDraft(record.profile); - setOtherChartRelationship(record.relationship === "self" ? "other" : record.relationship); - setEditingChartId(record.id); - setAccountError(""); - setProfileNotice(""); - } - - async function deleteOtherChart(recordId: string) { - if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return; - try { - await deleteCloudChartProfile(recordId); - } catch { - setProfileNotice("删除失败,请重试"); - setAccountError(""); - return; - } - setChartLibrary((current) => { - const next = current.filter((record) => record.id !== recordId || record.role === "self"); - if (activeChartId === recordId) { - setActiveChartId("self"); - localStorage.setItem(activeChartStorageKey(accountId), "self"); - } - return next; - }); - setAccountError(""); - setProfileNotice("已从云端星盘库删除。"); - } - - function makeDefaultChart(record: ChartLibraryRecord) { - if (record.role !== "other" || profileSaving || !accountId) return; - setActiveChartId(record.id); - localStorage.setItem(activeChartStorageKey(accountId), record.id); - setProfile(record.profile); - setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。"); - } - + async function assessSavedBirthTime(nextProfile: Profile) { const result = process.env.NODE_ENV === "development" && uiPreview.current ? previewRectificationJourney @@ -4249,116 +3127,42 @@ export default function Home() { return ( <>

管理本人和其他人的出生资料。本人资料用于默认解盘,其他人资料可用于合盘或单独查看。

-
-
-
-
我的星盘当前账号的默认资料
- {chartLibrary.filter((record) => record.role === "self").length} -
- {chartLibrary.filter((record) => record.role === "self").map((record) => ( -
-
-
{record.profile.name || "未命名"}本人当前默认
- {record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} - {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} -
-
- -
-
- ))} - {editingSelfChart && ( -
-
编辑本人星盘这份资料会用于默认解盘与新对话。
- - {profileNotice &&

{profileNotice}

} -
- - -
- - )} - {chartLibrary.filter((record) => record.role === "self").length === 0 &&

请先在个人资料中补全你的出生资料。

} -
-
-
-
其他人的星盘亲友、伴侣或客户资料
- {chartLibrary.filter((record) => record.role === "other").length} -
- {chartLibrary.filter((record) => record.role === "other").length === 0 &&

还没有其他星盘,先添加一份资料。

} - {chartLibrary.filter((record) => record.role === "other").map((record) => ( -
-
-
{record.profile.name || "未命名"}{chartRelationshipLabel(record.relationship)}
- {record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} - {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} -
-
- - - - - -
-
- ))} -
- {synastryReportCard && ( -
-
- 合盘结果摘要 - {synastryReportCard.partnerName} - Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"} -
- {synastryReportCard.headline &&

{synastryReportCard.headline}

} -
- 查看证据 -
    - {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} - {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )} -
- 下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"} -
-
- )} - {synastryHistory.length > 0 && ( -
- 合盘历史 - {synastryHistory.slice(0, 5).map((item) => ( - - ))} -
- )} -
-
{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}用于合盘、亲友盘或客户盘。
- - -
- {editingChartId && } - -
- -
+ ); }, + renderGeneral() { return ; }, @@ -4542,95 +3346,30 @@ export default function Home() { ) : ( -
-
-
-

{starterGreeting.salutation}

-

{starterGreeting.question}

-
-
- -
-
-
-
-
-
- {rectificationError && !rectificationSurfaceOpen && ( -

- {rectificationErrorMessage} -

- )} - -
-
-

从一个主题开始

-

{natalMinuteAvailable - ? "选择一个你现在想解决的问题。" - : "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}

-
-
- {starterSuggestions.map((item) => { - const theme = starterThemes.find((candidate) => candidate.id === item.theme); - return ( - - ); - })} -
-
- {onboardingError &&

个性化问题暂时不可用,已显示安全的默认问题。

} -
+ ))} ) : sessionMessagesLoading ? ( diff --git a/frontend/src/components/birth-location-fields.tsx b/frontend/src/components/birth-location-fields.tsx new file mode 100644 index 00000000..b8c936c6 --- /dev/null +++ b/frontend/src/components/birth-location-fields.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { BirthPlacePicker } from "@/components/birth-place-picker"; +import type { Profile } from "@/lib/home-types"; + +export function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { + return ( +
+ 出生地点 + onChange({ + ...value, + countryCode: location?.countryCode || "CN", + provinceCode: location?.provinceCode || "", + cityCode: location?.cityCode || "", + districtCode: location?.districtCode || "", + birthPlaceLabel: location?.label || "", + birthPlaceType: location?.placeType || "", + birthPlaceProvider: location?.provider || "", + birthPlaceProviderId: location?.providerPlaceId || "", + timezoneId: location?.timezoneId || "", + timezoneSource: location ? "iana_historical" : "", + latitude: location?.latitude ?? null, + longitude: location?.longitude ?? null, + timezoneOffset: location?.timezoneOffset ?? null, + })} + /> +
+ ); +} diff --git a/frontend/src/components/chart-library-panel.tsx b/frontend/src/components/chart-library-panel.tsx new file mode 100644 index 00000000..1209c636 --- /dev/null +++ b/frontend/src/components/chart-library-panel.tsx @@ -0,0 +1,269 @@ +"use client"; + +import type { Dispatch, FormEvent, SetStateAction } from "react"; +import { ProfileFields } from "@/components/profile-fields"; +import { activeChartStorageKey, deleteCloudChartProfile, saveCloudChartProfile, updateCloudChartProfile } from "@/lib/home-cloud-sync"; +import { + chartRelationshipLabel, + formatChartUpdatedAt, + missingOtherProfileStep, + profileBirthTimeLabel, + profileBirthTimeStatusLabel, + profilePlaceLabel, + readProfile, + upsertSelfChart, +} from "@/lib/home-profile"; +import { emptyProfile, timestamp, type Account, type ChartLibraryRecord, type ChartRelationship, type Profile, type SynastryRelationshipType, type SynastryReportCard } from "@/lib/home-types"; + +export type ChartLibraryPanelProps = { + readonly account: Account | null; + readonly accountId: string | undefined; + readonly chartLibrary: ChartLibraryRecord[]; + readonly setChartLibrary: Dispatch>; + readonly profile: Profile; + readonly profileDraft: Profile; + readonly setProfileDraft: Dispatch>; + readonly setProfile: Dispatch>; + readonly profileSaving: boolean; + readonly profileNotice: string; + readonly setProfileNotice: Dispatch>; + readonly setAccountError: Dispatch>; + readonly editingSelfChart: boolean; + readonly setEditingSelfChart: Dispatch>; + readonly otherProfileDraft: Profile; + readonly setOtherProfileDraft: Dispatch>; + readonly otherChartRelationship: Exclude; + readonly setOtherChartRelationship: Dispatch>>; + readonly editingChartId: string | null; + readonly setEditingChartId: Dispatch>; + readonly synastryRelationshipType: SynastryRelationshipType; + readonly setSynastryRelationshipType: Dispatch>; + readonly synastryPendingId: string | null; + readonly synastryReportCard: SynastryReportCard | null; + readonly setSynastryReportCard: Dispatch>; + readonly synastryHistory: SynastryReportCard[]; + readonly activeChartId: string; + readonly setActiveChartId: Dispatch>; + readonly saveProfile: (event: FormEvent) => void; + readonly draftSynastryQuestionFromChart: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => void; +}; + +export function ChartLibraryPanel({ + account, + accountId, + chartLibrary, + setChartLibrary, + profile, + profileDraft, + setProfileDraft, + setProfile, + profileSaving, + profileNotice, + setProfileNotice, + setAccountError, + editingSelfChart, + setEditingSelfChart, + otherProfileDraft, + setOtherProfileDraft, + otherChartRelationship, + setOtherChartRelationship, + editingChartId, + setEditingChartId, + synastryRelationshipType, + setSynastryRelationshipType, + synastryPendingId, + synastryReportCard, + setSynastryReportCard, + synastryHistory, + activeChartId, + setActiveChartId, + saveProfile, + draftSynastryQuestionFromChart, +}: ChartLibraryPanelProps) { + async function saveOtherChart(event: FormEvent) { + event.preventDefault(); + const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim(), chartRelationship: otherChartRelationship }; + + if (missingOtherProfileStep(nextProfile)) { + setAccountError("请补全其他星盘的称呼、出生时间和出生地点。"); + return; + } + if (!accountId) return; + const record: ChartLibraryRecord = { + id: editingChartId || globalThis.crypto.randomUUID(), + role: "other", + profile: nextProfile, + relationship: otherChartRelationship, + updatedAt: timestamp(), + }; + try { + const saved = editingChartId + ? await updateCloudChartProfile(record) + : await saveCloudChartProfile(record); + const selfProfile = account ? readProfile(account.profile) : profile; + setChartLibrary((current) => { + const others = editingChartId + ? current.map((item) => item.id === saved.id ? saved : item) + : [...current, saved]; + return upsertSelfChart(others, selfProfile); + }); + setOtherProfileDraft(emptyProfile); + setOtherChartRelationship("other"); + setEditingChartId(null); + setAccountError(""); + setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); + } catch { + setProfileNotice("保存失败,请重试"); + setAccountError(""); + } + } + + function editOtherChart(record: ChartLibraryRecord) { + if (record.role !== "other") return; + setOtherProfileDraft(record.profile); + setOtherChartRelationship(record.relationship === "self" ? "other" : record.relationship); + setEditingChartId(record.id); + setAccountError(""); + setProfileNotice(""); + } + + async function deleteOtherChart(recordId: string) { + if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return; + try { + await deleteCloudChartProfile(recordId); + } catch { + setProfileNotice("删除失败,请重试"); + setAccountError(""); + return; + } + setChartLibrary((current) => { + const next = current.filter((record) => record.id !== recordId || record.role === "self"); + if (activeChartId === recordId) { + setActiveChartId("self"); + localStorage.setItem(activeChartStorageKey(accountId), "self"); + } + return next; + }); + setAccountError(""); + setProfileNotice("已从云端星盘库删除。"); + } + + function makeDefaultChart(record: ChartLibraryRecord) { + if (record.role !== "other" || profileSaving || !accountId) return; + setActiveChartId(record.id); + localStorage.setItem(activeChartStorageKey(accountId), record.id); + setProfile(record.profile); + setProfileNotice("已设为当前使用资料,账户本人的出生资料未被覆盖。"); + } + + return ( +
+
+
+
我的星盘当前账号的默认资料
+ {chartLibrary.filter((record) => record.role === "self").length} +
+ {chartLibrary.filter((record) => record.role === "self").map((record) => ( +
+
+
{record.profile.name || "未命名"}本人当前默认
+ {record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} + {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} +
+
+ +
+
+ ))} + {editingSelfChart && ( +
+
编辑本人星盘这份资料会用于默认解盘与新对话。
+ + {profileNotice &&

{profileNotice}

} +
+ + +
+ + )} + {chartLibrary.filter((record) => record.role === "self").length === 0 &&

请先在个人资料中补全你的出生资料。

} +
+
+
+
其他人的星盘亲友、伴侣或客户资料
+ {chartLibrary.filter((record) => record.role === "other").length} +
+ {chartLibrary.filter((record) => record.role === "other").length === 0 &&

还没有其他星盘,先添加一份资料。

} + {chartLibrary.filter((record) => record.role === "other").map((record) => ( +
+
+
{record.profile.name || "未命名"}{chartRelationshipLabel(record.relationship)}
+ {record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)} + {profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)} +
+
+ + + + + +
+
+ ))} +
+ {synastryReportCard && ( +
+
+ 合盘结果摘要 + {synastryReportCard.partnerName} + Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"} +
+ {synastryReportCard.headline &&

{synastryReportCard.headline}

} +
+ 查看证据 +
    + {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} + {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )} +
+ 下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"} +
+
+ )} + {synastryHistory.length > 0 && ( +
+ 合盘历史 + {synastryHistory.slice(0, 5).map((item) => ( + + ))} +
+ )} +
+
{editingChartId ? "编辑其他人的星盘" : "添加其他人的星盘"}用于合盘、亲友盘或客户盘。
+ + +
+ {editingChartId && } + +
+ +
+ + ); +} diff --git a/frontend/src/components/onboarding-chat-message.tsx b/frontend/src/components/onboarding-chat-message.tsx new file mode 100644 index 00000000..46066df1 --- /dev/null +++ b/frontend/src/components/onboarding-chat-message.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ChatMessageContent } from "@/components/chat-message-content"; +import { AgentAvatar } from "@/components/chat-message-row"; +import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; +import type { Message } from "@/lib/home-types"; + +export function OnboardingChatMessage({ role, text, streaming = false, length = text.length, phraseSafe = false }: { role: Message["role"]; text: string; streaming?: boolean; length?: number; phraseSafe?: boolean }) { + const visibleText = streaming ? text.slice(0, length) : text; + const protectedVisibleText = protectOnboardingPhrases(visibleText); + return ( +
+ {role === "assistant" && } +
+
+ {role === "assistant" ? ( + streaming ? ( + <> +
= text.length ? "is-complete" : ""}`} aria-hidden="true">
+ {length >= text.length ? text : ""} + + ) : + ) :

{protectedVisibleText}

} +
+
+
+ ); +} diff --git a/frontend/src/components/profile-fields.tsx b/frontend/src/components/profile-fields.tsx new file mode 100644 index 00000000..640d025a --- /dev/null +++ b/frontend/src/components/profile-fields.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; +import { BirthLocationFields } from "@/components/birth-location-fields"; +import { CharacterRemaining } from "@/components/character-remaining"; +import { characterRemainingVisible } from "@/lib/character-remaining"; +import { applyBirthTimeDraftPatch } from "@/lib/birth-time-intake-model"; +import { invalidateCandidateAfterLocationChange } from "@/lib/home-profile"; +import type { Profile } from "@/lib/home-types"; + +export function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) { + const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`; + return ( + <> + + onChange(applyBirthTimeDraftPatch(value, patch))} /> + onChange(invalidateCandidateAfterLocationChange(value, next))} + /> + + ); +} diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx new file mode 100644 index 00000000..39a29a1d --- /dev/null +++ b/frontend/src/components/starter-home.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { ArrowUpRight } from "lucide-react"; +import type { DailyStarlanguageState, Theme } from "@/lib/home-types"; +import type { RectificationEntrySummary } from "@/lib/rectification-entry"; + +export type StarterHomeTheme = { + readonly id: string; + readonly label: string; + readonly prompt: string; +}; + +export type StarterHomeSuggestion = { + readonly theme: Theme; + readonly text: string; +}; + +export type StarterHomeProps = { + readonly starterGreeting: { readonly salutation: string; readonly question: string }; + readonly dailyStarlanguage: DailyStarlanguageState; + readonly natalMinuteAvailable: boolean; + readonly dailyStarlanguageQuestion: string; + readonly dailyStarlanguageTrend: string; + readonly dailyStarlanguageAction: string; + readonly dailyStarlanguageBusy: boolean; + readonly productEntrypointsDisabled: boolean; + readonly startDailyStarlanguageConsultation: () => void; + readonly rectificationCardLabel: string; + readonly rectificationCardAction: string; + readonly rectificationLoading: boolean; + readonly rectificationMutationPending: boolean; + readonly openRectificationFromHomepage: () => void; + readonly rectificationEntrySummary: RectificationEntrySummary | null; + readonly rectificationError: string; + readonly rectificationSurfaceOpen: boolean; + readonly rectificationErrorMessage: string; + readonly starterThemes: readonly StarterHomeTheme[]; + readonly starterSuggestions: readonly StarterHomeSuggestion[]; + readonly startSuggestedConsultation: (text: string, theme: Theme) => void; + readonly onboardingError: string; +}; + +export function StarterHome({ + starterGreeting, + natalMinuteAvailable, + dailyStarlanguage, + dailyStarlanguageQuestion, + dailyStarlanguageTrend, + dailyStarlanguageAction, + dailyStarlanguageBusy, + productEntrypointsDisabled, + startDailyStarlanguageConsultation, + rectificationCardLabel, + rectificationCardAction, + rectificationLoading, + rectificationMutationPending, + openRectificationFromHomepage, + rectificationEntrySummary, + rectificationError, + rectificationSurfaceOpen, + rectificationErrorMessage, + starterThemes, + starterSuggestions, + startSuggestedConsultation, + onboardingError, +}: StarterHomeProps) { + return ( +
+
+
+

{starterGreeting.salutation}

+

{starterGreeting.question}

+
+
+ +
+
+
+
+
+
+ {rectificationError && !rectificationSurfaceOpen && ( +

+ {rectificationErrorMessage} +

+ )} + +
+
+

从一个主题开始

+

{natalMinuteAvailable + ? "选择一个你现在想解决的问题。" + : "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}

+
+
+ {starterSuggestions.map((item) => { + const theme = starterThemes.find((candidate) => candidate.id === item.theme); + return ( + + ); + })} +
+
+ {onboardingError &&

个性化问题暂时不可用,已显示安全的默认问题。

} +
+ + ); +} diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts new file mode 100644 index 00000000..2e86ac96 --- /dev/null +++ b/frontend/src/lib/home-cloud-sync.ts @@ -0,0 +1,499 @@ +import { writeChatSession } from "@/lib/chat-session-write-contract"; +import { persistLoginSessionReturn } from "@/lib/chat-session-url"; +import { parsePublicThinkingSections } from "@/lib/consultation-thinking-plan"; +import { + parsePublicModelCatalog, + resolveSessionModelId, + type PublicLanguageModelCatalog, +} from "@/lib/public-models"; +import { normalizeConsultationDomain } from "@/lib/consultation-domain-registry"; +import { + timestamp, + undoWindowMs, + uuidPattern, + type Account, + type ChartLibraryApiRecord, + type ChartLibraryRecord, + type ChatProfileBinding, + type ChatSession, + type ChatSessionType, + type ConsultationStatus, + type DailyStarlanguageApiResponse, + type DailyStarlanguageState, + type Message, + type SessionReadResult, + type StoredDailyStarlanguage, + type StoredPendingConsultation, + type SynastryReportApiRecord, + type SynastryReportCard, +} from "@/lib/home-types"; + +export function readStoredPendingConsultation( + raw: string | null, + sessionIds: Iterable, +): StoredPendingConsultation | null { + if (!raw) return null; + try { + const parsedPending = JSON.parse(raw) as Record; + if (typeof parsedPending.sessionId !== "string" + || typeof parsedPending.requestId !== "string" + || !uuidPattern.test(parsedPending.sessionId) + || !uuidPattern.test(parsedPending.requestId)) { + return null; + } + let sessionKnown = false; + for (const sessionId of sessionIds) { + if (sessionId === parsedPending.sessionId) { + sessionKnown = true; + break; + } + } + if (!sessionKnown) return null; + return { + sessionId: parsedPending.sessionId, + requestId: parsedPending.requestId, + question: typeof parsedPending.question === "string" ? parsedPending.question : "", + theme: normalizeConsultationDomain(parsedPending.theme), + entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null, + }; + } catch { + return null; + } +} + +export function createSession( + modelId: string, + sessionType: ChatSessionType = "consultation", + chartBinding: ChatProfileBinding = { chartProfileId: null, chartProfileName: null, chartProfileRole: null }, +): ChatSession { + return { + id: globalThis.crypto.randomUUID(), + title: sessionType === "birth_time_rectification" ? "生时校正" : "新对话", + theme: "general", + modelId, + messages: [], + updatedAt: timestamp(), + sessionType, + rectificationCaseId: null, + pinned: false, + archivedAt: null, + messagesHydrated: true, + ...chartBinding, + }; +} +export function activeChartStorageKey(accountId: string) { + return `jyotisha_active_chart:${accountId}`; +} +export function dailyStarlanguageStorageKey(accountId: string) { + return `jyotisha_daily_starlanguage:${accountId}`; +} + +export function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") { + return `jyotisha-session-controls:${accountId}:${kind}`; +} + +export function discardLegacyCloudMirrorKeys(accountId: string) { + localStorage.removeItem(`jyotisha_chart_library:${accountId}`); + localStorage.removeItem(`jyotisha_synastry_history:${accountId}`); +} + +export function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] { + try { + const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown; + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; + } catch { + return []; + } +} + +export function clearLegacySessionControlKeys(accountId: string) { + localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned")); + localStorage.removeItem(sessionControlsStorageKey(accountId, "archived")); +} + +export function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] { + const pinnedKey = sessionControlsStorageKey(accountId, "pinned"); + const archivedKey = sessionControlsStorageKey(accountId, "archived"); + if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) { + return sessions; + } + const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned")); + const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived")); + const next = sessions.map((session) => ({ + ...session, + pinned: session.pinned || pinnedIds.has(session.id), + archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null), + })); + void Promise.allSettled(next.flatMap((session, index) => { + const previous = sessions[index]; + if (!previous) return []; + const patch: { pinned?: boolean; archived_at?: string | null } = {}; + if (session.pinned !== previous.pinned) patch.pinned = session.pinned; + if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt; + if (patch.pinned === undefined && patch.archived_at === undefined) return []; + return [writeChatSession(session.id, patch, "update")]; + })); + clearLegacySessionControlKeys(accountId); + return next; +} + +export function readStoredDailyStarlanguage(accountId: string): StoredDailyStarlanguage | null { + try { + const parsed = JSON.parse(localStorage.getItem(dailyStarlanguageStorageKey(accountId)) || "null") as StoredDailyStarlanguage | null; + if (!parsed?.day || !parsed.fingerprint || !parsed.card?.trend || !parsed.card?.action) return null; + return parsed; + } catch { + return null; + } +} + +export function writeStoredDailyStarlanguage(accountId: string, stored: StoredDailyStarlanguage) { + localStorage.setItem(dailyStarlanguageStorageKey(accountId), JSON.stringify(stored)); +} +export function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null { + if (!record.report || typeof record.report !== "object") return null; + return { + ...record.report, + id: record.id, + partnerName: record.partner_name || record.report.partnerName || "对方", + createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(), + }; +} + +export async function fetchCloudSynastryHistory() { + const response = await fetch("/api/synastry-reports", { cache: "no-store" }); + if (!response.ok) throw new Error("cloud_synastry_history_unavailable"); + const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null; + return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[]; +} + +export async function saveCloudSynastryReport(report: SynastryReportCard) { + const response = await fetch("/api/synastry-reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ partnerName: report.partnerName, report }), + }); + if (!response.ok) throw new Error("cloud_synastry_report_save_failed"); + const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null; + return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report; +} + +export function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord { + const relationship = record.role === "self" ? "self" : record.profile.chartRelationship || "other"; + return { + id: record.role === "self" ? "self" : record.id, + role: record.role, + profile: { ...record.profile, chartRelationship: relationship }, + relationship, + updatedAt: Date.parse(record.updated_at || "") || timestamp(), + }; +} + +export async function fetchCloudChartLibrary() { + const response = await fetch("/api/chart-profiles", { cache: "no-store" }); + if (!response.ok) throw new Error("cloud_chart_library_unavailable"); + const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null; + return (payload?.profiles || []).map(normalizeChartLibraryApiRecord); +} + +export async function saveCloudChartProfile(record: ChartLibraryRecord) { + const response = await fetch("/api/chart-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role: record.role, + profile: record.profile, + }), + }); + const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_save_failed"); + return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; +} + +export async function updateCloudChartProfile(record: ChartLibraryRecord) { + const response = await fetch(`/api/chart-profiles/${encodeURIComponent(record.id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: record.profile }), + }); + const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_update_failed"); + return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; +} + +export async function deleteCloudChartProfile(recordId: string) { + const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" }); + if (!response.ok) throw new Error("cloud_chart_profile_delete_failed"); +} +export async function fetchDailyStarlanguage(signal: AbortSignal): Promise { + const response = await fetch("/api/daily-starlanguage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + signal, + }); + if (!response.ok) return { kind: "unavailable" }; + const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null; + if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" }; + return { kind: "ready", card: payload.card }; +} +export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult { + if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] }; + const fallbackSessionIds: string[] = []; + const sessions = value.flatMap((item): ChatSession[] => { + if (!item || typeof item !== "object") return []; + const session = item as Partial & { + model_id?: unknown; + rectification_case_id?: unknown; + chart_profile_id?: unknown; + chart_profile_name?: unknown; + chart_profile_role?: unknown; + session_type?: unknown; + updated_at?: unknown; + archived_at?: unknown; + }; + const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages"); + const messages: Message[] = Array.isArray(session.messages) + ? session.messages.flatMap((message) => { + if (!message || typeof message !== "object") return []; + const stored = message as Message; + if ((stored.role !== "user" && stored.role !== "assistant") || typeof stored.text !== "string") { + return []; + } + const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim() + ? stored.thinkingText.slice(0, 4000) + : undefined; + const thinkingSections = parsePublicThinkingSections(stored.thinkingSections); + return [{ + role: stored.role, + text: stored.text.slice(0, 12000), + ...(thinkingText ? { thinkingText } : {}), + ...(thinkingSections.length ? { thinkingSections } : {}), + ...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}), + ...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}), + ...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}), + }]; + }) + : []; + + if (typeof session.id !== "string") return []; + const savedModelId = session.model_id ?? session.modelId; + const selection = catalog + ? resolveSessionModelId(savedModelId, catalog) + : { modelId: typeof savedModelId === "string" ? savedModelId : "", fellBack: false }; + if (catalog && selection.fellBack) fallbackSessionIds.push(session.id); + return [{ + id: session.id, + title: typeof session.title === "string" ? session.title.slice(0, 48) : "新对话", + theme: normalizeConsultationDomain(session.theme) ?? "general", + modelId: selection.modelId, + messages, + sessionType: session.session_type === "birth_time_rectification" + ? "birth_time_rectification" + : "consultation", + rectificationCaseId: typeof session.rectification_case_id === "string" + ? session.rectification_case_id + : null, + chartProfileId: typeof session.chart_profile_id === "string" ? session.chart_profile_id : null, + chartProfileName: typeof session.chart_profile_name === "string" ? session.chart_profile_name : null, + chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other" + ? session.chart_profile_role + : null, + pinned: session.pinned === true, + archivedAt: typeof session.archived_at === "string" && session.archived_at + ? session.archived_at + : typeof session.archivedAt === "string" && session.archivedAt + ? session.archivedAt + : null, + updatedAt: typeof session.updatedAt === "number" + ? session.updatedAt + : typeof session.updated_at === "string" + ? Date.parse(session.updated_at) + : timestamp(), + messagesHydrated: messagesPresent, + }]; + }); + return { sessions, fallbackSessionIds }; +} + +export function mergeHydratedSession(current: ChatSession[], detailed: ChatSession): ChatSession[] { + const next = { ...detailed, messagesHydrated: true }; + if (current.some((session) => session.id === next.id)) { + return current.map((session) => (session.id === next.id ? { ...session, ...next } : session)); + } + return [next, ...current]; +} +export function friendlyError(message: string) { + return ( + message.includes("数据库配置缺失") + || (message.includes("Supabase") && (message.includes("配置") || message.includes("environment") || message.includes("URL"))) + ) + ? "数据库尚未配置" + : message; +} + +export function payloadMessage(payload: unknown, fallback: string) { + if (!payload || typeof payload !== "object") return fallback; + const data = payload as Record; + const message = [data.recovery, data.message, data.error].find((value) => typeof value === "string") as string | undefined; + return friendlyError(message || fallback); +} + +export function payloadCode(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return undefined; + const code = (payload as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +export class CancellationResponseError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "CancellationResponseError"; + this.status = status; + } +} + +export class ConsultationResponseError extends Error { + readonly status: number; + readonly code?: string; + + constructor(status: number, message: string, code?: string) { + super(message); + this.name = "ConsultationResponseError"; + this.status = status; + this.code = code; + } +} + +export class ConsultationStatusError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ConsultationStatusError"; + this.status = status; + } +} + +export class LoginRedirectError extends Error { + constructor() { + super("Redirecting to login"); + this.name = "LoginRedirectError"; + } +} + +export function redirectToLogin(): never { + persistLoginSessionReturn(); + window.location.replace("/login"); + throw new LoginRedirectError(); +} + +export function waitForUndoWindow(signal: AbortSignal) { + return new Promise((resolve) => { + const finish = () => { + window.clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = window.setTimeout(finish, undoWindowMs); + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function fetchAccount(signal?: AbortSignal): Promise { + const response = await fetch("/api/account", { signal, cache: "no-store" }); + if (response.status === 401) redirectToLogin(); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息")); + return payload as Account; +} + +export async function fetchModelCatalog(signal?: AbortSignal) { + const response = await fetch("/api/models", { signal, cache: "no-store" }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型")); + return parsePublicModelCatalog(payload); +} + +export async function fetchSessions(signal?: AbortSignal): Promise { + const response = await fetch("/api/sessions", { signal, cache: "no-store" }); + if (response.status === 401) redirectToLogin(); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); + return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; +} + +export async function fetchSessionDetail( + sessionId: string, + catalog: PublicLanguageModelCatalog | null, + signal?: AbortSignal, +): Promise { + const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + signal, + cache: "no-store", + }); + if (response.status === 401) redirectToLogin(); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); + const sessionValue = payload && typeof payload === "object" + ? (payload as { session?: unknown }).session + : null; + return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null; +} + +export function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus { + if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效"); + const status = payload as Partial; + if (typeof status.requestId !== "string" + || typeof status.sessionId !== "string" + || (requestId && status.requestId !== requestId) + || (status.status !== "reserved" && status.status !== "completed" && status.status !== "cancelled")) { + throw new Error("后台回答状态无效"); + } + return status as ConsultationStatus; +} + +export async function fetchConsultationStatus(sessionId: string, requestId: string, signal?: AbortSignal): Promise { + const response = await fetch(`/api/consult/status?sessionId=${encodeURIComponent(sessionId)}&requestId=${encodeURIComponent(requestId)}`, { + signal, + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) { + throw new ConsultationStatusError( + response.status, + payloadMessage(payload, "暂时无法恢复后台回答"), + ); + } + const status = parseConsultationStatus(payload, requestId); + if (status.sessionId !== sessionId) throw new Error("后台回答状态无效"); + return status; +} + +export async function fetchActiveConsultationStatus(signal?: AbortSignal): Promise { + const response = await fetch("/api/consult/status", { signal, cache: "no-store" }); + const payload: unknown = await response.json().catch(() => null); + if (response.status === 404) return null; + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法恢复后台回答")); + const status = parseConsultationStatus(payload); + if (status.status !== "reserved") throw new Error("后台回答状态无效"); + return status; +} + +export async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { + const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ model_id: modelId }), + signal, + }); + if (response.status === 401) { + window.location.assign("/login"); + throw new Error("请先登录"); + } + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。")); +} diff --git a/frontend/src/lib/home-profile.ts b/frontend/src/lib/home-profile.ts new file mode 100644 index 00000000..f3f7b7ea --- /dev/null +++ b/frontend/src/lib/home-profile.ts @@ -0,0 +1,323 @@ +import type { ProvinceNode } from "@/data/china-locations"; +import { + declaredBirthInputChanged, + describeBirthTimeDraft, + hydrateDeclaredWindowDraft, + isBirthTimeDraftReady, + isDeclaredBirthProfileComplete, + normalizePersistedBirthDate, + type BirthTimeSource, +} from "@/lib/birth-time-intake-model"; +import { + birthLocationKeys, + china, + emptyProfile, + presetOnboardingMessage, + timestamp, + type BirthPlace, + type ChartLibraryRecord, + type ChartRelationship, + type ChatProfileBinding, + type ChatSession, + type Message, + type OnboardingStep, + type Profile, + type SynastryRelationshipType, +} from "@/lib/home-types"; + +export function findProvince(code: string) { + return china.provinces.find((province) => province.code === code); +} + +export function findCity(province: ProvinceNode | undefined, code: string) { + return province?.cities.find((city) => city.code === code); +} + +export function selectedBirthPlace(profile: Profile): BirthPlace | null { + if (profile.birthPlaceLabel + && Number.isFinite(profile.latitude) + && Number.isFinite(profile.longitude) + && profile.timezoneId.trim() + && (profile.timezoneOffset === null || Number.isFinite(profile.timezoneOffset))) { + return { + label: profile.birthPlaceLabel, + lat: profile.latitude as number, + lon: profile.longitude as number, + tz: profile.timezoneOffset, + timezoneId: profile.timezoneId, + }; + } + + const province = findProvince(profile.provinceCode); + const city = findCity(province, profile.cityCode); + if (!province || !city) return null; + + const district = city.districts.find((item) => item.code === profile.districtCode); + if (city.districts.length > 0 && !district) return null; + + const location = district ?? city; + const label = [china.name, province.name, city.name, district?.name] + .filter((name, index, names) => Boolean(name) && names.indexOf(name) === index) + .join(" · "); + + return { + label, + lat: location.center[1], + lon: location.center[0], + tz: china.timezone, + timezoneId: "Asia/Shanghai", + }; +} +export function profileReadyForLibrary(profile: Profile) { + return !missingProfileStep(profile); +} + +export function buildSelfChartRecord(profile: Profile): ChartLibraryRecord { + return { id: "self", role: "self", profile: { ...profile, chartRelationship: "self" }, relationship: "self", updatedAt: timestamp() }; +} + +export function chartSnapshotForSession( + chartId: string, + library: readonly ChartLibraryRecord[], + fallbackProfile: Profile, +): ChatProfileBinding { + const record = library.find((item) => item.id === chartId); + if (record) { + return { + chartProfileId: record.id, + chartProfileName: record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料"), + chartProfileRole: record.role, + }; + } + if (chartId === "self") { + return { chartProfileId: "self", chartProfileName: fallbackProfile.name.trim() || "我", chartProfileRole: "self" }; + } + return { chartProfileId: chartId || null, chartProfileName: "未命名资料", chartProfileRole: chartId ? "other" : null }; +} + +export function sessionChartLabel(session: ChatSession, library: readonly ChartLibraryRecord[]) { + if (!session.chartProfileId) return "未关联资料"; + const current = session.chartProfileId === "self" || library.some((record) => record.id === session.chartProfileId); + const name = session.chartProfileName?.trim() || (session.chartProfileRole === "self" ? "我" : "未命名资料"); + return current ? name : `资料已删除 · ${name}`; +} + +export function sessionSidebarTitle(session: ChatSession, library: readonly ChartLibraryRecord[]) { + return `${sessionChartLabel(session, library)} · ${session.title || "新对话"}`; +} + +export function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) { + if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self"); + const others = library.filter((record) => record.role !== "self"); + return [buildSelfChartRecord(profile), ...others]; +} +export function profilePlaceLabel(profile: Profile) { + return selectedBirthPlace(profile)?.label || "地点未完整"; +} + +export function profileBirthTimeLabel(profile: Profile) { + if (profile.time) return profile.time; + if (profile.birthTimePeriod) return `${profile.birthTimePeriod}(时分待确认)`; + return "出生时间待补全"; +} + +export function profileBirthTimeStatusLabel(profile: Profile) { + if (profile.birthTimeStatus === "confirmed") return "时间已确认"; + if (profile.birthTimeStatus === "candidate" || profile.birthTimeStatus === "accepted") return "时间为候选"; + if (profile.birthTimeStatus === "rectifying" || profile.birthTimeStatus === "assessing") return "正在评估时间"; + return "时间待确认"; +} + +export function chartRelationshipLabel(relationship: ChartRelationship) { + return relationship === "partner" ? "伴侣" : relationship === "family" ? "家人" : relationship === "friend" ? "朋友" : relationship === "client" ? "客户" : relationship === "self" ? "本人" : "其他"; +} + +export function formatChartUpdatedAt(updatedAt: number) { + if (!Number.isFinite(updatedAt) || updatedAt <= 0) return "刚刚更新"; + return `更新于 ${new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(new Date(updatedAt))}`; +} + +export function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) { + const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋"; + const evidenceRequest = relationshipType === "business" + ? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。" + : relationshipType === "romance" + ? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。" + : "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。"; + return [ + `请用印度占星分析我和${partnerProfile.name || "对方"}的${relationshipLabel}关系。`, + `我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`, + `对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`, + evidenceRequest, + ].join("\n"); +} +export function missingProfileStep(profile: Profile): OnboardingStep | null { + if (!profile.name.trim()) return "name"; + if (!isDeclaredBirthProfileComplete(profile)) return "birth"; + if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place"; + return null; +} + +export function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null { + if (!profile.name.trim()) return "name"; + if (!isBirthTimeDraftReady(profile)) return "birth"; + if (!selectedBirthPlace(profile)) return "place"; + return null; +} + +export function birthQuestion(name: string) { + return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`; +} + +export function formatBirthMoment(profile: Profile) { + return describeBirthTimeDraft(profile); +} + +export function placeQuestion(profile: Profile) { + return `记下了:${formatBirthMoment(profile)}。最后一个问题,你出生在哪里?`; +} + +export function completedOnboardingMessage(name: string) { + return `${name},我们可以开始了。你可以从下面三个方向选择,也可以直接告诉我现在最想问的事。`; +} + +export function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] { + const name = profile.name.trim(); + const birthPlace = selectedBirthPlace(profile); + if (!name || !isDeclaredBirthProfileComplete(profile) || !birthPlace) return []; + + return [ + { role: "assistant", text: presetOnboardingMessage }, + { role: "user", text: name }, + { role: "assistant", text: birthQuestion(name) }, + { role: "user", text: formatBirthMoment(profile) }, + { role: "assistant", text: placeQuestion(profile) }, + { role: "user", text: birthPlace.label }, + { role: "assistant", text: greeting || completedOnboardingMessage(name) }, + ]; +} + +export function readProfile(value: unknown): Profile { + if (!value || typeof value !== "object") return emptyProfile; + const profile = value as Partial & { + birth_date?: unknown; + birth_time?: unknown; + reported_birth_time?: unknown; + active_birth_time?: unknown; + birth_time_source?: unknown; + birth_time_period?: unknown; + declared_window_start?: unknown; + declared_window_end?: unknown; + birth_time_clue?: unknown; + uncertainty_before_minutes?: unknown; + uncertainty_after_minutes?: unknown; + birth_time_status?: unknown; + rectification_case_id?: unknown; + country_code?: unknown; + province_code?: unknown; + city_code?: unknown; + district_code?: unknown; + birth_place_label?: unknown; + birth_place_type?: unknown; + birth_place_provider?: unknown; + birth_place_provider_id?: unknown; + timezone_id?: unknown; + timezone_source?: unknown; + latitude?: unknown; + longitude?: unknown; + timezone_offset?: unknown; + chartRelationship?: unknown; + }; + const date = normalizePersistedBirthDate( + typeof profile.birth_date === "string" ? profile.birth_date : profile.date, + ); + const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time; + const time = typeof profile.active_birth_time === "string" + ? profile.active_birth_time.slice(0, 5) + : legacyTime; + const persistedReportedTime = typeof profile.reported_birth_time === "string" + ? profile.reported_birth_time.slice(0, 5) + : ""; + const knownSources: readonly BirthTimeSource[] = [ + "hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import", + ]; + const source = knownSources.find((item) => item === profile.birth_time_source) + ?? (time ? "legacy_import" : ""); + const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : ""); + const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; + const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? ""; + const windowStart = typeof profile.declared_window_start === "string" ? profile.declared_window_start.slice(0, 5) : ""; + const windowEnd = typeof profile.declared_window_end === "string" ? profile.declared_window_end.slice(0, 5) : ""; + const declaredWindow = hydrateDeclaredWindowDraft({ + period, + start: windowStart, + end: windowEnd, + }); + const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const; + const status = knownStatuses.find((item) => item === profile.birth_time_status) + ?? (time ? "confirmed" : ""); + const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode; + const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode; + const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode; + const countryCode = typeof profile.country_code === "string" ? profile.country_code : profile.countryCode; + const birthPlaceLabel = typeof profile.birth_place_label === "string" ? profile.birth_place_label : profile.birthPlaceLabel; + const birthPlaceType = typeof profile.birth_place_type === "string" ? profile.birth_place_type : profile.birthPlaceType; + const birthPlaceProvider = typeof profile.birth_place_provider === "string" ? profile.birth_place_provider : profile.birthPlaceProvider; + const birthPlaceProviderId = typeof profile.birth_place_provider_id === "string" ? profile.birth_place_provider_id : profile.birthPlaceProviderId; + const timezoneId = typeof profile.timezone_id === "string" ? profile.timezone_id : profile.timezoneId; + const timezoneSource = typeof profile.timezone_source === "string" ? profile.timezone_source : profile.timezoneSource; + const latitude = typeof profile.latitude === "number" && Number.isFinite(profile.latitude) ? profile.latitude : null; + const longitude = typeof profile.longitude === "number" && Number.isFinite(profile.longitude) ? profile.longitude : null; + const timezoneOffset = typeof profile.timezone_offset === "number" && Number.isFinite(profile.timezone_offset) + ? profile.timezone_offset + : typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset) + ? profile.timezoneOffset + : null; + const chartRelationships: readonly ChartRelationship[] = ["self", "partner", "family", "friend", "client", "other"]; + const chartRelationship = chartRelationships.find((item) => item === profile.chartRelationship); + + return { + name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "", + date: typeof date === "string" ? date : "", + time: typeof time === "string" ? time : "", + reportedTime: typeof reportedTime === "string" ? reportedTime : "", + birthTimeSource: source, + birthTimePeriod: declaredWindow.birthTimePeriod, + declaredWindowStart: declaredWindow.declaredWindowStart, + declaredWindowEnd: declaredWindow.declaredWindowEnd, + birthTimeClue: "", + uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null, + uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null, + birthTimeStatus: status, + rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "", + countryCode: typeof countryCode === "string" && countryCode ? countryCode : "CN", + provinceCode: typeof provinceCode === "string" ? provinceCode : "", + cityCode: typeof cityCode === "string" ? cityCode : "", + districtCode: typeof districtCode === "string" ? districtCode : "", + birthPlaceLabel: typeof birthPlaceLabel === "string" ? birthPlaceLabel : "", + birthPlaceType: typeof birthPlaceType === "string" ? birthPlaceType : "", + birthPlaceProvider: typeof birthPlaceProvider === "string" ? birthPlaceProvider : "", + birthPlaceProviderId: typeof birthPlaceProviderId === "string" ? birthPlaceProviderId : "", + timezoneId: typeof timezoneId === "string" ? timezoneId : "", + timezoneSource: typeof timezoneSource === "string" ? timezoneSource : "", + latitude, + longitude, + timezoneOffset, + ...(chartRelationship ? { chartRelationship } : {}), + }; +} +export function birthProfileDeclarationChanged(current: Profile, next: Profile) { + return declaredBirthInputChanged(current, next) + || birthLocationKeys.some((key) => current[key] !== next[key]); +} + +export function invalidateCandidateAfterLocationChange(current: Profile, next: Profile): Profile { + const locationChanged = birthLocationKeys.some((key) => current[key] !== next[key]); + if (!locationChanged + || current.birthTimeStatus === "confirmed" + || (current.birthTimeStatus !== "candidate" && !current.time)) return next; + return { ...next, time: "", birthTimeStatus: "reported" }; +} +export function isProfileComplete(profile: Profile) { + return missingProfileStep(profile) === null; +} diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts new file mode 100644 index 00000000..2a15ae6d --- /dev/null +++ b/frontend/src/lib/home-types.ts @@ -0,0 +1,254 @@ +import type { BeamAvatar } from "@/lib/beam-avatar"; +import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; +import type { BirthTimeDraft } from "@/lib/birth-time-intake-model"; +import type { AgentActivityView, ChatMessage } from "@/lib/chat-message-view"; +import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline"; +import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan"; +import type { ChatReplyPhase } from "@/lib/chat-reply-announcement"; +import type { ReplyTheme } from "@/lib/agent-reply"; +import { parsePublicModelCatalog } from "@/lib/public-models"; +import { defaultGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; +import { chinaLocations } from "@/data/china-locations"; + +export type Theme = ReplyTheme; +export type Message = ChatMessage; +export type Profile = BirthTimeDraft & { + name: string; + countryCode: string; + provinceCode: string; + cityCode: string; + districtCode: string; + birthPlaceLabel: string; + birthPlaceType: string; + birthPlaceProvider: string; + birthPlaceProviderId: string; + timezoneId: string; + timezoneSource: string; + latitude: number | null; + longitude: number | null; + timezoneOffset: number | null; + rectificationCaseId: string; + chartRelationship?: ChartRelationship; +}; +export type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other"; +export type ChartLibraryRecord = { + id: string; + role: "self" | "other"; + profile: Profile; + relationship: ChartRelationship; + updatedAt: number; +}; +export type SynastryRelationshipType = "romance" | "business" | "family" | "general"; +export type ChartLibraryApiRecord = { + id: string; + role: "self" | "other"; + profile: Profile; + updated_at?: string; +}; +export type SynastryReportCard = { + id: string; + partnerName: string; + score?: number; + maxScore?: number; + assessment?: string; + headline?: string; + scoreBand?: string; + strengths?: string[]; + risks?: string[]; + nextEvidence?: string[]; + createdAt: number; +}; +export type SynastryReportApiRecord = { + id: string; + partner_name?: string; + report?: SynastryReportCard; + created_at?: string; +}; +export type ChatSessionType = "consultation" | "birth_time_rectification"; +export type ChatProfileBinding = { + chartProfileId: string | null; + chartProfileName: string | null; + chartProfileRole: "self" | "other" | null; +}; +export type ChatSession = { + id: string; + title: string; + theme: Theme; + modelId: string; + messages: Message[]; + updatedAt: number; + sessionType: ChatSessionType; + rectificationCaseId: string | null; + chartProfileId: string | null; + chartProfileName: string | null; + chartProfileRole: "self" | "other" | null; + pinned: boolean; + archivedAt: string | null; + messagesHydrated: boolean; +}; + +export type RequestError = { sessionId: string; message: string }; +export type ReplyOutcome = { + readonly sessionId: string; + readonly phase: Extract; + readonly replyOrdinal: number; +}; +export type StreamingReply = { + sessionId: string; + text: string; + activity?: AgentActivityView; + thinkingText?: string; + thinkingSections?: PublicThinkingSection[]; + timeline?: readonly ConsultationTimelineRow[]; +}; +export type BirthPlace = { + label: string; + lat: number; + lon: number; + tz: number | null; + timezoneId: string; +}; +export type Account = { + user: { id: string; email: string | null }; + avatar: BeamAvatar | null; + credits: number; + isAdmin: boolean; + adminUrl: string | null; + rectificationPriceCredits: number; + activeSubscription: { + id: string; + status: string; + startsAt: string; + endsAt: string; + productCode: string; + productVersion: number; + product: { name?: string; productType?: string } | null; + entitlements: unknown; + } | null; + hasConfirmedBirthTime: boolean; + hasUsableBirthTime: boolean; + profile: unknown; +}; +export type OnboardingStep = "name" | "birth" | "place" | "rectification"; +export type AccountDialog = "profile" | "chart-library" | "general" | "logout"; +export type DailyStarlanguageCard = { trend: string; action: string; caution: string }; +export type DailyStarlanguageApiResponse = { + status?: "ok" | "unavailable" | "unauthenticated"; + card?: DailyStarlanguageCard; + source?: "engine_evidence" | "engine_evidence_cache" | "agent" | "agent_cache"; + claim_status?: "exploratory_unvalidated"; + boundary?: "not_deterministic_prediction"; +}; +export type DailyStarlanguageState = + | { kind: "pending" } + | { kind: "ready"; card: DailyStarlanguageCard } + | { kind: "unavailable" }; +export type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; +export type ConsultationStatus = { + readonly requestId: string; + readonly sessionId: string; + readonly status: "reserved" | "completed" | "cancelled"; + readonly responseMessage?: unknown; + readonly updatedAt?: string; +}; +export type PendingConsultation = { + readonly requestId: string; + readonly sessionId: string; + readonly question: string; + readonly entrypoint: ConsultationEntrypoint | null; + readonly theme: Theme; + readonly previousSession: ChatSession; + readonly optimisticSession: ChatSession; + readonly previousOnboardingState: boolean; + readonly controller: AbortController; + readonly cancelled: boolean; + readonly phase: "undo" | "streaming" | "recovering"; + readonly partialReply: string; +}; +export const undoWindowMs = 2_500; +export const pendingConsultationStorageKey = "jyotisha.pending-consultation"; +export const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export type StoredPendingConsultation = { + readonly sessionId: string; + readonly requestId: string; + readonly question: string; + readonly theme: Theme | null; + readonly entrypoint: ConsultationEntrypoint | null; +}; +export const china = chinaLocations.country; + +export const themes = defaultGuidedJyotishTopics; + +export const accountDialogTitles = { + profile: "个人资料", + "chart-library": "星盘资料", + general: "通用设置", + logout: "退出登录?", +} as const satisfies Record; + +export const accountDialogClasses = { + profile: "profile-modal", + "chart-library": "chart-library-modal", + general: "general-modal", + logout: "logout-modal", +} as const satisfies Record; + +export const previewModelCatalog = parsePublicModelCatalog({ + defaultModelId: "deepseek-pro", + models: [ + { id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "更适合复杂分析", creditCost: 1, isDefault: true }, + { id: "gpt-5-mini", label: "ChatGPT 5 Mini", description: "响应稳定、速度均衡", creditCost: 1, isDefault: false }, + ], +}); + +export const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; + +export const emptyProfile: Profile = { + name: "", + date: "", + time: "", + reportedTime: "", + birthTimeSource: "", + birthTimePeriod: "", + declaredWindowStart: "", + declaredWindowEnd: "", + birthTimeClue: "", + uncertaintyBeforeMinutes: null, + uncertaintyAfterMinutes: null, + birthTimeStatus: "", + rectificationCaseId: "", + countryCode: "CN", + provinceCode: "", + cityCode: "", + districtCode: "", + birthPlaceLabel: "", + birthPlaceType: "", + birthPlaceProvider: "", + birthPlaceProviderId: "", + timezoneId: "", + timezoneSource: "", + latitude: null, + longitude: null, + timezoneOffset: null, +}; +export type StoredDailyStarlanguage = { + readonly day: string; + readonly fingerprint: string; + readonly card: DailyStarlanguageCard; +}; +export const dailyStarlanguageRetryDelayMs = 5_000; +export function timestamp() { + return Date.now(); +} +export const birthLocationKeys = [ + "countryCode", + "provinceCode", + "cityCode", + "districtCode", + "birthPlaceLabel", + "birthPlaceProviderId", + "timezoneId", + "latitude", + "longitude", + "timezoneOffset", +] as const; diff --git a/frontend/tests/birth-place-picker.test.ts b/frontend/tests/birth-place-picker.test.ts index 2c00acfb..a9bec3b3 100644 --- a/frontend/tests/birth-place-picker.test.ts +++ b/frontend/tests/birth-place-picker.test.ts @@ -9,9 +9,9 @@ import { findProvinceNode, resolveBirthPlaceNode, } from "../src/lib/china-birth-place.ts"; +import { homeSurface as pageSource } from "./home-surface.ts"; const pickerSource = readFileSync(new URL("../src/components/birth-place-picker.tsx", import.meta.url), "utf8"); -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); test("resolves a province, city and district to the district centre", () => { const resolved = resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "130402" }); diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index a145016c..42178d4e 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -15,6 +15,7 @@ import { resolveRectificationCardAction, unverifiedBirthTime, } from "../src/lib/birth-time-consultation-consent.ts"; +import { homeSurface } from "./home-surface.ts"; import type { BirthTimeDraft } from "../src/lib/birth-time-intake-model.ts"; const reportedExactTime = { @@ -98,7 +99,7 @@ test("the current reported minute wins over an old candidate and never falls bac assert.equal(unverifiedBirthTime(periodCandidate), null); assert.equal(unverifiedBirthTime(missingReportedCandidate), null); - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; assert.match(page, /persistedReportedTime \|\| \(source === "legacy_import" \? time : ""\)/); }); @@ -202,7 +203,7 @@ test("account refresh identities reject an older response after a newer case req }); test("unverified birth time no longer emits a modal or toast gate", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; assert.doesNotMatch(page, / { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/); @@ -235,7 +236,7 @@ test("homepage and profile result copy use the source-aware consultation options }); test("onboarding form steps hide the dead composer and keep source choices in a single column", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); assert.match(page, /const onboardingFormActive = !profileComplete && onboardingStep !== "name"/); diff --git a/frontend/tests/character-remaining-contract.test.ts b/frontend/tests/character-remaining-contract.test.ts index f2e910fa..eb7c4412 100644 --- a/frontend/tests/character-remaining-contract.test.ts +++ b/frontend/tests/character-remaining-contract.test.ts @@ -8,10 +8,10 @@ import { characterRemainingVisible, remainingThreshold, } from "../src/lib/character-remaining.ts"; +import { homeSurface as page } from "./home-surface.ts"; const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"); const remainingSource = readFileSync(new URL("../src/components/character-remaining.tsx", import.meta.url), "utf8"); -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8"); const choice = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index 0e360053..69e6dc9d 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; - -const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as source } from "./home-surface.ts"; const route = readFileSync(new URL("../src/app/api/chart-profiles/route.ts", import.meta.url), "utf8"); test("other chart saves do not require the owner's rectification state", () => { diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 5a965eb9..b20a5bf1 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -8,7 +8,7 @@ import { type ChatReplyPhase, } from "../src/lib/chat-reply-announcement.ts"; -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as pageSource } from "./home-surface.ts"; const membershipSource = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8"); const announcementSource = readFileSync(new URL("../src/lib/chat-reply-announcement.ts", import.meta.url), "utf8"); diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index ba83130a..a509338e 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as page } from "./home-surface.ts"; const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index b9961442..c12747f9 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -14,7 +14,7 @@ import { writeSessionUrl, } from "../src/lib/chat-session-url.ts"; -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as page } from "./home-surface.ts"; 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"; diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index f06d99a4..2ce56952 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; +import { homeSurface } from "./home-surface.ts"; const sessionId = "11111111-1111-4111-8111-111111111111"; const values = { @@ -178,7 +179,7 @@ test("session writes reject oversized transcripts before they reach storage", () }); test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); const observability = readFileSync(new URL("../src/lib/chat-session-observability.ts", import.meta.url), "utf8"); @@ -217,7 +218,7 @@ test("session API owns create and update while answer UI keeps sync failures out test("self-hosted staging bootstrap reads profile and sessions through same-origin APIs", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); assert.doesNotMatch(page, /createBrowserSupabaseClient/); diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 9dab33a0..6225ec86 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as pageSource } from "./home-surface.ts"; const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"); const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8"); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 70e90589..35937d6e 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -9,6 +9,7 @@ import { resolveConsultationQuestion, shouldLoadGeneralDailyPanchanga, } from "../src/lib/consultation-entrypoint.ts"; +import { homeSurface } from "./home-surface.ts"; test("plain consultation questions remain user-authored", () => { // Given: an ordinary question without a product entrypoint. @@ -115,7 +116,7 @@ test("consultation entrypoints form a closed public request enum", () => { }); test("browser source does not own private entrypoint prompts", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; assert.doesNotMatch(source, /function buildDailyStarlanguageQuestion/); assert.doesNotMatch(source, /function buildBirthTimeRectificationQuestion/); @@ -124,7 +125,7 @@ test("browser source does not own private entrypoint prompts", () => { }); test("ordinary product drafts keep the public question and clear hidden routing after edits", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; assert.match(source, /dailyStarlanguageQuestion/); assert.match(source, /从今日问起/); @@ -144,7 +145,7 @@ test("ordinary product drafts keep the public question and clear hidden routing }); test("homepage birth-time card opens the V9 Agentic surface via the server case API", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); assert.match(source, /function openRectificationCase/); @@ -157,7 +158,7 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case }); test("homepage mounts the Agentic surface without invoking retired rectification starters", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); @@ -167,7 +168,7 @@ test("homepage mounts the Agentic surface without invoking retired rectification }); test("homepage opens through the server Case API and merges the returned session", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const start = source.indexOf("async function openRectificationCase"); const end = source.indexOf("async function openRectificationFromHomepage", start); const handler = source.slice(start, end); @@ -185,7 +186,7 @@ test("homepage opens through the server Case API and merges the returned session }); test("the page never creates the session shell locally; the server owns session creation", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); const start = page.indexOf("async function openRectificationCase"); const end = page.indexOf("async function openRectificationFromHomepage", start); @@ -197,7 +198,7 @@ test("the page never creates the session shell locally; the server owns session }); test("rectification cards render only inside the active rectification session", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; assert.match(source, /activeSession\?\.sessionType === "birth_time_rectification"/); assert.match(source, /session_type:\s*session\.sessionType/); @@ -206,7 +207,7 @@ test("rectification cards render only inside the active rectification session", }); test("selecting a rectification session resumes it through the exact-session open API", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const selectSession = source.slice( source.indexOf("function selectSession("), source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")), @@ -220,7 +221,7 @@ test("selecting a rectification session resumes it through the exact-session ope }); test("homepage creation and sidebar selection resolve through distinct server intents", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/); @@ -232,7 +233,7 @@ test("homepage creation and sidebar selection resolve through distinct server in }); test("an answered conversation offers no suggested follow-up questions", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); @@ -253,7 +254,7 @@ test("an answered conversation offers no suggested follow-up questions", () => { }); test("rectify-first handoffs stay as Agent context", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/); @@ -262,7 +263,7 @@ test("rectify-first handoffs stay as Agent context", () => { }); test("ordinary consultation uses current birth data without a rectification notice", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const sendStart = source.indexOf("async function send("); const consultCall = source.indexOf('fetch("/api/consult"', sendStart); @@ -274,7 +275,7 @@ test("ordinary consultation uses current birth data without a rectification noti }); test("rectification mutations report pending state while session-level return controls stay absent", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/); assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/); @@ -283,7 +284,7 @@ test("rectification mutations report pending state while session-level return co }); test("session changes contain no birth-time notice state", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const selectSession = source.slice( source.indexOf("function selectSession("), source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")), @@ -294,7 +295,7 @@ test("session changes contain no birth-time notice state", () => { }); test("profile and place saves do not auto-start the retired assessment flow", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const normalSave = source.slice(source.indexOf("async function saveProfile"), source.indexOf("async function saveOnboardingName")); const placeSave = source.slice(source.indexOf("async function saveOnboardingPlace"), source.indexOf("function completeGuidedBirthTime")); @@ -315,7 +316,7 @@ test("consult route expands an optional entrypoint for both Agent and tool input }); test("homepage entrypoints use two whole-card native actions", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const wholeCardActions = source.match(/className="product-entrypoint-hitarea"/g) ?? []; assert.equal(wholeCardActions.length, 2); @@ -345,7 +346,7 @@ test("starter cards use transient pressed feedback instead of sticky hover shadi }); test("the starter heading is drawn per visit and the rectification card carries no fine print", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); // Given: the heading reads from a variant seeded once per mount and redrawn when the home is left. @@ -393,7 +394,7 @@ test("starter homepage keeps its editorial display typography", () => { }); test("starter homepage stays editorial and hides technical chart parameters", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const start = source.indexOf('
{ test("the homepage card is engine-backed first, with Agent polish off the request path", () => { const route = readFileSync(new URL("../src/app/api/daily-starlanguage/route.ts", import.meta.url), "utf8"); - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const agents = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); const generateCard = route.slice(route.indexOf("async function generateCard"), route.indexOf("function unavailable")); const polish = route.slice(route.indexOf("function scheduleAgentPolish"), route.indexOf("async function generateCard")); @@ -306,7 +307,7 @@ test("the homepage card is engine-backed first, with Agent polish off the reques }); test("the home requests the card exactly when it renders one, and retries a failed day once", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const effect = page.slice( page.indexOf("if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;"), page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);"), diff --git a/frontend/tests/home-surface.ts b/frontend/tests/home-surface.ts new file mode 100644 index 00000000..887f8628 --- /dev/null +++ b/frontend/tests/home-surface.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; + +const homeSurfaceFiles = [ + "../src/app/page.tsx", + "../src/lib/home-types.ts", + "../src/components/onboarding-chat-message.tsx", + "../src/lib/home-profile.ts", + "../src/lib/home-cloud-sync.ts", + "../src/components/birth-location-fields.tsx", + "../src/components/profile-fields.tsx", + "../src/components/chart-library-panel.tsx", + "../src/components/starter-home.tsx", +] as const; + +export const homeSurface = homeSurfaceFiles + .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8")) + .join("\n"); diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index e4658ab7..e21fa622 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -3,11 +3,11 @@ import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { cssDeclarations } from "./css-contract-test-support.ts"; +import { homeSurface as homePageSource } from "./home-surface.ts"; const projectFile = (path: string) => new URL(`../${path}`, import.meta.url); const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); const globalStyles = readProjectFile("src/app/globals.css"); -const homePageSource = readProjectFile("src/app/page.tsx"); const onboardingPaywallSource = readProjectFile("src/components/onboarding-redeem-paywall.tsx"); const pageSource = readProjectFile("src/app/membership/page.tsx"); const ordersPageSource = readProjectFile("src/app/membership/orders/page.tsx"); diff --git a/frontend/tests/onboarding-presentation.test.ts b/frontend/tests/onboarding-presentation.test.ts index ec29b614..43e321f1 100644 --- a/frontend/tests/onboarding-presentation.test.ts +++ b/frontend/tests/onboarding-presentation.test.ts @@ -9,6 +9,7 @@ import { onboardingProfileFingerprint, onboardingRequestIdentity, } from "../src/lib/onboarding-client.ts"; +import { homeSurface } from "./home-surface.ts"; const completeProfile = { name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", @@ -60,7 +61,7 @@ test("the starter hero splits one time-aware greeting instead of drawing from a }); test("the starter hero stays at a salutation and a question", () => { - const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const source = homeSurface; // Given: the hero heading is the question half of the time-aware greeting. assert.match(source, /

\{starterGreeting\.salutation\}<\/p>/); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 3474cf38..50663c8c 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { homeSurface as page } from "./home-surface.ts"; const component = readFileSync( new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), @@ -22,7 +23,6 @@ const route = readFileSync( new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8", ); -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const agent = readFileSync( new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8", diff --git a/frontend/tests/settings-mvp-contract.test.ts b/frontend/tests/settings-mvp-contract.test.ts index 6b29c4c6..f3466e1e 100644 --- a/frontend/tests/settings-mvp-contract.test.ts +++ b/frontend/tests/settings-mvp-contract.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as page } from "./home-surface.ts"; const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); @@ -32,7 +32,7 @@ test("personal profile is limited to account basics and links to chart settings" }); test("the chart library owns self-profile editing and keeps other-chart save separate", () => { - const charts = between(page, " renderChartLibrary() {", " renderGeneral() {"); + const charts = readFileSync(new URL("../src/components/chart-library-panel.tsx", import.meta.url), "utf8"); assert.match(charts, /编辑本人资料/); assert.match(charts, /onSubmit=\{saveProfile\}/); assert.match(charts, /onSubmit=\{saveOtherChart\}/); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index 0f915827..68245e47 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -10,7 +10,7 @@ import { generalGuidedJyotishTopics, } from "../src/lib/guided-jyotish-topics.ts"; -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as pageSource } from "./home-surface.ts"; const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const appSidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); const guidedTopicsSource = readFileSync(new URL("../src/lib/guided-jyotish-topics.ts", import.meta.url), "utf8"); diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 1b6a1ada..3fceaacb 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -63,6 +63,8 @@ CORE_PYTEST_TARGETS = [ # full pytest tree but are workflow_dispatch only, so a stale window_scan # assertion in this glob stayed red on origin/staging until listed here. "tests/test_rectification_*.py", + # This file regexes frontend source. Home-split and other page.tsx moves must keep it green. + "tests/test_supabase_user_data_contract.py", ] RUNTIME_TRUTH_PYTEST_TARGETS = [ diff --git a/tests/test_birth_time_journey_contract.py b/tests/test_birth_time_journey_contract.py index 87bd8d99..245628a3 100644 --- a/tests/test_birth_time_journey_contract.py +++ b/tests/test_birth_time_journey_contract.py @@ -127,7 +127,13 @@ def test_rectification_cases_are_owner_scoped_and_auditable() -> None: def test_web_onboarding_uses_the_deterministic_free_journey() -> None: - page = (FRONTEND / "src" / "app" / "page.tsx").read_text(encoding="utf-8") + page = "".join( + path.read_text(encoding="utf-8") + for path in ( + FRONTEND / "src" / "app" / "page.tsx", + FRONTEND / "src" / "lib" / "home-profile.ts", + ) + ) route = ( FRONTEND / "src" / "app" / "api" / "birth-time-journey" / "route.ts" ).read_text(encoding="utf-8") diff --git a/tests/test_daily_and_rectification_entrypoints.py b/tests/test_daily_and_rectification_entrypoints.py index e5ed0c96..73226bd3 100644 --- a/tests/test_daily_and_rectification_entrypoints.py +++ b/tests/test_daily_and_rectification_entrypoints.py @@ -2,12 +2,14 @@ from pathlib import Path PAGE = Path("frontend/src/app/page.tsx") +STARTER_HOME = Path("frontend/src/components/starter-home.tsx") +HOME_CLOUD = Path("frontend/src/lib/home-cloud-sync.ts") DAILY_ROUTE = Path("frontend/src/app/api/daily-starlanguage/route.ts") RECTIFICATION_ROUTE = Path("frontend/src/app/api/birth-rectification/route.ts") def test_daily_starlanguage_entrypoint_is_productized() -> None: - source = PAGE.read_text(encoding="utf-8") + source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8") assert "今日星语" in source assert "fetchDailyStarlanguage" in source assert "daily-starlanguage-card" in source @@ -30,7 +32,7 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None: def test_birth_time_rectification_entrypoint_is_productized() -> None: - source = PAGE.read_text(encoding="utf-8") + source = PAGE.read_text(encoding="utf-8") + STARTER_HOME.read_text(encoding="utf-8") + HOME_CLOUD.read_text(encoding="utf-8") assert "生时校正" in source assert "birth-rectification-card" in source assert "openRectificationFromHomepage" in source diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index ec93afe5..b583a893 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -38,6 +38,24 @@ SYNASTRY_REPORT_MIGRATION = ( / "20260718101000_repair_missing_synastry_reports.sql" ) PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx" +_FRONTEND_SRC = Path(__file__).resolve().parents[1] / "frontend" / "src" +HOME_SURFACE_FILES = ( + PAGE, + _FRONTEND_SRC / "lib" / "home-types.ts", + _FRONTEND_SRC / "lib" / "home-profile.ts", + _FRONTEND_SRC / "lib" / "home-cloud-sync.ts", + _FRONTEND_SRC / "components" / "birth-location-fields.tsx", + _FRONTEND_SRC / "components" / "profile-fields.tsx", + _FRONTEND_SRC / "components" / "onboarding-chat-message.tsx", + _FRONTEND_SRC / "components" / "chart-library-panel.tsx", + _FRONTEND_SRC / "components" / "starter-home.tsx", +) + + +def _home_surface() -> str: + return "".join(path.read_text(encoding="utf-8") for path in HOME_SURFACE_FILES) + + SESSION_CREATE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "route.ts" SESSION_ITEM_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "[id]" / "route.ts" ACCOUNT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "account" / "route.ts" @@ -94,12 +112,16 @@ def test_user_profile_and_chat_session_database_contract() -> None: def test_chat_page_uses_authenticated_cloud_persistence() -> None: - source = PAGE.read_text(encoding="utf-8") + source = _home_surface() create_route = SESSION_CREATE_ROUTE.read_text(encoding="utf-8") item_route = SESSION_ITEM_ROUTE.read_text(encoding="utf-8") - assert '.from("profiles")' in source - assert '.from("chat_sessions")' in source + # Former value: page talked to Supabase with `.from("profiles")` / `.from("chat_sessions")`. + # Persistence is now same-origin APIs; the lock is still "no browser-owned writes". + assert 'fetch("/api/account"' in source + assert 'fetch("/api/sessions"' in source + assert '.from("profiles")' not in source + assert '.from("chat_sessions")' not in source assert 'await writeChatSession(session.id, values, mode)' in source assert 'mode === "create" ? "/api/sessions"' in ( Path(__file__).resolve().parents[1] @@ -114,16 +136,22 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None: assert 'await persistSession(userSession)' not in source assert source.index('updateSession(sessionId, () => userSession)') < source.index('await persistSession(completedSession)') assert 'function completedOnboardingTranscript(profile: Profile, greeting: string): Message[]' in source - assert 'messages: [...preservedMessages, { role: "user", text: question }]' in source + # Former value: `messages: [...preservedMessages, { role: "user", text: question }]` + # The user turn is still appended unless the question is already present. + assert 'messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }]' in source assert 'await persistSession(completedSession)' in source assert 'const stoppedRequestAwaitingSettlement = useRef(null)' in source assert 'const stoppedSessionPersistence = useRef(new Map>())' in source - assert 'if (ownsInterface && !partialReply)' in source - assert 'await persistSession(interruptedSession)' in source - assert 'await persistence' in source + # Former values: persist interruptedSession when `ownsInterface && !partialReply`. + # Interrupted answers stay in page memory; the client does not replace the server transcript. + assert "await persistSession(interruptedSession)" not in source + assert "if (!cancelled && ownsInterface && pendingConsultation.current)" in source + assert "await persistence" in source assert "pendingSessionId || cancellationInFlight.current" in source assert "setCancellationPending(true)" in source - assert "系统正在以账户记录为准同步点数" in source + # Former value: "系统正在以账户记录为准同步点数" + assert "正在停止回答并申请退回本次点数…" in source + assert "void refreshAccount()" in source assert "回答中途断开,已保留现有内容,本次已计费。" not in source assert "本次已开始生成并计费" not in source # Former value: `localStorage.setItem(chartLibraryStorageKey(accountId)` @@ -134,7 +162,17 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None: def test_account_profile_patch_rejects_array_payloads() -> None: route = ACCOUNT_ROUTE.read_text(encoding="utf-8") - assert 'typeof payload !== "object" || Array.isArray(payload)' in route + schema = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "src" + / "lib" + / "account-profile-patch.ts" + ).read_text(encoding="utf-8") + # Former value: `typeof payload !== "object" || Array.isArray(payload)` in the route. + # Arrays are still rejected: the route parses with a Zod object schema. + assert "accountProfilePatchSchema.safeParse" in route + assert "export const accountProfilePatchSchema = z.object({" in schema assert "账户资料格式不正确" in route @@ -142,7 +180,7 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: sql = re.sub(r"\s+", " ", CHART_PROFILE_MIGRATION.read_text(encoding="utf-8").lower()).strip() route = CHART_PROFILE_ROUTE.read_text(encoding="utf-8") delete_route = CHART_PROFILE_DELETE_ROUTE.read_text(encoding="utf-8") - page = PAGE.read_text(encoding="utf-8") + page = _home_surface() for token in ( "create table if not exists public.chart_profiles", @@ -190,7 +228,7 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: "jyotisha_chart_library", "保存失败,请重试", "星盘库", - "添加其他星盘", + "添加其他人的星盘", "用于合盘", "设为默认", ): @@ -208,8 +246,8 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None: route = SYNASTRY_ROUTE.read_text(encoding="utf-8") for token in ( 'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"', - 'postPython("/api/chart", birthPayload(body.selfProfile))', - 'postPython("/api/chart", birthPayload(body.partnerProfile))', + 'postPython("/api/chart", selfPayload)', + 'postPython("/api/chart", partnerPayload)', 'postPython("/api/varga_full"', 'postPython("/api/synastry"', "moonLongitude(selfChart)", @@ -221,6 +259,7 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None: "ashtakoot_plus_moon_nakshatra_d9", 'evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"]', 'status: "blocked"', + "synastryBirthPayload", ): assert token in route @@ -228,7 +267,7 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None: def test_synastry_reports_are_cloud_persisted_per_user() -> None: sql = re.sub(r"\s+", " ", SYNASTRY_REPORT_MIGRATION.read_text(encoding="utf-8").lower()).strip() route = SYNASTRY_REPORT_ROUTE.read_text(encoding="utf-8") - page = PAGE.read_text(encoding="utf-8") + page = _home_surface() for token in ( "create table if not exists public.synastry_reports", @@ -290,7 +329,7 @@ def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None: def test_profile_coordinates_are_persisted_with_database_bounds() -> None: sql = re.sub(r"\s+", " ", COORDS_MIGRATION.read_text(encoding="utf-8").lower()).strip() - source = PAGE.read_text(encoding="utf-8") + source = _home_surface() for definition in ( "latitude double precision",