fix: harden birth-time consultation modes

This commit is contained in:
Jesse_Chen
2026-07-21 07:25:02 +08:00
parent fc1e9f1085
commit 8be39d5ad6
21 changed files with 1516 additions and 206 deletions
+84 -69
View File
@@ -3,6 +3,10 @@ import {
parseRectificationPriceCredits,
type AccountRectificationCaseState,
} from "@/lib/birth-time-consultation-consent";
import {
accountProfilePatchSchema,
resolveAccountBirthTimeApplicationPatch,
} from "@/lib/account-profile-patch";
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
@@ -11,45 +15,8 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
type ProfilePatchPayload = {
name?: unknown;
birth_date?: unknown;
birth_time?: unknown;
reported_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;
district_code?: unknown;
latitude?: unknown;
longitude?: unknown;
timezone_offset?: unknown;
};
const birthTimeSources = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const;
const birthTimePeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const rectificationStatuses = ["starting", "active", "paused", "confirming", "completed", "abandoned"] as const;
function nullableString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function nullableNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function nullableInteger(value: unknown) {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function nullableChoice(value: unknown, choices: readonly string[]) {
return typeof value === "string" && choices.includes(value) ? value : null;
}
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "PGRST204"
@@ -88,6 +55,7 @@ export async function GET() {
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = user.id;
const rectificationPriceCredits = parseRectificationPriceCredits(
process.env.RECTIFICATION_PRICE_CREDITS,
@@ -95,7 +63,7 @@ export async function GET() {
const { data: profile, error } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status")
.eq("id", user.id)
.eq("id", userId)
.single();
if (error) {
@@ -141,52 +109,99 @@ export async function PATCH(request: Request) {
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = user.id;
const payload = await request.json().catch(() => null) as ProfilePatchPayload | null;
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 });
}
const parsedPayload = accountProfilePatchSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsedPayload.success) return NextResponse.json({
error: "账户资料格式不正确",
details: parsedPayload.error.flatten(),
}, { status: 400 });
const payload = parsedPayload.data;
const admin = createAdminSupabaseClient();
const { data: currentProfile, error: currentProfileError } = await admin
.from("profiles")
.select("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", userId)
.maybeSingle();
if (currentProfileError) {
return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 });
}
const applicationPatch = currentProfile
? resolveAccountBirthTimeApplicationPatch(currentProfile, payload)
: {};
const baseProfile = {
id: user.id,
name: nullableString(payload.name),
birth_date: nullableString(payload.birth_date),
birth_time: nullableString(payload.birth_time),
reported_birth_time: nullableString(payload.reported_birth_time),
birth_time_source: nullableChoice(payload.birth_time_source, birthTimeSources),
birth_time_period: nullableChoice(payload.birth_time_period, birthTimePeriods),
birth_time_clue: nullableString(payload.birth_time_clue),
uncertainty_before_minutes: nullableInteger(payload.uncertainty_before_minutes),
uncertainty_after_minutes: nullableInteger(payload.uncertainty_after_minutes),
country_code: nullableString(payload.country_code),
province_code: nullableString(payload.province_code),
city_code: nullableString(payload.city_code),
district_code: nullableString(payload.district_code),
id: userId,
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.birth_date !== undefined ? { birth_date: payload.birth_date } : {}),
...(payload.reported_birth_time !== undefined
? { reported_birth_time: payload.reported_birth_time }
: {}),
...(payload.birth_time_source !== undefined
? { birth_time_source: payload.birth_time_source }
: {}),
...(payload.birth_time_period !== undefined
? { birth_time_period: payload.birth_time_period }
: {}),
...(payload.birth_time_clue !== undefined
? { birth_time_clue: payload.birth_time_clue }
: {}),
...(payload.uncertainty_before_minutes !== undefined
? { uncertainty_before_minutes: payload.uncertainty_before_minutes }
: {}),
...(payload.uncertainty_after_minutes !== undefined
? { uncertainty_after_minutes: payload.uncertainty_after_minutes }
: {}),
...(payload.country_code !== undefined ? { country_code: payload.country_code } : {}),
...(payload.province_code !== undefined ? { province_code: payload.province_code } : {}),
...(payload.city_code !== undefined ? { city_code: payload.city_code } : {}),
...(payload.district_code !== undefined ? { district_code: payload.district_code } : {}),
...applicationPatch,
updated_at: new Date().toISOString(),
};
const withCoordinates = {
...baseProfile,
latitude: nullableNumber(payload.latitude),
longitude: nullableNumber(payload.longitude),
timezone_offset: nullableNumber(payload.timezone_offset),
...(payload.latitude !== undefined ? { latitude: payload.latitude } : {}),
...(payload.longitude !== undefined ? { longitude: payload.longitude } : {}),
...(payload.timezone_offset !== undefined ? { timezone_offset: payload.timezone_offset } : {}),
};
const withoutCoordinates = baseProfile;
let { data, error } = await admin
.from("profiles")
.upsert(withCoordinates, { onConflict: "id" })
.select("id")
.single();
const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0;
async function writeProfile(values: Record<string, unknown>) {
if (!currentProfile) {
return admin
.from("profiles")
.upsert(values, { onConflict: "id" })
.select("id")
.maybeSingle();
}
let query = admin.from("profiles").update(values).eq("id", userId);
if (invalidatesUnconfirmedApplication) {
query = currentProfile.birth_time_status === null
? query.is("birth_time_status", null)
: query.eq("birth_time_status", currentProfile.birth_time_status);
query = currentProfile.active_birth_time === null
? query.is("active_birth_time", null)
: query.eq("active_birth_time", currentProfile.active_birth_time);
}
return query.select("id").maybeSingle();
}
let { data, error } = await writeProfile(withCoordinates);
if (error && isMissingProfileColumn(error)) {
const fallback = await admin
.from("profiles")
.upsert(withoutCoordinates, { onConflict: "id" })
.select("id")
.single();
const fallback = await writeProfile(withoutCoordinates);
data = fallback.data;
error = fallback.error;
}
if (!error && !data && invalidatesUnconfirmedApplication) {
return NextResponse.json({
error: "出生时间状态已经变化",
message: "最新确认结果已保留,请刷新后重新编辑。",
}, { status: 409 });
}
if (error || !data) {
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
}
+128 -8
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import {
consultationInputSchema,
consultationWorkflowReceipt,
getGeneralJyotishAgent,
getJyotishAgent,
runConsultationWorkflow,
} from "@/mastra";
@@ -19,16 +20,22 @@ import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { guardPreciseTimingOutput } from "@/lib/timing-output-guard";
import {
applyBirthTimeModeToWorkflowContext,
consultationBirthTimeModeSchema,
createBirthTimeModeOutputGuard,
serverProfileAllowsBirthTimeMode,
shouldRunBirthChartWorkflow,
type ConsultationBirthTimeMode,
} from "@/lib/consultation-birth-time-mode";
import { z } from "zod";
export const runtime = "nodejs";
export const maxDuration = 60;
const chatRequestSchema = consultationInputSchema.extend({
const chatRequestMetadataSchema = z.object({
requestId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64),
entrypoint: consultationEntrypointSchema.optional(),
name: z.string().trim().max(80).optional().default(""),
history: z
.array(
@@ -41,6 +48,24 @@ const chatRequestSchema = consultationInputSchema.extend({
.default([]),
});
const chartChatRequestSchema = consultationInputSchema.extend({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
.optional()
.default("verified_chart"),
entrypoint: consultationEntrypointSchema.optional(),
});
const generalChatRequestSchema = z.object({
...chatRequestMetadataSchema.shape,
consultationMode: z.literal("general_no_birth_time"),
question: z.string().trim().min(1).max(500),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
entrypoint: z.undefined().optional(),
}).strict();
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -119,6 +144,16 @@ export async function POST(request: Request) {
);
}
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
{
error: "旧版生时校正入口已停用",
message: "请从首页生时校正卡片开始或继续对话式校正,本次不会扣点。",
},
{ status: 409 },
);
}
const userControlledPrompt = [
parsed.data.question,
...parsed.data.history
@@ -136,6 +171,41 @@ export async function POST(request: Request) {
);
}
const requestedConsultationMode = parsed.data.consultationMode;
if (shouldRunBirthChartWorkflow(requestedConsultationMode)) {
if (!("hour" in parsed.data) || !("minute" in parsed.data)) {
return NextResponse.json(
{ error: "出生时间请求格式不正确" },
{ status: 400 },
);
}
const requestedTime = `${String(parsed.data.hour).padStart(2, "0")}:${String(parsed.data.minute).padStart(2, "0")}`;
const { data: birthTimeProfile, error: birthTimeProfileError } = await supabase
.from("profiles")
.select("active_birth_time,reported_birth_time,birth_time_source,birth_time_status")
.eq("id", user.id)
.single();
if (birthTimeProfileError || !birthTimeProfile) {
return NextResponse.json(
{ error: "暂时无法核对出生时间状态", message: "请稍后重试,本次不会扣点。" },
{ status: 503 },
);
}
if (!serverProfileAllowsBirthTimeMode(
birthTimeProfile,
requestedConsultationMode,
requestedTime,
)) {
return NextResponse.json(
{
error: "出生时间状态已经变化",
message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。",
},
{ status: 409 },
);
}
}
const requestTime = new Date();
const resolvedQuestion = resolveConsultationQuestion({
visibleQuestion: parsed.data.question,
@@ -230,11 +300,60 @@ export async function POST(request: Request) {
try {
const { history, name } = parsed.data;
const consultationMode: ConsultationBirthTimeMode = parsed.data.consultationMode;
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const result = await getGeneralJyotishAgent(selectedModel).stream([
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
"当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(
accounting,
userId,
requestId,
modelSelection.usageModelId,
result.totalUsage,
);
};
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
mode: "mastra",
requestId,
headers: {
"x-jyotish-workflow-route": "general-no-birth-time",
"x-jyotish-workflow-status": "ready",
"x-jyotish-technique-truth": "not-applicable",
"x-jyotish-precise-timing": "blocked",
"x-jyotish-missing-layers": "birth-minute",
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: () => settle(completeAndRecordUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
});
}
const toolInput = consultationInputSchema.parse({
...parsed.data,
// Unverified use is still a normal chart calculation with a hard answer
// boundary. It must never reactivate the retired rectification questionnaire.
entryMode: "direct_chart",
question: resolvedQuestion.modelQuestion,
});
const workflowContext = await runConsultationWorkflow(toolInput);
const workflowContext = applyBirthTimeModeToWorkflowContext(
await runConsultationWorkflow(toolInput),
consultationMode,
);
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
@@ -265,10 +384,10 @@ export async function POST(request: Request) {
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeAndRecordUsage : cancel);
return streamTextResponse(result.textStream, {
transformText:
workflowReceipt.preciseTiming === "blocked"
? guardPreciseTimingOutput
: undefined,
transformText: createBirthTimeModeOutputGuard(
consultationMode,
workflowReceipt.preciseTiming !== "blocked",
),
mode: "mastra",
requestId,
headers: {
@@ -277,6 +396,7 @@ export async function POST(request: Request) {
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: () => settle(completeAndRecordUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
+4
View File
@@ -132,6 +132,10 @@ button:disabled { cursor: default; opacity: .45; }
.onboarding-card-actions { display: flex; justify-content: flex-end; padding-top: 2px; }
.onboarding-card-actions .button-primary { min-width: 84px; }
.birth-time-transition-card { position: relative; overflow: hidden; }
.unverified-birth-time-choice-scrim { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); }
.unverified-birth-time-choice { width: min(100%, 620px); margin: 0; overflow: visible; }
.unverified-birth-time-choice .onboarding-card-actions { flex-wrap: wrap; gap: var(--space-2); }
.unverified-birth-time-choice button { min-height: 44px; }
.birth-time-transition-fields { min-width: 0; display: grid; gap: 14px; margin: 0; padding: 0; border: 0; }
.birth-time-assessment-overlay { position: absolute; z-index: 2; inset: 0; display: grid; place-items: center; padding: var(--space-6); border-radius: inherit; background: color-mix(in srgb, var(--color-canvas-soft) 94%, transparent); backdrop-filter: blur(2px); }
.birth-time-assessment-progress { width: min(360px, 100%); display: grid; justify-items: center; gap: var(--space-2); color: var(--color-ink-secondary); text-align: center; }
+137 -47
View File
@@ -22,9 +22,11 @@ import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import {
applyBirthTimeDraftPatch,
assistantIntentCopy,
birthTimeDisplayState,
birthTimePersistenceValues,
declaredBirthInputChanged,
describeBirthTimeDraft,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
@@ -34,16 +36,17 @@ import {
import {
canUseUnverifiedBirthTime,
clearBirthTimeConsultationConsent,
createLatestAccountRequestGuard,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
hasBirthTimeConsultationConsent,
requiresBirthTimeConsent,
resolveBirthTimeConsultationRoute,
resolveRectificationCardAction,
unverifiedBirthTime,
type AccountRectificationCaseState,
type BirthTimeConsultationConsentState,
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client";
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
@@ -423,7 +426,7 @@ async function fetchDailyStarlanguage(profile: Profile) {
function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!isDeclaredBirthProfileComplete(profile)) return "birth";
if (!selectedBirthPlace(profile)) return "place";
if (!isDeclaredBirthProfileComplete(profile, selectedBirthPlace(profile))) return "place";
return null;
}
@@ -491,14 +494,15 @@ function readProfile(value: unknown): Profile {
const time = typeof profile.active_birth_time === "string"
? profile.active_birth_time.slice(0, 5)
: legacyTime;
const reportedTime = typeof profile.reported_birth_time === "string"
const persistedReportedTime = 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 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 knownStatuses = ["reported", "assessing", "rectifying", "candidate", "confirmed"] as const;
@@ -588,6 +592,21 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p
);
}
const birthLocationKeys = ["countryCode", "provinceCode", "cityCode", "districtCode"] 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 }) {
return (
<>
@@ -595,8 +614,11 @@ function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onCha
<span></span>
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
</label>
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange({ ...value, ...patch })} />
<BirthLocationFields value={value} onChange={onChange} />
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
<BirthLocationFields
value={value}
onChange={(next) => onChange(invalidateCandidateAfterLocationChange(value, next))}
/>
</>
);
}
@@ -723,6 +745,7 @@ export default function Home() {
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationTurn | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [hydrated, setHydrated] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
@@ -756,6 +779,7 @@ export default function Home() {
const activeSessionIdRef = useRef("");
const chartLibraryLoadedAccount = useRef("");
const activeOnboardingRequestIdentity = useRef("");
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPending = useRef(false);
@@ -769,12 +793,20 @@ export default function Home() {
});
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const activeBirthTimeChoice = pendingBirthTimeChoice?.sessionId === activeSession?.id
? pendingBirthTimeChoice
: null;
const visibleSessions = sessions
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog;
const productEntrypointsDisabled = !hydrated
|| Boolean(pendingSessionId)
|| cancellationPending
|| rectificationMutationPending
|| !account
|| !modelCatalog;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
const accountId = account?.user.id;
const rectificationCardAction = resolveRectificationCardAction({
@@ -1195,10 +1227,25 @@ export default function Home() {
}, [activeAccountDialog, signingOut]);
async function refreshAccount() {
const requestIdentity = accountRefreshGuard.current.begin();
try {
setAccount(await fetchAccount());
const latest = await fetchAccount();
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
setAccount((current) => {
if (current?.rectificationCase
&& latest.rectificationCase?.caseId === current.rectificationCase.caseId
&& latest.rectificationCase.turnVersion < current.rectificationCase.turnVersion) {
return {
...latest,
hasConfirmedBirthTime: latest.hasConfirmedBirthTime || current.hasConfirmedBirthTime,
rectificationCase: current.rectificationCase,
};
}
return latest;
});
setAccountError("");
} catch (caught) {
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
}
}
@@ -1312,7 +1359,6 @@ export default function Home() {
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setPendingBirthTimeChoice(null);
setComposerNotice("");
setRequestError(null);
try {
@@ -1331,7 +1377,6 @@ export default function Home() {
function selectSession(sessionId: string) {
setActiveSessionId(sessionId);
setPendingBirthTimeChoice(null);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
@@ -1533,9 +1578,13 @@ export default function Home() {
setProfileNotice("");
setAccountError("");
try {
const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft);
await persistProfile(profileDraft);
setProfile(profileDraft);
setProfileDraft(profileDraft);
if (declarationChanged) {
setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState());
}
setProfileNotice(profileDraft.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: "出生资料已保存。你可以先使用填报时间询问,也可以从首页卡片开始校正。");
@@ -1726,7 +1775,7 @@ export default function Home() {
}
async function openBirthTimeRectification(pendingConsultationQuestion: string | null = null) {
if (!account || rectificationLoading) return;
if (!account || rectificationLoading || rectificationMutationPending) return;
const action = resolveRectificationCardAction({
rectificationCase: account.rectificationCase,
hasConfirmedBirthTime: account.hasConfirmedBirthTime,
@@ -1761,6 +1810,7 @@ export default function Home() {
}
function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) {
const requestIdentity = accountRefreshGuard.current.begin();
setRectificationInitialTurn(turn);
setAccount((current) => current ? {
...current,
@@ -1794,7 +1844,14 @@ export default function Home() {
}));
}
void fetchAccount()
.then((latest) => setAccount(latest))
.then((latest) => {
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
setAccount((current) => {
if (latest.rectificationCase?.caseId !== turn.caseId
|| latest.rectificationCase.turnVersion < turn.turnVersion) return current;
return latest;
});
})
.catch(() => undefined);
}
@@ -1989,7 +2046,7 @@ export default function Home() {
text: string,
requestedTheme?: Theme,
entrypoint: ConsultationEntrypoint | null = null,
consentGrantedForRequest = false,
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
) {
const originalQuestion = text;
const question = text.trim();
@@ -2012,26 +2069,28 @@ export default function Home() {
const currentSession = activeSession;
const theme = requestedTheme ?? currentSession.theme;
const sessionId = currentSession.id;
const consultationTime = profile.birthTimeStatus === "confirmed"
? profile.time
: unverifiedBirthTime(profile);
const hasSessionConsent = hasBirthTimeConsultationConsent(
birthTimeConsultationConsent,
const consentForDecision = consentGrantedForRequest === "unverified_birth_time"
? grantBirthTimeConsultationConsent(
birthTimeConsultationConsent,
sessionId,
"unverified_birth_time",
)
: birthTimeConsultationConsent;
const consultationRoute = resolveBirthTimeConsultationRoute(
profile,
consentForDecision,
sessionId,
);
if (!consultationTime
|| (requiresBirthTimeConsent(profile)
&& !hasSessionConsent
&& !consentGrantedForRequest)) {
if (consultationRoute.kind === "choice") {
setPendingBirthTimeChoice({
sessionId,
question,
entrypoint,
theme,
});
setComposerNotice(consultationTime
setComposerNotice(consultationRoute.canUseUnverifiedTime
? "请选择在当前聊天临时使用填报时间,或先校正再询问。"
: "你还没有可使用的具体出生分钟,可以先校正再询问。");
: "你还没有可使用的具体出生分钟,可以先校正,或改问不依赖出生分钟的一般问题。");
return;
}
@@ -2041,7 +2100,7 @@ export default function Home() {
}
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = consultationTime.split(":").map(Number);
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
const preservedMessages = onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
@@ -2138,19 +2197,22 @@ export default function Home() {
body: JSON.stringify({
requestId,
modelId: currentSession.modelId,
entrypoint: entrypoint ?? undefined,
name: profile.name,
year,
month,
day,
hour,
minute,
city: birthPlace.label,
lat: birthPlace.lat,
lon: birthPlace.lon,
tz: birthPlace.tz,
consultationMode: consultationRoute.mode,
...(consultationRoute.mode === "general_no_birth_time" ? {} : {
entrypoint: entrypoint ?? undefined,
year,
month,
day,
hour,
minute,
city: birthPlace.label,
lat: birthPlace.lat,
lon: birthPlace.lon,
tz: birthPlace.tz,
entryMode: "direct_chart" as const,
}),
theme,
entryMode: profile.birthTimeStatus === "confirmed" ? "direct_chart" : "rectification",
question,
history: currentSession.messages.slice(-12).map((message) => ({
role: message.role,
@@ -2287,10 +2349,32 @@ export default function Home() {
setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent(
current,
activeSession.id,
"unverified_birth_time",
));
setPendingBirthTimeChoice(null);
setComposerNotice("本次聊天会标明出生时间尚未校正;新聊天会重新提醒。");
void send(pending.question, pending.theme, pending.entrypoint, true);
void send(pending.question, pending.theme, pending.entrypoint, "unverified_birth_time");
}
function continueGenerallyWithoutBirthTime() {
if (!pendingBirthTimeChoice
|| !activeSession
|| pendingBirthTimeChoice.sessionId !== activeSession.id
|| canUseUnverifiedBirthTime(profile)) return;
const pending = pendingBirthTimeChoice;
setBirthTimeConsultationConsent((current) => grantBirthTimeConsultationConsent(
current,
activeSession.id,
"general_no_birth_time",
));
setDraft(pending.question);
setDraftTheme("general");
// Product entrypoints such as "今日星语" require a chart. General mode
// deliberately restores only visible text and never carries hidden routing.
setDraftEntrypoint(null);
setPendingBirthTimeChoice(null);
setComposerNotice("原问题尚未发送,也没有扣点。请把它改成不依赖个人出生分钟的一般问题后再发送。");
window.requestAnimationFrame(() => composerInput.current?.focus());
}
function rectifyBeforePendingConsultation() {
@@ -2465,7 +2549,7 @@ export default function Home() {
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading"><b></b><small></small></div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => ({ ...current, ...patch }))} />
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button></div>
</fieldset>
@@ -2513,7 +2597,7 @@ export default function Home() {
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && !pendingBirthTimeChoice && (onboardingPending ? (
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && !activeBirthTimeChoice && (onboardingPending ? (
<div className="starter-loading" role="status"></div>
) : (
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
@@ -2544,7 +2628,7 @@ export default function Home() {
className="product-entrypoint-hitarea"
type="button"
aria-label={`${rectificationCardLabel},固定费用 ${account.rectificationPriceCredits}`}
disabled={productEntrypointsDisabled || rectificationLoading}
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending}
onClick={() => void openBirthTimeRectification()}
/>
<div className="daily-starlanguage-heading">
@@ -2599,12 +2683,13 @@ export default function Home() {
<div ref={conversationEnd} />
</div>
)}
{pendingBirthTimeChoice?.sessionId === activeSession?.id && (
{activeBirthTimeChoice && (
<UnverifiedBirthTimeChoice
canUseUnverifiedTime={canUseUnverifiedBirthTime(profile)}
pending={rectificationLoading}
unverifiedTime={unverifiedBirthTime(profile)}
onCancel={cancelPendingBirthTimeChoice}
onContinueGenerally={continueGenerallyWithoutBirthTime}
onRectifyFirst={rectifyBeforePendingConsultation}
onUseUnverifiedTime={useUnverifiedTimeForPendingConsultation}
/>
@@ -2620,9 +2705,13 @@ export default function Home() {
<div className="onboarding-card-actions">
<button
className="button-secondary"
disabled={rectificationLoading}
disabled={rectificationLoading || rectificationMutationPending}
type="button"
onClick={() => setRectificationSurfaceOpen(false)}
onClick={() => {
if (!rectificationLoading && !rectificationMutationPending) {
setRectificationSurfaceOpen(false);
}
}}
>
</button>
@@ -2644,6 +2733,7 @@ export default function Home() {
<ConversationalBirthTimeRectification
initialTurn={rectificationInitialTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
/>
)}
@@ -2655,7 +2745,7 @@ export default function Home() {
{activeSuggestions.length > 0 && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
<button key={question} type="button" disabled={!account || !modelCatalog || isLoading || cancellationPending || Boolean(pendingBirthTimeChoice) || rectificationSurfaceOpen} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
<button key={question} type="button" disabled={!account || !modelCatalog || isLoading || cancellationPending || Boolean(activeBirthTimeChoice) || rectificationSurfaceOpen} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
))}
</div>
)}
@@ -2674,7 +2764,7 @@ export default function Home() {
: "例如:未来半年是否适合换工作?"}
rows={1}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
disabled={isLoading || cancellationPending || Boolean(pendingBirthTimeChoice) || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
disabled={isLoading || cancellationPending || Boolean(activeBirthTimeChoice) || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
@@ -2696,7 +2786,7 @@ export default function Home() {
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || Boolean(pendingBirthTimeChoice) || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || Boolean(activeBirthTimeChoice) || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}