fix(frontend): streamline birth time onboarding

This commit is contained in:
Jesse_Chen
2026-07-22 02:35:31 +08:00
parent 153869fee4
commit d5453681ad
18 changed files with 408 additions and 154 deletions
+48
View File
@@ -169,3 +169,51 @@
- 相关记录:ERR-091
- 复发自:无
- 修复版本:`b981c4e`
## BUG-009 | 未校正的已填报时间被降级为零星盘咨询
- 状态:resolved
- 首次发现:2026-07-22
- 最近更新:2026-07-22
- 影响面:生日初始化、普通咨询路由、生时校正提示
- 用户现象:已填写具体出生时间但没有完成生时校正时,普通问题仍提示只能回答一般知识或必须先完成校正。
- 触发条件:出生时间来源包含有效具体分钟,但状态不是 `confirmed`,且当前聊天没有旧的临时授权状态。
- 根因:前端把“使用未校正填报时间”设计成逐会话授权;没有授权时默认退回 `general_no_birth_time`,因此完全绕过现有的未校正星盘安全模式。
- 修复:具体填报时间现在自动进入 `unverified_birth_time`,保留禁止精确应期的安全边界但不禁用个人分析;无具体分钟时直接进入无分钟模式;移除校正 toast、弹窗和每条回答前重复的校正警告,并把生日入口收敛为“知道准确时间 / 不确定准确时间”两个选择。
- 验证:`frontend/tests/birth-time-consultation-consent.test.ts``frontend/tests/consultation-entrypoint.test.ts``frontend/tests/consultation-birth-time-mode.test.ts``frontend/tests/birth-time-intake.test.ts`
- 防复发:咨询路由测试锁定“有效填报分钟无需授权即可使用”;页面契约禁止重新引入生时校正 toast 或阻断式选择。
- 相关记录:BUG-003、BUG-004
- 复发自:无
- 修复版本:待提交(本地可测)
## BUG-010 | 浏览器直连 Supabase 写会话泄露 `TypeError: Load failed`
- 状态:resolved
- 首次发现:2026-07-22
- 最近更新:2026-07-22
- 影响面:回答完成后的聊天记录持久化、移动 Safari 错误提示
- 用户现象:回答已经生成,但页面反复显示“云端同步失败:TypeError: Load failed”,并要求复制保存后重试。
- 触发条件:浏览器直接向 Supabase `chat_sessions` 发起跨域写入时发生传输失败。
- 根因:会话读取和多数业务写入已经使用同源 Next.js API,但会话创建与更新仍由浏览器客户端直写 Supabase;异常原文又被拼进回答错误区域。
- 修复:新增同源 `POST /api/sessions``PATCH /api/sessions/[id]`,服务端校验登录、所有权和写入负载;客户端对可重试失败短重试一次,并把最终失败降级为输入区状态提示,不再把浏览器异常原文渲染成回答错误。
- 验证:`frontend/tests/chat-session-write.test.ts` 覆盖同源路由、所有者约束、短重试和 `Load failed` 脱敏;相关咨询与资料回归测试通过。
- 防复发:会话写入契约禁止页面直接调用 `supabase.from("chat_sessions")`;网络异常必须映射为稳定用户文案。
- 相关记录:BUG-001、BUG-003
- 复发自:无
- 修复版本:待提交(本地可测)
## BUG-011 | 对话消息暴露内部证据审计状态
- 状态:resolved
- 首次发现:2026-07-22
- 最近更新:2026-07-22
- 影响面:普通问答消息、Agent 回答顶部区域
- 用户现象:回答正文上方显示“证据状态:not-applicable”和“证据链摘要 · not-applicable”等工程审计信息。
- 触发条件:任意已完成的 Agent 回答,尤其是后端返回 `not-applicable` 时。
- 根因:消息行对每条非思考态 Agent 消息无条件渲染 claim boundary 与 Technique Audit Table;未识别状态又直接回退显示原始状态值。
- 修复:从普通聊天消息行移除内部证据徽章与审计面板;证据状态和 workflow receipt 仍随消息保存并供内部约束及报告生成使用。
- 验证:`frontend/tests/claim-boundary-badge.test.ts``frontend/tests/evidence-audit-panel.test.ts` 锁定聊天消息不再挂载两个内部组件。
- 防复发:聊天消息渲染契约禁止直接展示 `techniqueTruth``workflowReceipt`;需要运营或调试时使用独立的受控界面。
- 相关记录:BUG-009
- 复发自:无
- 修复版本:待提交(本地可测)
@@ -1,9 +1,36 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { chatSessionWriteSchema } from "@/lib/chat-session-write-contract";
type RouteContext = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
const parsed = chatSessionWriteSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.update(parsed.data)
.eq("id", id)
.eq("user_id", user.id)
.select("id")
.maybeSingle();
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const { id } = await context.params;
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export async function POST(request: Request) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const parsed = chatSessionCreateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const { id, ...values } = parsed.data;
const { error } = await supabase.from("chat_sessions").insert({
id,
user_id: user.id,
...values,
});
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
return NextResponse.json({ ok: true }, { status: 201 });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
}
}
+8 -48
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import {
@@ -11,7 +11,6 @@ import {
type BirthTimeAssessmentPhase,
} from "@/components/birth-time-assessment-overlay";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { BirthTimeSoftNotice } from "@/components/birth-time-soft-notice";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
@@ -68,7 +67,7 @@ import {
import { defaultGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
import { keepFocusWithin } from "@/lib/focus-trap";
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
import { persistExistingChatSession } from "@/lib/chat-session-persistence";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
OnboardingAuthenticationError,
@@ -780,7 +779,6 @@ export default function Home() {
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
createBirthTimeConsultationConsentState,
);
const [birthTimeSoftNotice, setBirthTimeSoftNotice] = useState("");
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationTurn | null>(null);
@@ -873,7 +871,6 @@ export default function Home() {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
const dismissBirthTimeSoftNotice = useCallback(() => setBirthTimeSoftNotice(""), []);
useEffect(() => {
if (!hydrated
@@ -1347,7 +1344,6 @@ export default function Home() {
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
const supabase = createBrowserSupabaseClient();
const values = {
title: session.title,
theme: session.theme,
@@ -1357,26 +1353,7 @@ export default function Home() {
rectification_case_id: session.rectificationCaseId,
updated_at: new Date(session.updatedAt).toISOString(),
};
if (mode === "create") {
const { error } = await supabase.from("chat_sessions").insert({
id: session.id,
user_id: account.user.id,
...values,
});
if (error) throw new Error(`云端同步失败:${error.message}`);
return;
}
await persistExistingChatSession(async () => {
const { data, error } = await supabase
.from("chat_sessions")
.update(values)
.eq("id", session.id)
.eq("user_id", account.user.id)
.select("id")
.maybeSingle();
return { found: Boolean(data), error: error?.message ?? null };
});
await writeChatSession(session.id, values, mode);
}
async function renameSession(session: ChatSession) {
@@ -2351,15 +2328,6 @@ export default function Home() {
consentForDecision,
sessionId,
);
if (initialConsultationRoute.kind === "choice") {
const generalConsent = grantBirthTimeConsultationConsent(
consentForDecision,
sessionId,
"general_no_birth_time",
);
setBirthTimeConsultationConsent(generalConsent);
setBirthTimeSoftNotice("出生时间尚未校正,本次将按不依赖具体分钟的一般咨询回答。");
}
const consultationRoute = initialConsultationRoute.kind === "choice"
? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null }
: initialConsultationRoute;
@@ -2551,10 +2519,8 @@ export default function Home() {
try {
await persistSession(completedSession);
} catch (caught) {
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "云端同步失败"} 回答仍保留在当前页面,请复制保存后重试。`,
});
void caught;
setComposerNotice("回答已保留;网络恢复后,下一次对话会继续同步完整记录。");
}
void refreshAccount();
return true;
@@ -2603,10 +2569,8 @@ export default function Home() {
message: "回答中途断开,已保留生成内容;请复制现有内容或继续追问,系统正在以账户记录为准同步点数。",
});
} catch (persistError) {
setRequestError({
sessionId,
message: `${persistError instanceof Error ? persistError.message : "云端同步失败"} 部分回答仍保留在当前页面,请复制保存后继续追问。`,
});
void persistError;
setComposerNotice("已保留当前回答;网络恢复后,下一次对话会继续同步完整记录。");
}
if (activeSessionIdRef.current === sessionId) {
setComposerNotice("回答中途断开,已保留现有内容;请继续追问或复制保存。");
@@ -2798,10 +2762,6 @@ export default function Home() {
return (
<SidebarProvider escapeBlocked={accountMenuOpen || activeAccountDialog !== null}>
<main className="chat-app">
<BirthTimeSoftNotice
message={birthTimeSoftNotice}
onDismiss={dismissBirthTimeSoftNotice}
/>
<AppSidebar
sessions={sidebarSessions}
activeSessionId={activeSession?.id ?? null}
@@ -3112,7 +3072,7 @@ export default function Home() {
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo"
? "已加入发送队列,2.5 秒内可免费撤回。"
: !profileComplete
? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "完成上方生时校正后可提问" : "请先完成上方资料"
? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料"
: "Enter 发送 · Shift + Enter 换行")}</p>
</div>
</div>}
+62 -31
View File
@@ -55,6 +55,9 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed";
const displayState = birthTimeDisplayState(value);
const knowledgeMode = source === "period_only" || source === "unknown"
? "uncertain"
: source ? "exact" : "";
const usesClockTime = source === "hospital_record"
|| source === "family_exact"
|| source === "approximate"
@@ -96,15 +99,19 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
)}
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend></legend>
<legend></legend>
<div className="birth-time-source-list">
{birthTimeSourceOptions.map((option) => (
<label
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
className={`birth-time-source-option ${option.value === "family_exact"
? knowledgeMode === "exact" ? "is-selected" : ""
: knowledgeMode === "uncertain" ? "is-selected" : ""}`}
key={option.value}
>
<input
checked={source === option.value}
checked={option.value === "family_exact"
? knowledgeMode === "exact"
: knowledgeMode === "uncertain"}
name={`birth-time-source-${groupId}`}
type="radio"
value={option.value}
@@ -139,7 +146,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
{source === "hospital_record" && (
<p className="birth-time-detail-note"> 2 D9 / D10 </p>
)}
{(source === "family_exact" || source === "approximate") && (
{source === "approximate" && (
<label>
<span></span>
<select
@@ -149,7 +156,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
onPatch({ uncertaintyBeforeMinutes: minutes, uncertaintyAfterMinutes: minutes });
}}
>
{(source === "family_exact" ? [5, 10, 15] : [15, 30, 60]).map((minutes) => (
{[15, 30, 60].map((minutes) => (
<option key={minutes} value={minutes}> {minutes} </option>
))}
</select>
@@ -159,35 +166,59 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
)}
{source === "period_only" && (
<label className="onboarding-card-reveal">
<span></span>
<select
required
value={value.birthTimePeriod}
onChange={(event) => {
const period = birthTimePeriodOptions.find((option) => option.value === event.target.value);
if (period) onPatch({ birthTimePeriod: period.value });
}}
>
<option value="" disabled></option>
{birthTimePeriodOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<div className="birth-time-detail-grid onboarding-card-reveal">
<label>
<span></span>
<select
required
value={value.birthTimePeriod}
onChange={(event) => {
const period = birthTimePeriodOptions.find((option) => option.value === event.target.value);
if (period) onPatch({ birthTimePeriod: period.value });
}}
>
<option value="" disabled></option>
{birthTimePeriodOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>
<span></span>
<textarea
maxLength={240}
placeholder="例如:天刚亮、午饭前后、家人记得大约 6—8 点"
rows={2}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
<button
className="button-secondary"
type="button"
onClick={() => onPatch({
birthTimeSource: "unknown",
reportedTime: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "reported",
time: "",
})}
></button>
</div>
)}
{source === "unknown" && (
<label className="onboarding-card-reveal">
<span>线</span>
<textarea
maxLength={240}
placeholder="例如:家人只记得天黑以后,或可以再询问长辈"
rows={3}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
<div className="birth-time-detail-note onboarding-card-reveal" role="status">
<p>使</p>
<button
className="button-secondary"
type="button"
onClick={() => onPatch({ birthTimeSource: "period_only", birthTimeStatus: "reported" })}
></button>
</div>
)}
</div>
);
+1 -3
View File
@@ -1,6 +1,4 @@
import { ChatMessageContent } from "@/components/chat-message-content";
import { ClaimBoundaryBadge } from "@/components/claim-boundary-badge";
import { EvidenceAuditPanel } from "@/components/evidence-audit-panel";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { consultationReportMarkdown, downloadMarkdownReport } from "@/lib/consultation-report-export";
@@ -26,7 +24,7 @@ export function ChatMessageRow({ message }: { readonly message: ChatMessageView
{message.role === "assistant" ? (
message.state === "thinking"
? <div className="thinking"><i /><i /><i /></div>
: <><ClaimBoundaryBadge status={message.techniqueTruth} /><EvidenceAuditPanel claimStatus={message.techniqueTruth} workflowReceipt={message.workflowReceipt} /><ChatMessageContent text={message.text} /><button className="report-download-button" type="button" onClick={() => downloadMarkdownReport("Jyotisha 咨询报告", consultationReportMarkdown({ title: "Jyotisha 咨询报告", messages: [message] }))}></button></>
: <><ChatMessageContent text={message.text} /><button className="report-download-button" type="button" onClick={() => downloadMarkdownReport("Jyotisha 咨询报告", consultationReportMarkdown({ title: "Jyotisha 咨询报告", messages: [message] }))}></button></>
) : <p>{message.text}</p>}
</div>
</div>
@@ -86,13 +86,14 @@ export function canUseUnverifiedBirthTime(profile: BirthTimeDraft): boolean {
}
export function requiresBirthTimeConsent(profile: BirthTimeDraft): boolean {
return canUseUnverifiedBirthTime(profile);
void profile;
return false;
}
export function birthTimeConsultationOptionsCopy(profile: BirthTimeDraft): string {
return canUseUnverifiedBirthTime(profile)
? "你可以在当前聊天临时使用原始填报时间询问,也可以先校正。"
: "你可以继续不依赖出生分钟的一般咨询,也可以先校正;系统不会替你生成具体出生分钟。";
? "当前分析会直接使用你填报时间;生时校正是可选增强,不影响使用其他功能。"
: "当前分析会使用已有的日期与地点资料;生时校正是可选增强,系统不会替你生成具体出生分钟。";
}
export type BirthTimeConsultationRoute =
@@ -105,21 +106,19 @@ export type BirthTimeConsultationRoute =
export function resolveBirthTimeConsultationRoute(
profile: BirthTimeDraft,
state: BirthTimeConsultationConsentState,
sessionId: string,
_state: BirthTimeConsultationConsentState,
_sessionId: string,
): BirthTimeConsultationRoute {
void _state;
void _sessionId;
if (profile.birthTimeStatus === "confirmed" && isBirthClockTime(profile.time)) {
return { kind: "consult", mode: "verified_chart", time: profile.time };
}
const reportedTime = unverifiedBirthTime(profile);
const consentMode = consultationModeForSession(state, sessionId);
if (consentMode === "general_no_birth_time") {
return { kind: "consult", mode: "general_no_birth_time", time: null };
}
if (reportedTime && consentMode === "unverified_birth_time") {
if (reportedTime) {
return { kind: "consult", mode: "unverified_birth_time", time: reportedTime };
}
return { kind: "choice", canUseUnverifiedTime: reportedTime !== null };
return { kind: "consult", mode: "general_no_birth_time", time: null };
}
export function resolveRectificationCardAction(input: Readonly<{
+4 -7
View File
@@ -71,11 +71,8 @@ export function formatBirthDate(value: Date): string {
}
export const birthTimeSourceOptions = [
{ value: "hospital_record", label: "出生证明或医院记录", hint: "先检查前后两分钟是否稳定" },
{ value: "family_exact", label: "家人明确记得具体时间", hint: "进行 5—15 分钟轻量校正" },
{ value: "approximate", label: "只记得大概几点", hint: "按你选择的误差范围扫描" },
{ value: "period_only", label: "只知道早晨、上午、下午或晚上", hint: "先从时段范围做粗筛" },
{ value: "unknown", label: "完全不知道", hint: "不要求你随便填写具体时间" },
{ value: "family_exact", label: "我知道准确出生时间", hint: "填写后直接用于当前分析,无需先做生时校正" },
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
] as const;
export const birthTimePeriodOptions = [
@@ -276,11 +273,11 @@ export function describeBirthTimeDraft(draft: BirthTimeDraft) {
case "hospital_record":
return `${date} ${draft.reportedTime}(医院记录)`;
case "family_exact":
return `${date} ${draft.reportedTime}家人明确记得,前后 ${draft.uncertaintyBeforeMinutes} 分钟`;
return `${date} ${draft.reportedTime}填报准确时间`;
case "approximate":
return `${date},约 ${draft.reportedTime}(前后 ${draft.uncertaintyBeforeMinutes} 分钟)`;
case "period_only":
return `${date}${periodLabels[draft.birthTimePeriod]}`;
return `${date}${periodLabels[draft.birthTimePeriod]}${draft.birthTimeClue.trim() ? `${draft.birthTimeClue.trim()}` : ""}`;
case "unknown":
return `${date},具体时间未知`;
case "legacy_import":
@@ -0,0 +1,87 @@
import { z } from "zod";
const chatMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(100_000),
suggestions: z.array(z.string().max(200)).max(3).optional(),
techniqueTruth: z.string().max(120).optional(),
workflowReceipt: z.object({
route: z.string().max(120),
status: z.string().max(120),
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
}).strict().optional(),
}).strict();
export const chatSessionWriteSchema = z.object({
title: z.string().trim().min(1).max(160),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
model_id: z.string().trim().min(1).max(64),
messages: z.array(chatMessageSchema).max(500),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
updated_at: z.string().datetime(),
}).strict();
export const chatSessionCreateSchema = chatSessionWriteSchema.extend({
id: z.string().uuid(),
}).strict();
export type ChatSessionWrite = Readonly<{
title: string;
theme: "career" | "marriage" | "wealth" | "timing" | "general";
model_id: string;
messages: readonly Readonly<{
role: "user" | "assistant";
text: string;
suggestions?: readonly string[];
techniqueTruth?: string;
workflowReceipt?: Readonly<{
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
}>;
}>[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
updated_at: string;
}>;
function retryableStatus(status: number) {
return status === 408 || status === 429 || status >= 500;
}
class TerminalChatSessionWriteError extends Error {}
export async function writeChatSession(
id: string,
values: ChatSessionWrite,
mode: "create" | "update",
fetcher: typeof fetch = fetch,
): Promise<void> {
const url = mode === "create" ? "/api/sessions" : `/api/sessions/${encodeURIComponent(id)}`;
const body = mode === "create" ? { id, ...values } : values;
let lastError = "云端同步暂时不可用";
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await fetcher(url, {
method: mode === "create" ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null) as { error?: string } | null;
if (response.ok) return;
lastError = payload?.error || "云端同步暂时不可用";
if (!retryableStatus(response.status)) throw new TerminalChatSessionWriteError(lastError);
} catch (error) {
if (error instanceof TerminalChatSessionWriteError) throw error;
lastError = error instanceof TypeError
? "网络暂时不可用,云端记录尚未更新"
: error instanceof Error ? error.message : lastError;
}
if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(lastError);
}
@@ -32,7 +32,6 @@ export function applyBirthTimeModeToWorkflowContext<
...context,
consumer_context: {
...context.consumer_context,
user_facing_limitation: UNVERIFIED_BIRTH_TIME_NOTICE,
answer_policy: {
...context.consumer_context.answer_policy,
can_answer_precise_timing: false,
@@ -44,21 +43,16 @@ export function applyBirthTimeModeToWorkflowContext<
}
/**
* Server-side output boundary. The visible notice is added once to each HTTP
* answer stream, while timing/guarantee filtering remains active for the full
* answer whenever the consultation does not have a confirmed birth minute.
* Server-side output boundary. Timing and guarantee filtering remains active
* without inserting a rectification warning into every answer.
*/
export function createBirthTimeModeOutputGuard(
mode: ConsultationBirthTimeMode,
canAnswerPreciseTiming: boolean,
): (text: string) => string {
let noticeWritten = false;
return (text) => {
const guarded = mode === "general_no_birth_time"
return mode === "general_no_birth_time"
? guardGeneralNoBirthTimeOutput(text)
: canAnswerPreciseTiming ? text : guardPreciseTimingOutput(text);
if (mode !== "unverified_birth_time" || noticeWritten || !guarded.trim()) return guarded;
noticeWritten = true;
return `> ${UNVERIFIED_BIRTH_TIME_NOTICE}\n\n${guarded}`;
};
}
@@ -29,12 +29,17 @@ const reportedExactTime = {
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
test("an unverified concrete time requires consent only until this chat grants it", () => {
test("an unverified concrete time is usable immediately without a consent gate", () => {
const initial = createBirthTimeConsultationConsentState();
assert.equal(canUseUnverifiedBirthTime(reportedExactTime), true);
assert.equal(requiresBirthTimeConsent(reportedExactTime), true);
assert.equal(requiresBirthTimeConsent(reportedExactTime), false);
assert.equal(hasBirthTimeConsultationConsent(initial, "chat-a"), false);
assert.deepEqual(resolveBirthTimeConsultationRoute(reportedExactTime, initial, "chat-a"), {
kind: "consult",
mode: "unverified_birth_time",
time: "05:30",
});
const consented = grantBirthTimeConsultationConsent(initial, "chat-a");
assert.equal(hasBirthTimeConsultationConsent(consented, "chat-a"), true);
@@ -61,11 +66,11 @@ test("period-only and unknown declarations never pretend to provide an unverifie
assert.equal(canUseUnverifiedBirthTime(unknown), false);
assert.equal(requiresBirthTimeConsent(periodOnly), false);
assert.equal(requiresBirthTimeConsent(unknown), false);
assert.match(birthTimeConsultationOptionsCopy(periodOnly), /一般咨询.*校正/);
assert.match(birthTimeConsultationOptionsCopy(unknown), /一般咨询.*校正/);
assert.match(birthTimeConsultationOptionsCopy(periodOnly), /日期与地点.*可选增强/);
assert.match(birthTimeConsultationOptionsCopy(unknown), /日期与地点.*可选增强/);
assert.doesNotMatch(birthTimeConsultationOptionsCopy(periodOnly), /使用.*原始填报时间|具体原始时间/);
assert.doesNotMatch(birthTimeConsultationOptionsCopy(unknown), /使用.*原始填报时间|具体原始时间/);
assert.match(birthTimeConsultationOptionsCopy(reportedExactTime), /原始填报时间.*校正/);
assert.match(birthTimeConsultationOptionsCopy(reportedExactTime), /直接使用你填报时间.*可选增强/);
});
test("the current reported minute wins over an old candidate and never falls back to it", () => {
@@ -95,7 +100,7 @@ test("the current reported minute wins over an old candidate and never falls bac
assert.match(page, /persistedReportedTime \|\| \(source === "legacy_import" \? time : ""\)/);
});
test("period-only users can explicitly choose a minute-free consultation without carrying it to another chat", () => {
test("period-only users enter minute-free consultation directly without a blocking choice", () => {
const periodOnly = {
...reportedExactTime,
reportedTime: "",
@@ -105,8 +110,9 @@ test("period-only users can explicitly choose a minute-free consultation without
const initial = createBirthTimeConsultationConsentState();
assert.deepEqual(resolveBirthTimeConsultationRoute(periodOnly, initial, "chat-a"), {
kind: "choice",
canUseUnverifiedTime: false,
kind: "consult",
mode: "general_no_birth_time",
time: null,
});
const general = grantBirthTimeConsultationConsent(initial, "chat-a", "general_no_birth_time");
assert.equal(consultationModeForSession(general, "chat-a"), "general_no_birth_time");
@@ -117,8 +123,9 @@ test("period-only users can explicitly choose a minute-free consultation without
time: null,
});
assert.deepEqual(resolveBirthTimeConsultationRoute(periodOnly, general, "chat-b"), {
kind: "choice",
canUseUnverifiedTime: false,
kind: "consult",
mode: "general_no_birth_time",
time: null,
});
});
@@ -144,7 +151,7 @@ test("unverified consent resolves to a chart request and confirmed profiles need
});
});
test("minute-free mode ignores an unverified reported minute", () => {
test("a stale minute-free preference cannot suppress the user's current reported time", () => {
const general = grantBirthTimeConsultationConsent(
createBirthTimeConsultationConsentState(),
"chat-a",
@@ -153,8 +160,8 @@ test("minute-free mode ignores an unverified reported minute", () => {
assert.deepEqual(resolveBirthTimeConsultationRoute(reportedExactTime, general, "chat-a"), {
kind: "consult",
mode: "general_no_birth_time",
time: null,
mode: "unverified_birth_time",
time: "05:30",
});
});
@@ -208,21 +215,25 @@ test("account refresh identities reject an older response after a newer case req
assert.equal(guard.isCurrent(newCaseRequest), true);
});
test("unverified birth time uses a self-dismissing notice instead of a modal", () => {
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 notice = readFileSync(new URL("../src/components/birth-time-soft-notice.tsx", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const sonner = readFileSync(new URL("../src/components/ui/sonner.tsx", import.meta.url), "utf8");
assert.match(page, /<BirthTimeSoftNotice/);
assert.match(notice, /toast\("出生时间尚未校正"/);
assert.match(notice, /durationMs = 4_500/);
assert.match(notice, /window\.setTimeout\(onDismiss, durationMs\)/);
assert.match(layout, /<Toaster \/>/);
assert.match(sonner, /from "sonner"/);
assert.doesNotMatch(page, /<BirthTimeSoftNotice|setBirthTimeSoftNotice/);
assert.doesNotMatch(page, /role="alertdialog"[\s\S]{0,300}出生时间还没有完成校正/);
});
test("birth time intake starts with two simple choices and keeps rectification optional", () => {
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
const model = readFileSync(new URL("../src/lib/birth-time-intake-model.ts", import.meta.url), "utf8");
assert.match(model, /我知道准确出生时间/);
assert.match(model, /我不确定准确时间/);
assert.doesNotMatch(intake, /source === "family_exact" \|\| source === "approximate"/);
assert.match(intake, /请选择最接近的时间范围/);
assert.match(intake, /完全不清楚,跳过出生时间/);
assert.match(intake, /生时校正以后需要时再做/);
});
test("homepage and profile result copy use the source-aware consultation options", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
const values = {
title: "事业方向",
theme: "career",
model_id: "gpt-test",
messages: [{ role: "user", text: "你好" }],
session_type: "consultation",
rectification_case_id: null,
updated_at: "2026-07-22T00:00:00.000Z",
} satisfies ChatSessionWrite;
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, values, "update", async (url, init) => {
calls.push({ url: String(url), init });
return Response.json({ ok: true });
});
assert.equal(calls.length, 1);
assert.equal(calls[0]?.url, `/api/sessions/${sessionId}`);
assert.equal(calls[0]?.init?.method, "PATCH");
assert.equal(calls[0]?.init?.credentials, "same-origin");
});
test("transient Load failed is retried and never exposed as raw browser copy", async () => {
let attempts = 0;
await assert.rejects(
writeChatSession(sessionId, values, "update", async () => {
attempts += 1;
throw new TypeError("Load failed");
}),
(error: unknown) => error instanceof Error
&& error.message === "网络暂时不可用,云端记录尚未更新",
);
assert.equal(attempts, 2);
});
test("owner or validation failures are not retried", async () => {
let attempts = 0;
await assert.rejects(
writeChatSession(sessionId, values, "update", async () => {
attempts += 1;
return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}),
/聊天记录不存在或已被删除/,
);
assert.equal(attempts, 1);
});
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 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");
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
assert.match(collectionRoute, /export async function POST/);
assert.match(itemRoute, /export async function PATCH/);
assert.match(itemRoute, /\.eq\("user_id", user\.id\)/);
});
+3 -3
View File
@@ -6,9 +6,9 @@ const rowSource = readFileSync(new URL("../src/components/chat-message-row.tsx",
const badgeSource = readFileSync(new URL("../src/components/claim-boundary-badge.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("assistant messages render a claim boundary badge from technique truth", () => {
assert.match(rowSource, /ClaimBoundaryBadge/);
assert.match(rowSource, /status=\{message\.techniqueTruth\}/);
test("assistant messages do not expose the internal claim boundary badge", () => {
assert.doesNotMatch(rowSource, /ClaimBoundaryBadge/);
assert.doesNotMatch(rowSource, /status=\{message\.techniqueTruth\}/);
assert.match(badgeSource, /不把未闭环内容包装成确定预测/);
assert.match(badgeSource, /observation_only/);
assert.match(badgeSource, /reference_only/);
@@ -41,12 +41,12 @@ test("unverified chart context can never become confirmed or retain precise timi
});
});
test("every unverified streamed answer receives a stable visible marker and timing guard", () => {
test("unverified answers keep the timing guard without a repetitive rectification warning", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const first = transform("2026年8月适合观察方向。");
const second = transform("你一定会升职。");
assert.match(first, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.doesNotMatch(first, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.match(first, /具体时间已省略/);
assert.doesNotMatch(second, /一定会升职/);
assert.doesNotMatch(second, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
@@ -189,16 +189,15 @@ test("completed handoffs automatically return and continue the source question",
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
});
test("ordinary consultation falls back to minute-free mode with a non-blocking notice", () => {
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 sendStart = source.indexOf("async function send(");
const consultCall = source.indexOf('fetch("/api/consult"', sendStart);
const softNotice = source.indexOf("setBirthTimeSoftNotice", sendStart);
assert.ok(sendStart >= 0);
assert.ok(softNotice > sendStart && softNotice < consultCall);
assert.ok(consultCall > sendStart);
assert.match(source, /mode: "general_no_birth_time" as const/);
assert.match(source, /<BirthTimeSoftNotice/);
assert.doesNotMatch(source, /<BirthTimeSoftNotice|setBirthTimeSoftNotice/);
assert.doesNotMatch(source, /setPendingBirthTimeChoice|<UnverifiedBirthTimeChoice/);
});
@@ -211,7 +210,7 @@ test("rectification mutations report pending state while session-level return co
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
});
test("birth-time soft notice dismisses itself without blocking session changes", () => {
test("session changes contain no birth-time notice state", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const selectSession = source.slice(
source.indexOf("function selectSession("),
@@ -219,7 +218,7 @@ test("birth-time soft notice dismisses itself without blocking session changes",
);
assert.doesNotMatch(selectSession, /birthTimeSoftNotice|setBirthTimeSoftNotice/);
assert.match(source, /dismissBirthTimeSoftNotice/);
assert.doesNotMatch(source, /dismissBirthTimeSoftNotice/);
});
test("profile and place saves do not auto-start the retired assessment flow", () => {
@@ -449,15 +449,15 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
let consent = createBirthTimeConsultationConsentState();
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
kind: "choice", canUseUnverifiedTime: true,
kind: "consult", mode: "unverified_birth_time", time: "05:30",
});
consent = grantBirthTimeConsultationConsent(consent, "chat-a", "unverified_birth_time");
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
kind: "consult", mode: "unverified_birth_time", time: "05:30",
});
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-b").kind, "choice");
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-b").kind, "consult");
consent = clearBirthTimeConsultationConsent(consent, "chat-a");
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "choice");
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "consult");
const packetEvidenceCalls: string[][] = [];
const backend = createSyntheticBackend({ packetEvidenceCalls });
+4 -4
View File
@@ -7,10 +7,10 @@ const panelSource = readFileSync(new URL("../src/components/evidence-audit-panel
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("assistant messages include a collapsible Technique Audit Table shell", () => {
assert.match(rowSource, /EvidenceAuditPanel/);
assert.match(rowSource, /claimStatus=\{message\.techniqueTruth\}/);
assert.match(rowSource, /workflowReceipt=\{message\.workflowReceipt\}/);
test("assistant messages do not expose the internal Technique Audit Table", () => {
assert.doesNotMatch(rowSource, /EvidenceAuditPanel/);
assert.doesNotMatch(rowSource, /claimStatus=\{message\.techniqueTruth\}/);
assert.doesNotMatch(rowSource, /workflowReceipt=\{message\.workflowReceipt\}/);
assert.match(panelSource, /Technique Audit Table/);
assert.match(panelSource, /Workflow route:/);
assert.match(panelSource, /Precise timing:/);
+15 -4
View File
@@ -38,6 +38,8 @@ SYNASTRY_REPORT_MIGRATION = (
/ "20260718101000_repair_missing_synastry_reports.sql"
)
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx"
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"
CHART_PROFILE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "route.ts"
CHART_PROFILE_DELETE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "[id]" / "route.ts"
@@ -93,13 +95,22 @@ 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")
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
assert 'user_id: account.user.id' in source
assert 'await writeChatSession(session.id, values, mode)' in source
assert 'mode === "create" ? "/api/sessions"' in (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "src"
/ "lib"
/ "chat-session-write-contract.ts"
).read_text(encoding="utf-8")
assert 'user_id: user.id' in create_route
assert '.eq("user_id", user.id)' in item_route
assert '.upsert(' not in source
assert '.update(values)' in source
assert '.insert({' in source
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
@@ -110,7 +121,7 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None:
assert 'if (ownsInterface && !partialReply)' in source
assert 'await persistSession(interruptedSession)' in source
assert 'await persistence' in source
assert "pendingSessionId || cancellationInFlight.current || pendingConsultation.current" in source
assert "pendingSessionId || cancellationInFlight.current" in source
assert "setCancellationPending(true)" in source
assert "系统正在以账户记录为准同步点数" in source
assert "回答中途断开,已保留现有内容,本次已计费。" not in source