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",