diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d65d3897..18428b9a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -93,6 +93,15 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading. - **Accessibility:** persistent label where practical; composer has an explicit accessible label. +### Birth time intake + +- **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source. +- **Surface:** choice rows use the warm canvas and hairline system; the selected row uses `--color-action-soft` with a deep-brown border, never a dark promotional card. +- **States:** no source selected, source selected, source-specific details incomplete, ready to continue, assessing, rectifying, candidate saved, confirmed. +- **Copy:** labels describe what the user actually knows. Candidate results explicitly distinguish a reported time, a candidate range, and an active chart time. +- **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable. +- **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation. + ### Model selector - **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger shows only the active model name; each option shows only its model name and radio selection state. diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index 8fbed961..d87f1f85 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -8,11 +8,13 @@ import { import { createBirthTimeJourneyService, RectificationCaseNotFoundError, + RectificationQuestionsUnavailableError, } from "@/lib/birth-time-journey-service"; import { createSupabaseBirthTimeJourneyStore, BirthTimeJourneyStoreError, } from "@/lib/birth-time-journey-store"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -21,6 +23,7 @@ export const maxDuration = 60; const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("assess") }).strict(), + z.object({ type: z.literal("resume"), caseId: z.string().uuid() }).strict(), z.object({ type: z.literal("answer_question"), caseId: z.string().uuid(), @@ -40,8 +43,10 @@ async function requestPayload(request: Request): Promise { export async function POST(request: Request) { let supabase: Awaited>; + let journeyStoreClient: ReturnType; try { supabase = await createServerSupabaseClient(); + journeyStoreClient = createAdminSupabaseClient(); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json( @@ -69,7 +74,7 @@ export async function POST(request: Request) { } const service = createBirthTimeJourneyService({ - store: createSupabaseBirthTimeJourneyStore(supabase), + store: createSupabaseBirthTimeJourneyStore(journeyStoreClient), engine: createJyotishBirthTimeJourneyEngine(), }); @@ -98,6 +103,8 @@ export async function POST(request: Request) { parsed.data.questionId, parsed.data.answer, )); + case "resume": + return NextResponse.json(await service.resume(user.id, parsed.data.caseId)); default: { const exhaustive: never = parsed.data; return exhaustive; @@ -116,6 +123,12 @@ export async function POST(request: Request) { { status: 404 }, ); } + if (error instanceof RectificationQuestionsUnavailableError) { + return NextResponse.json( + { error: "校正问题暂不可用", message: "当前资料已安全保留,请稍后重新评估。" }, + { status: 409 }, + ); + } if (error instanceof BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError) { return NextResponse.json( { error: "生时评估暂时不可用", message: "已保留当前资料,请稍后重试。" }, diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index 8a1dc487..12fe2a9a 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -44,7 +44,8 @@ function hasCompleteBirthProfile(profile: Record) { return Boolean( profile.name && profile.birth_date - && profile.birth_time + && (profile.active_birth_time || profile.birth_time) + && (profile.birth_time_status === "confirmed" || (!profile.birth_time_status && profile.birth_time)) && profile.country_code && profile.province_code && profile.city_code, @@ -74,7 +75,7 @@ export async function POST() { const { data: profile, error: profileError } = await admin .from("profiles") - .select("name,birth_date,birth_time,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") + .select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") .eq("id", user.id) .maybeSingle(); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1f59aa1e..1ee56265 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -301,6 +301,43 @@ button:disabled { cursor: default; opacity: .45; } .onboarding-card { border: 1px solid var(--color-border); max-width: 680px; margin: var(--space-2) 0 var(--space-5); padding: var(--space-6); border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); } .onboarding-card-heading b { font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } .onboarding-card-heading small { color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); } +.birth-time-intake { display: grid; gap: var(--space-4); } +.birth-time-source-fieldset { margin: 0; padding: 0; border: 0; } +.birth-time-source-fieldset legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; } +.birth-time-source-list { display: grid; gap: var(--space-2); } +.birth-time-source-option { min-height: 64px; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } +.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); } +.birth-time-source-option input { width: 16px; height: 16px; margin: 0; accent-color: var(--color-action); } +.birth-time-source-option > span { display: grid; gap: 3px; } +.birth-time-source-option b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; } +.birth-time-source-option small, .birth-time-detail-note, .birth-time-legacy-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } +.birth-time-detail-grid { display: grid; grid-template-columns: minmax(0, 180px) minmax(0, 1fr); align-items: end; gap: var(--space-3); } +.birth-time-detail-grid > label { min-width: 0; } +.birth-time-detail-note { padding-bottom: 12px; } +.birth-time-legacy-note { padding: var(--space-3); border-left: 2px solid var(--color-warning); background: var(--color-canvas-muted); } +.birth-time-rectification { max-width: 680px; display: grid; gap: var(--space-5); } +.birth-time-assessment-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } +.birth-time-assessment-heading > div { display: grid; gap: var(--space-1); } +.birth-time-assessment-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; } +.birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; } +.birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: 11px !important; letter-spacing: .04em !important; } +.birth-time-range-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } +.birth-time-range-summary > div { display: grid; gap: var(--space-1); padding: var(--space-3) var(--space-4); background: var(--color-canvas); } +.birth-time-range-summary dt { color: var(--color-ink-tertiary); font-size: 11px; } +.birth-time-range-summary dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; } +.birth-time-assistant-intent, .birth-time-assessment-unavailable { margin: 0; padding: var(--space-4); border-left: 2px solid var(--color-action); background: var(--color-action-soft); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; } +.birth-time-question-list { display: grid; gap: var(--space-4); } +.birth-time-question-progress { display: flex; justify-content: space-between; color: var(--color-ink-secondary); font-size: var(--type-caption); } +.birth-time-question { display: grid; gap: var(--space-3); margin: 0; padding: var(--space-4) 0 0; border: 0; border-top: 1px solid var(--color-border); } +.birth-time-question legend { display: flex; align-items: flex-start; gap: var(--space-2); color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; line-height: 1.55; } +.birth-time-question legend span { width: 24px; height: 24px; display: inline-grid; flex: 0 0 24px; place-items: center; border: 1px solid var(--color-border-strong); border-radius: var(--radius-xs); color: var(--color-ink-secondary); font-size: 11px; } +.birth-time-answer-list { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); } +.birth-time-answer-list button { min-height: 52px; display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; text-align: left; font-size: var(--type-caption); line-height: 1.4; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } +.birth-time-answer-list button > span { color: var(--color-action); font-family: var(--font-mono); font-weight: 600; } +.birth-time-answer-list button.is-selected { border-color: var(--color-action); background: var(--color-action-soft); } +.birth-time-question > small { color: var(--color-ink-secondary); font-size: 11px; } +.birth-time-retry-card { display: grid; justify-items: start; gap: var(--space-3); } +.birth-time-retry-card p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; } .starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: 1.08fr .92fr; grid-template-rows: 1fr 1fr; gap: var(--space-3); border: 0; } .starter-list button { width: 100%; display: grid; align-items: center; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 92px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 0; border-radius: var(--radius-lg); background: var(--color-canvas-muted); } @@ -439,6 +476,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); } .button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); } .section-toggle:not(:disabled):hover { color: var(--color-action-hover); } + .birth-time-source-option:hover, .birth-time-answer-list button:not(:disabled):hover { border-color: var(--color-action); } } @media (min-width: 768px) and (max-width: 900px) { @@ -478,6 +516,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: } @media (max-width: 480px) { + .birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list { grid-template-columns: 1fr; } .welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); } .starter-content span { font-size: var(--type-title-sm); } .profile-dialog h2 { font-size: var(--type-display-sm); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ce01f57f..854eef4a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,12 +4,30 @@ import Link from "next/link"; import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, Square, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; +import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; +import { BirthTimeRectification } from "@/components/birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { ModelSelector } from "@/components/model-selector"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply"; +import { + assistantIntentCopy, + birthTimePersistenceValues, + describeBirthTimeDraft, + isBirthTimeDraftReady, + type BirthTimeDraft, + type BirthTimeSource, +} from "@/lib/birth-time-intake-model"; +import { + answerBirthTimeQuestion, + parseJourneyResponse, + requestBirthTimeAssessment, + resumeBirthTimeJourney, + type JourneyAnswer, + type JourneyClientResponse, +} from "@/lib/birth-time-journey-client"; import { keepFocusWithin } from "@/lib/focus-trap"; import { SessionModelPersistenceQueue, @@ -24,14 +42,13 @@ import { createBrowserSupabaseClient } from "@/lib/supabase/client"; type Theme = ReplyTheme; type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] }; -type Profile = { +type Profile = BirthTimeDraft & { name: string; - date: string; - time: string; countryCode: "CN"; provinceCode: string; cityCode: string; districtCode: string; + rectificationCaseId: string; }; type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number }; type RequestError = { sessionId: string; message: string }; @@ -40,7 +57,7 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number }; type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean }; type OnboardingSuggestion = { theme: Exclude; text: string }; type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] }; -type OnboardingStep = "name" | "birth" | "place"; +type OnboardingStep = "name" | "birth" | "place" | "rectification"; type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; type PendingConsultation = { @@ -73,6 +90,30 @@ const previewModelCatalog = parsePublicModelCatalog({ ], }); +const previewRectificationJourney = parseJourneyResponse({ + caseId: "7299894c-10a8-4b45-91d1-339007282c50", + snapshot: { + state: "rectifying", + assistantIntent: "start_standard_rectification", + input: "rectification_questions", + route: "rectification", + confidence: null, + canApply: false, + activeTime: null, + reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" }, + }, + questionnaire: { + questions: [ + { id: "education_shift", prompt: "求学阶段是否发生过一次明显的环境或方向变化?" }, + { id: "career_shift", prompt: "工作早期是否经历过一次清晰的行业、岗位或城市切换?" }, + { id: "relationship_milestone", prompt: "重要关系或婚姻节点是否集中在某个明确年份?" }, + ], + samples: [], + raw: {}, + }, + scoring: null, +}); + const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?"; const greetingVariants: Record string>> = { @@ -121,6 +162,14 @@ const emptyProfile: Profile = { name: "", date: "", time: "", + reportedTime: "", + birthTimeSource: "", + birthTimePeriod: "", + birthTimeClue: "", + uncertaintyBeforeMinutes: null, + uncertaintyAfterMinutes: null, + birthTimeStatus: "", + rectificationCaseId: "", countryCode: "CN", provinceCode: "", cityCode: "", @@ -168,18 +217,18 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null { function missingProfileStep(profile: Profile): OnboardingStep | null { if (!profile.name.trim()) return "name"; - if (!profile.date || !profile.time) return "birth"; + if (!isBirthTimeDraftReady(profile)) return "birth"; if (!selectedBirthPlace(profile)) return "place"; + if (!profile.time || profile.birthTimeStatus !== "confirmed") return "rectification"; return null; } function birthQuestion(name: string) { - return `${name},你好。接下来请告诉我你的出生日期和时间。时间越准确,后面的判断越可靠。`; + return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`; } function formatBirthMoment(profile: Profile) { - const [year, month, day] = profile.date.split("-").map(Number); - return `${year}年${month}月${day}日 ${profile.time}`; + return describeBirthTimeDraft(profile); } function placeQuestion(profile: Profile) { @@ -193,7 +242,7 @@ function completedOnboardingMessage(name: string) { function completedOnboardingTranscript(profile: Profile, greeting: string): Message[] { const name = profile.name.trim(); const birthPlace = selectedBirthPlace(profile); - if (!name || !profile.date || !profile.time || !birthPlace) return []; + if (!name || !profile.date || !profile.time || profile.birthTimeStatus !== "confirmed" || !birthPlace) return []; return [ { role: "assistant", text: presetOnboardingMessage }, @@ -238,13 +287,38 @@ function readProfile(value: unknown): Profile { 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; + 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; }; const date = typeof profile.birth_date === "string" ? profile.birth_date : profile.date; - const time = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time; + 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 reportedTime = typeof profile.reported_birth_time === "string" + ? profile.reported_birth_time.slice(0, 5) + : time; + 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 knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; + const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? ""; + const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "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; @@ -253,6 +327,14 @@ function readProfile(value: unknown): Profile { 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: period, + birthTimeClue: typeof profile.birth_time_clue === "string" ? profile.birth_time_clue.slice(0, 240) : "", + 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: "CN", provinceCode: typeof provinceCode === "string" ? provinceCode : "", cityCode: typeof cityCode === "string" ? cityCode : "", @@ -302,15 +384,6 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null return { sessions, fallbackSessionIds }; } -function BirthMomentFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { - return ( -
- - -
- ); -} - function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { const province = findProvince(value.provinceCode); const cities = province?.cities ?? []; @@ -337,7 +410,7 @@ function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile 如何称呼你 onChange({ ...value, name: event.target.value })} /> - + onChange({ ...value, ...patch })} /> ); @@ -457,6 +530,10 @@ export default function Home() { const [onboardingError, setOnboardingError] = useState(""); const [onboardingStep, setOnboardingStep] = useState("name"); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); + const [birthTimeJourney, setBirthTimeJourney] = useState(null); + const [birthTimeAnswers, setBirthTimeAnswers] = useState>>({}); + const [birthTimeQuestionPending, setBirthTimeQuestionPending] = useState(""); + const [birthTimeError, setBirthTimeError] = useState(""); const [startGreeting, setStartGreeting] = useState(""); const [presetMessageLength, setPresetMessageLength] = useState(0); const conversationEnd = useRef(null); @@ -499,6 +576,8 @@ export default function Home() { ? birthQuestion(profileDraft.name.trim()) : onboardingStep === "place" ? placeQuestion(profileDraft) + : onboardingStep === "rectification" && birthTimeJourney + ? assistantIntentCopy(birthTimeJourney.snapshot.assistantIntent) : presetOnboardingMessage; const shouldStreamOnboarding = !profileComplete || onboardingJustCompleted; const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length; @@ -530,7 +609,15 @@ export default function Home() { : { name: "林遥", date: "1990-06-15", - time: "12:30", + time: previewMode === "birth-time-rectification" ? "" : "12:30", + reportedTime: previewMode === "birth-time-rectification" ? "14:30" : "12:30", + birthTimeSource: previewMode === "birth-time-rectification" ? "approximate" : "legacy_import", + birthTimePeriod: "", + birthTimeClue: "", + uncertaintyBeforeMinutes: previewMode === "birth-time-rectification" ? 30 : null, + uncertaintyAfterMinutes: previewMode === "birth-time-rectification" ? 30 : null, + birthTimeStatus: previewMode === "birth-time-rectification" ? "rectifying" : "confirmed", + rectificationCaseId: previewMode === "birth-time-rectification" ? previewRectificationJourney.caseId : "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", @@ -554,6 +641,7 @@ export default function Home() { setModelCatalog(previewModelCatalog); setProfile(previewProfile); setProfileDraft(previewProfile); + if (previewMode === "birth-time-rectification") setBirthTimeJourney(previewRectificationJourney); setOnboardingStep(missingProfileStep(previewProfile) ?? "name"); setSessions([previewSession]); setActiveSessionId(previewSession.id); @@ -588,7 +676,7 @@ export default function Home() { const [profileResult, sessionsResult] = await Promise.all([ supabase .from("profiles") - .select("name,birth_date,birth_time,country_code,province_code,city_code,district_code") + .select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code") .eq("id", nextAccount.user.id) .abortSignal(controller.signal) .maybeSingle(), @@ -633,6 +721,20 @@ export default function Home() { setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); setActiveSessionId(nextSessions[0].id); + if ((nextProfile.birthTimeStatus === "rectifying" || nextProfile.birthTimeStatus === "candidate") + && nextProfile.rectificationCaseId) { + try { + const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId); + if (!controller.signal.aborted) { + setBirthTimeJourney(resumed); + setBirthTimeAnswers(resumed.answers); + } + } catch (caught) { + if (!controller.signal.aborted) { + setBirthTimeError(caught instanceof Error ? caught.message : "暂时无法继续上次的时间校正。"); + } + } + } if (modelCatalogResult.unavailable) { setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); } else if (parsedSessions.fallbackSessionIds.length > 0) { @@ -915,7 +1017,7 @@ export default function Home() { .update({ name: nextProfile.name.trim() || null, birth_date: nextProfile.date || null, - birth_time: nextProfile.time || null, + ...birthTimePersistenceValues(nextProfile), country_code: nextProfile.countryCode, province_code: nextProfile.provinceCode || null, city_code: nextProfile.cityCode || null, @@ -932,16 +1034,45 @@ export default function Home() { if (!data) throw new Error("账户档案不存在,请重新登录后再试。"); } + async function assessSavedBirthTime(nextProfile: Profile) { + const result = process.env.NODE_ENV === "development" && uiPreview.current + ? previewRectificationJourney + : await requestBirthTimeAssessment(); + const nextStatus = result.snapshot.state === "ready" + ? "confirmed" + : result.snapshot.state === "candidate" + ? "candidate" + : "rectifying"; + const assessedProfile: Profile = { + ...nextProfile, + time: result.snapshot.activeTime ?? "", + birthTimeStatus: nextStatus, + rectificationCaseId: result.caseId, + }; + setBirthTimeJourney(result); + setBirthTimeAnswers({}); + setBirthTimeError(""); + setProfile(assessedProfile); + setProfileDraft(assessedProfile); + return assessedProfile; + } + async function saveProfile(event: FormEvent) { event.preventDefault(); - if (!isProfileComplete(profileDraft) || !account || profileSaving) return; + if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return; setProfileSaving(true); setProfileNotice(""); setAccountError(""); try { await persistProfile(profileDraft); - setProfile(profileDraft); - setProfileNotice("出生资料已保存到云端,可在同一账号的其他设备使用。"); + const nextProfile = profileDraft.birthTimeStatus === "confirmed" + ? profileDraft + : await assessSavedBirthTime(profileDraft); + setProfile(nextProfile); + setProfileDraft(nextProfile); + setProfileNotice(nextProfile.birthTimeStatus === "confirmed" + ? "出生资料已保存到云端,可在同一账号的其他设备使用。" + : "资料已保存,当前时间仍在校正中,不会用于正式排盘。"); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败")); } finally { @@ -974,7 +1105,7 @@ export default function Home() { async function saveOnboardingBirth(event: FormEvent) { event.preventDefault(); - if (!profileDraft.date || !profileDraft.time || !account || profileSaving) return; + if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return; setProfileSaving(true); setAccountError(""); try { @@ -998,9 +1129,13 @@ export default function Home() { setAccountError(""); try { await persistProfile(profileDraft); - setProfile(profileDraft); + const assessedProfile = await assessSavedBirthTime(profileDraft); setPresetMessageLength(0); - setOnboardingJustCompleted(true); + if (assessedProfile.birthTimeStatus === "confirmed") { + setOnboardingJustCompleted(true); + } else { + setOnboardingStep("rectification"); + } } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败")); } finally { @@ -1008,6 +1143,43 @@ export default function Home() { } } + async function saveBirthTimeAnswer(questionId: string, answer: JourneyAnswer) { + if (!birthTimeJourney || birthTimeQuestionPending) return; + setBirthTimeQuestionPending(questionId); + setBirthTimeError(""); + try { + const result = process.env.NODE_ENV === "development" && uiPreview.current + ? birthTimeJourney + : await answerBirthTimeQuestion(birthTimeJourney.caseId, questionId, answer); + setBirthTimeJourney(result); + setBirthTimeAnswers(process.env.NODE_ENV === "development" && uiPreview.current + ? (current) => ({ ...current, [questionId]: answer }) + : result.answers); + const birthTimeStatus = result.snapshot.state === "candidate" ? "candidate" : "rectifying"; + setProfile((current) => ({ ...current, birthTimeStatus })); + setProfileDraft((current) => ({ ...current, birthTimeStatus })); + } catch (caught) { + setBirthTimeError(caught instanceof Error ? caught.message : "这条回答暂时无法保存,请重试。"); + } finally { + setBirthTimeQuestionPending(""); + } + } + + async function retryBirthTimeAssessment() { + if (!account || profileSaving) return; + setProfileSaving(true); + setBirthTimeError(""); + try { + const assessedProfile = await assessSavedBirthTime(profileDraft); + setPresetMessageLength(0); + if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(true); + } catch (caught) { + setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。"); + } finally { + setProfileSaving(false); + } + } + async function redeem(event: FormEvent) { event.preventDefault(); const code = redeemCode.trim(); @@ -1312,6 +1484,7 @@ export default function Home() { lon: birthPlace.lon, tz: birthPlace.tz, theme, + entryMode: profile.birthTimeStatus === "confirmed" ? "direct_chart" : "rectification", question, history: currentSession.messages.slice(-12).map((message) => ({ role: message.role, @@ -1530,7 +1703,11 @@ export default function Home() {
{activeSession?.title || "新对话"} - {isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"} + {isLoading + ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") + : !profileComplete && onboardingStep === "rectification" + ? "正在校正出生时间" + : "基于星盘证据回答"}
+
@@ -1584,6 +1763,28 @@ export default function Home() { )} + {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && birthTimeJourney && ( +
+
+ void saveBirthTimeAnswer(questionId, answer)} + /> +
+
+ )} + + {!profileComplete && onboardingStep === "rectification" && presetMessageFinished && !birthTimeJourney && ( +
+ 出生时间尚未完成评估 +

{birthTimeError || "资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。"}

+ +
+ )} + {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} {profileComplete && presetMessageFinished && !draft.trim() && (onboardingPending ? ( @@ -1677,7 +1878,7 @@ export default function Home() {