fix(web): 账户弹窗移出 inert;校正快照失败不再静默(BUG-968/969)
账户弹窗与入门付费墙 portal 到 document.body,SidebarInset 的 inert 不再罩住弹窗。 GET 校正快照每个非 200 打 JSON warn;客户端失败显示「这一问还没读到」和重新读取。 下一题是可渲染选择题时,确定性回复正文带题干,挂卡后去重。
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { warnRectificationCaseGet } from "@/lib/rectification-case-get-log";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
loadV9TurnReceipt,
|
||||
RectificationToolServiceError,
|
||||
listV10ConversationFocuses,
|
||||
safeToolErrorCode,
|
||||
} from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
|
||||
|
||||
@@ -25,26 +27,49 @@ type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
* The browser restores real history from here; it never reconstructs history
|
||||
* from candidate text or local sentinels.
|
||||
*/
|
||||
function failedCaseGet(
|
||||
caseId: string,
|
||||
status: number,
|
||||
body: Record<string, unknown>,
|
||||
reason: string,
|
||||
) {
|
||||
warnRectificationCaseGet({
|
||||
caseId,
|
||||
status,
|
||||
code: typeof body.code === "string" ? body.code : `http_${status}`,
|
||||
reason,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const { caseId } = await context.params;
|
||||
const caseIdSafe = z.string().uuid().safeParse(caseId).success ? caseId : "";
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
|
||||
const response = jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
|
||||
warnRectificationCaseGet({
|
||||
caseId: caseIdSafe,
|
||||
status: response.status,
|
||||
code: "service_unavailable",
|
||||
reason: safeToolErrorCode(error),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
return failedCaseGet(caseIdSafe, 401, { error: "请先登录" }, authError ? safeToolErrorCode(authError) : "unauthenticated");
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
|
||||
if (!caseIdSafe) {
|
||||
return failedCaseGet(caseId, 400, { error: "请求内容不正确", code: "invalid_case_id" }, "invalid_case_id");
|
||||
}
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const sessionId = searchParams.get("sessionId") ?? "";
|
||||
@@ -53,7 +78,12 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
|
||||
if (sessionId && dossier.case.sessionId !== sessionId) {
|
||||
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
|
||||
return failedCaseGet(
|
||||
caseId,
|
||||
409,
|
||||
{ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" },
|
||||
"case_session_mismatch",
|
||||
);
|
||||
}
|
||||
const skillIdentity = await loadV9CaseSkillIdentityStatus(accounting, user.id, caseId);
|
||||
const receipts = await Promise.all(
|
||||
@@ -71,12 +101,19 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const message = error.message;
|
||||
if (message.includes("agentic_rectification_case_not_found")) {
|
||||
return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 });
|
||||
return failedCaseGet(
|
||||
caseId,
|
||||
404,
|
||||
{ error: "校正记录不存在或无权访问", code: "case_not_found" },
|
||||
safeToolErrorCode(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
return failedCaseGet(
|
||||
caseId,
|
||||
503,
|
||||
{ error: "暂时无法读取校正记录", code: "rectification_service_failed" },
|
||||
{ status: 503 },
|
||||
safeToolErrorCode(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChevronRight, Settings, UserRound, Users, WalletCards, X } from "lucide-react";
|
||||
import { memo, type MutableRefObject, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
|
||||
|
||||
@@ -60,7 +61,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
||||
return model.renderGeneral();
|
||||
};
|
||||
|
||||
return (
|
||||
const overlay = (
|
||||
<div className="account-modal-overlay" onMouseDown={model.close}>
|
||||
<section
|
||||
className={`account-modal ${model.dialogClass}`}
|
||||
@@ -110,4 +111,9 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
// BUG-968: SidebarInset is inert while the dialog is open. Portal to body so
|
||||
// the dialog is not inside that subtree. SSR keeps the in-tree markup so
|
||||
// renderToString contracts still see the dialog.
|
||||
if (typeof document === "undefined") return overlay;
|
||||
return createPortal(overlay, document.body);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Gift, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
|
||||
|
||||
@@ -72,7 +73,7 @@ export function OnboardingRedeemPaywall({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
const overlay = (
|
||||
<div className="account-modal-overlay" onMouseDown={close}>
|
||||
<section
|
||||
className="account-modal paywall-modal"
|
||||
@@ -133,4 +134,6 @@ export function OnboardingRedeemPaywall({
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
if (typeof document === "undefined") return overlay;
|
||||
return createPortal(overlay, document.body);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_SNAPSHOT_RETRY_LABEL,
|
||||
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
|
||||
RECTIFICATION_DELIVERED_COPY,
|
||||
isAbortError,
|
||||
@@ -74,6 +76,7 @@ import {
|
||||
interviewSessionOutcomeFromSnapshot,
|
||||
birthTimeSourceFromSnapshot,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
type RectificationSnapshotFailure,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { RectificationTimeline } from "@/components/rectification-timeline";
|
||||
import {
|
||||
@@ -430,6 +433,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
|
||||
const [questionRepairing, setQuestionRepairing] = useState(false);
|
||||
const [snapshotFailure, setSnapshotFailure] = useState<RectificationSnapshotFailure | null>(null);
|
||||
const [openingRequested, setOpeningRequested] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
@@ -633,11 +637,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) return undefined;
|
||||
if (controller.signal.aborted) return undefined;
|
||||
if (!response.ok) {
|
||||
setSnapshotFailure({ status: response.status });
|
||||
return undefined;
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) return undefined;
|
||||
const turns = snapshotTurns(payload);
|
||||
startTransition(() => {
|
||||
setSnapshotFailure(null);
|
||||
applyCaseSnapshot(payload);
|
||||
mergeMessages?.(turns);
|
||||
});
|
||||
@@ -645,8 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||||
turns,
|
||||
};
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || controller.signal.aborted) return undefined;
|
||||
setSnapshotFailure({ status: "network" });
|
||||
return undefined;
|
||||
} finally {
|
||||
if (snapshotAbort.current === controller) snapshotAbort.current = null;
|
||||
@@ -1779,7 +1789,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
{RECTIFICATION_DELIVERED_COPY}
|
||||
</p>
|
||||
)}
|
||||
{questionGap === "unavailable" && (
|
||||
{snapshotFailure && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={() => void refetchQuestion()}>
|
||||
{RECTIFICATION_SNAPSHOT_RETRY_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "unavailable" && !snapshotFailure && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
|
||||
@@ -1828,7 +1846,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? "点上面的选项即可;想补一句细节再写"
|
||||
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
|
||||
? "请回答上面的问题…"
|
||||
: questionGap === "collect_waiting"
|
||||
: !snapshotFailure && questionGap === "collect_waiting"
|
||||
? RECTIFICATION_COLLECT_WAITING_PLACEHOLDER
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY,
|
||||
rectificationCaseHref,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
@@ -188,11 +189,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
async function refreshRectificationCase(caseId: string, sessionId: string) {
|
||||
try {
|
||||
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
if (!response.ok) {
|
||||
setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
|
||||
} catch {
|
||||
// History refresh is best-effort; the stream restores live turns.
|
||||
setComposerNotice(RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from "./server-focus";
|
||||
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
|
||||
import { composeCollectSpokenAssistantText } from "./collect-prompt";
|
||||
import { renderableChoiceStem } from "./turn-question";
|
||||
import {
|
||||
blockingMethodsCovered,
|
||||
buildMethodFollowupPlan,
|
||||
@@ -1547,8 +1548,9 @@ export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDe
|
||||
|
||||
/**
|
||||
* After a collect "没有", persist the deterministic turn then bind the next
|
||||
* focus to that turn so the stem can hang on the message. Stream only the
|
||||
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
|
||||
* focus to that turn so the stem can hang on the message. Collect spoken
|
||||
* still streams the acknowledgment only (BUG-525); a renderable choice
|
||||
* streams ack+stem so the next question is not GET-only (BUG-969).
|
||||
*/
|
||||
export async function persistCollectDenialTurn(input: {
|
||||
accounting: AccountingClient;
|
||||
@@ -1567,7 +1569,8 @@ export async function persistCollectDenialTurn(input: {
|
||||
const stored = hasNextStem
|
||||
? composeCollectSpokenAssistantText(ack, input.applied.narration)
|
||||
: input.applied.narration;
|
||||
const streamText = hasNextStem ? ack : input.applied.narration;
|
||||
const choiceStem = renderableChoiceStem(input.applied.focus);
|
||||
const streamText = hasNextStem && choiceStem ? stored : hasNextStem ? ack : input.applied.narration;
|
||||
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
||||
requestId: input.requestId,
|
||||
userMessage: input.userMessage,
|
||||
@@ -2285,6 +2288,15 @@ ${nextInterview.hostNarration}`;
|
||||
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
|
||||
}
|
||||
|
||||
const choiceStem = nextChoiceReady ? renderableChoiceStem(nextFocus) : null;
|
||||
if (choiceStem) {
|
||||
const body = skipThisProbe
|
||||
? composeCollectSpokenAssistantText(input.narration, hostNarration)
|
||||
: hostNarration;
|
||||
hostNarration = composeCollectSpokenAssistantText(body, choiceStem);
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
|
||||
if (
|
||||
command.deferFollowup !== true
|
||||
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
|
||||
|
||||
@@ -42,6 +42,15 @@ export function turnQuestionKind(focus: {
|
||||
return "collect_spoken";
|
||||
}
|
||||
|
||||
export function renderableChoiceStem(focus: ConversationFocus | null | undefined): string | null {
|
||||
if (!focus) return null;
|
||||
const question = turnQuestionFromFocus(focus);
|
||||
if (!question) return null;
|
||||
if (question.kind !== "choice" && question.kind !== "reverse_verify") return null;
|
||||
const prompt = question.prompt.trim();
|
||||
return prompt || null;
|
||||
}
|
||||
|
||||
export function turnQuestionFromFocus(focus: ConversationFocus): TurnQuestion | null {
|
||||
const prompt = focusSpokenPrompt(focus.expectedAnswerSchema);
|
||||
if (!prompt || !focus.id || !focus.questionId) return null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* GET /api/rectification/cases/[caseId] used to return 4xx/5xx with no log.
|
||||
* Every non-200 now emits one JSON warn so a silent client miss is still
|
||||
* visible in the server log (BUG-969). Fields stay non-PII.
|
||||
*/
|
||||
export function warnRectificationCaseGet(input: Readonly<{
|
||||
caseId: string;
|
||||
status: number;
|
||||
code: string;
|
||||
reason: string;
|
||||
}>): void {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_case_get_failed",
|
||||
case_id: input.caseId,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
reason: input.reason,
|
||||
}));
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问
|
||||
export const RECTIFICATION_COLLECT_WAITING_PLACEHOLDER = "再说一件带年月的事";
|
||||
export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范围在上面,对不上可以改选。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_SNAPSHOT_UNAVAILABLE_COPY = "这一问还没读到。";
|
||||
export const RECTIFICATION_SNAPSHOT_RETRY_LABEL = "重新读取";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
@@ -185,11 +187,16 @@ export function parsePersistedRectificationTurns(value: unknown): PersistedRecti
|
||||
});
|
||||
}
|
||||
|
||||
export type RectificationSnapshotFailure = Readonly<{
|
||||
status: number | "network" | "timeout";
|
||||
}>;
|
||||
|
||||
export type RectificationCaseHydration = Readonly<{
|
||||
turns: PersistedRectificationTurn[];
|
||||
snapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** False when the request failed or the deadline passed first. */
|
||||
complete: boolean;
|
||||
failure?: RectificationSnapshotFailure;
|
||||
}>;
|
||||
|
||||
export function rectificationCaseHref(caseId: string, sessionId: string): string {
|
||||
@@ -216,7 +223,12 @@ export async function hydrateRectificationCase(
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
|
||||
const incomplete: RectificationCaseHydration = {
|
||||
turns: [],
|
||||
snapshot: null,
|
||||
complete: false,
|
||||
failure: { status: "timeout" },
|
||||
};
|
||||
|
||||
let deadline: unknown;
|
||||
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
|
||||
@@ -225,16 +237,20 @@ export async function hydrateRectificationCase(
|
||||
const read = (async (): Promise<RectificationCaseHydration> => {
|
||||
try {
|
||||
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return incomplete;
|
||||
if (!response.ok) {
|
||||
return { turns: [], snapshot: null, complete: false, failure: { status: response.status } };
|
||||
}
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
|
||||
if (!isRectificationCaseSnapshotPayload(payload)) {
|
||||
return { turns: [], snapshot: null, complete: false, failure: { status: "network" } };
|
||||
}
|
||||
return {
|
||||
turns: parsePersistedRectificationTurns(payload.turns),
|
||||
snapshot: payload,
|
||||
complete: true,
|
||||
};
|
||||
} catch {
|
||||
return incomplete;
|
||||
return { turns: [], snapshot: null, complete: false, failure: { status: "network" } };
|
||||
}
|
||||
})();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user