fix(consult): replay an unstarted consultation after refresh
Independent Staging Quality Gate / validate (push) Successful in 7m57s
Independent Staging Quality Gate / publish (push) Has been cancelled

Sending cleared the draft and stored a request id before the server reserved usage, so a refresh left a sent question with no agent run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 17:33:23 +08:00
parent f4a2ba86ae
commit 188cff99ec
6 changed files with 269 additions and 101 deletions
+223 -86
View File
@@ -262,6 +262,46 @@ type PendingConsultation = {
const undoWindowMs = 2_500;
const pendingConsultationStorageKey = "jyotisha.pending-consultation";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type StoredPendingConsultation = {
readonly sessionId: string;
readonly requestId: string;
readonly question: string;
readonly theme: Theme | null;
readonly entrypoint: ConsultationEntrypoint | null;
};
function readStoredPendingConsultation(
raw: string | null,
sessionIds: Iterable<string>,
): StoredPendingConsultation | null {
if (!raw) return null;
try {
const parsedPending = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsedPending.sessionId !== "string"
|| typeof parsedPending.requestId !== "string"
|| !uuidPattern.test(parsedPending.sessionId)
|| !uuidPattern.test(parsedPending.requestId)) {
return null;
}
let sessionKnown = false;
for (const sessionId of sessionIds) {
if (sessionId === parsedPending.sessionId) {
sessionKnown = true;
break;
}
}
if (!sessionKnown) return null;
return {
sessionId: parsedPending.sessionId,
requestId: parsedPending.requestId,
question: typeof parsedPending.question === "string" ? parsedPending.question : "",
theme: normalizeConsultationDomain(parsedPending.theme),
entrypoint: parsedPending.entrypoint === "daily_starlanguage" ? "daily_starlanguage" : null,
};
} catch {
return null;
}
}
const china = chinaLocations.country;
const themes = defaultGuidedJyotishTopics;
@@ -1037,7 +1077,9 @@ export default function Home() {
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
const consultationRecoveryWakeup = useRef<() => void>(() => undefined);
const consultationRecoveryCheck = useRef<() => void>(() => undefined);
const consultationReplay = useRef<() => void>(() => undefined);
const consultationStatusMissingCount = useRef(0);
const consultationReplayStarted = useRef<string | null>(null);
const modelPersistence = useRef(new SessionModelPersistenceQueue());
const modelSyncFailures = useRef(new Set<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
@@ -1256,21 +1298,40 @@ export default function Home() {
draftEntrypoint.current = entrypoint;
}
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
function restoreConsultationRecovery(
session: ChatSession,
requestId: string,
stored?: StoredPendingConsultation | null,
) {
if (pendingConsultation.current) return;
const lastMessage = session.messages.at(-1);
const question = lastMessage?.role === "user" ? lastMessage.text : "";
const previousSession = lastMessage?.role === "user"
? { ...session, messages: session.messages.slice(0, -1) }
: session;
const storedQuestion = stored?.question?.trim() ?? "";
const lastIsQuestion = lastMessage?.role === "user"
&& (!storedQuestion || lastMessage.text === storedQuestion);
const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion;
const optimisticSession = lastIsQuestion || !question
? session
: {
...session,
title: session.messages.length === 0 && session.title === "新对话"
? resolveSessionTitle(question)
: session.title,
theme: stored?.theme ?? session.theme,
messages: [...session.messages, { role: "user" as const, text: question }],
updatedAt: timestamp(),
};
const previousSession = optimisticSession.messages.at(-1)?.role === "user"
? { ...optimisticSession, messages: optimisticSession.messages.slice(0, -1) }
: optimisticSession;
if (optimisticSession !== session) updateSession(session.id, () => optimisticSession);
pendingConsultation.current = {
requestId,
sessionId: session.id,
question,
entrypoint: null,
theme: session.theme,
entrypoint: stored?.entrypoint ?? null,
theme: stored?.theme ?? session.theme,
previousSession,
optimisticSession: session,
optimisticSession,
previousOnboardingState: false,
controller: new AbortController(),
cancelled: false,
@@ -1279,6 +1340,7 @@ export default function Home() {
};
setPendingSessionId(session.id);
setPendingRequestId(requestId);
setActiveSessionId(session.id);
setConsultationPhase("recovering");
setStreamingReply({ sessionId: session.id, text: "" });
setComposerNotice(navigator.onLine
@@ -1434,26 +1496,12 @@ export default function Home() {
}
let reservedConsultation: ConsultationStatus | null = null;
let storedPending: { sessionId: string; requestId: string } | null = null;
const storedPendingJson = sessionStorage.getItem(pendingConsultationStorageKey);
if (storedPendingJson) {
try {
const parsedPending = JSON.parse(storedPendingJson) as Record<string, unknown>;
if (typeof parsedPending.sessionId === "string"
&& typeof parsedPending.requestId === "string"
&& uuidPattern.test(parsedPending.sessionId)
&& uuidPattern.test(parsedPending.requestId)
&& nextSessions.some((session) => session.id === parsedPending.sessionId)) {
storedPending = {
sessionId: parsedPending.sessionId,
requestId: parsedPending.requestId,
};
} else {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
} catch {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
sessionStorage.getItem(pendingConsultationStorageKey),
nextSessions.map((session) => session.id),
);
if (!storedPending && sessionStorage.getItem(pendingConsultationStorageKey)) {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
if (storedPending) {
@@ -1499,7 +1547,7 @@ export default function Home() {
setActiveSessionId(nextSessions[0].id);
if (reservedConsultation?.status === "reserved") {
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
@@ -1543,6 +1591,9 @@ export default function Home() {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId: pendingSessionId,
requestId: pendingRequestId,
question: pendingConsultation.current?.question ?? "",
theme: pendingConsultation.current?.theme ?? null,
entrypoint: pendingConsultation.current?.entrypoint ?? null,
}));
} else {
consultationStatusMissingCount.current = 0;
@@ -1598,7 +1649,15 @@ export default function Home() {
if (controller.signal.aborted) return;
if (caught instanceof ConsultationStatusError && caught.status === 404) {
consultationStatusMissingCount.current += 1;
if (consultationStatusMissingCount.current >= 3) {
if (consultationStatusMissingCount.current === 1) {
setComposerNotice("正在确认本次咨询请求是否已开始…");
return;
}
if (consultationReplayStarted.current !== pendingRequestId) {
consultationReplay.current();
return;
}
if (consultationStatusMissingCount.current >= 4) {
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
@@ -1611,7 +1670,7 @@ export default function Home() {
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
return;
}
setComposerNotice("正在确认本次咨询请求是否已开始…");
setComposerNotice("正在重新发起本次咨询…");
return;
}
consultationStatusMissingCount.current = 0;
@@ -2784,6 +2843,7 @@ export default function Home() {
function completeConsultationInterface(requestId: string) {
if (pendingConsultation.current?.requestId !== requestId) return;
pendingConsultation.current = null;
if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setPendingRequestId(null);
@@ -2796,14 +2856,24 @@ export default function Home() {
entrypoint: ConsultationEntrypoint | null = null,
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
targetSessionId: string | null = null,
options: { resumeRequestId?: string } = {},
): Promise<boolean> {
const originalQuestion = text;
const question = text.trim();
const resumeRequestId = options.resumeRequestId;
const resuming = Boolean(resumeRequestId);
const currentSession = targetSessionId
? sessions.find((session) => session.id === targetSessionId)
: activeSession;
if (!question || !currentSession || !modelCatalog || pendingSessionId
|| cancellationInFlight.current || pendingConsultation.current || !account) return false;
if (!question || !currentSession || !modelCatalog || !account) return false;
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
if (resuming) {
const pending = pendingConsultation.current;
if (!pending
|| pending.requestId !== resumeRequestId
|| pending.sessionId !== currentSession.id
|| pending.cancelled) return false;
}
if (!isProfileComplete(profile)) {
openAccountDialog("profile");
@@ -2845,51 +2915,71 @@ export default function Home() {
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
const preservedMessages = onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
: currentSession.messages;
const lastMessage = currentSession.messages.at(-1);
const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question;
const preservedMessages = questionAlreadyPresent
? currentSession.messages
: (onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
: currentSession.messages);
const userSession: ChatSession = {
...currentSession,
title: currentSession.messages.length === 0 && currentSession.title === "新对话"
? resolveSessionTitle(question)
: currentSession.title,
theme,
messages: [...preservedMessages, { role: "user", text: question }],
updatedAt: timestamp(),
messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }],
updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(),
};
const requestId = globalThis.crypto.randomUUID();
const controller = new AbortController();
const requestId = resumeRequestId ?? globalThis.crypto.randomUUID();
const controller = resuming && pendingConsultation.current
? pendingConsultation.current.controller
: new AbortController();
const previousOnboardingState = onboardingJustCompleted;
cancellationFeedbackRequest.current = null;
setRequestError(null);
setReplyOutcome(null);
setComposerNotice("");
consultationStatusMissingCount.current = 0;
setPendingSessionId(sessionId);
setPendingRequestId(requestId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
sessionId,
question: originalQuestion,
entrypoint,
theme,
previousSession: currentSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
cancelled: false,
phase: "undo",
partialReply: "",
};
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
if (!resuming) {
setComposerNotice("");
consultationStatusMissingCount.current = 0;
consultationReplayStarted.current = null;
setPendingSessionId(sessionId);
setPendingRequestId(requestId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
sessionId,
question: originalQuestion,
entrypoint,
theme,
previousSession: currentSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
cancelled: false,
phase: "undo",
partialReply: "",
};
try {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId,
requestId,
question: originalQuestion,
theme,
entrypoint,
}));
} catch {
// Private-mode storage must not block send.
}
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
}
if (process.env.NODE_ENV === "development" && uiPreview.current) {
if (!resuming && process.env.NODE_ENV === "development" && uiPreview.current) {
setStreamingReply({ sessionId, text: "" });
if (uiPreviewMode.current === "partial") {
const partialReply = "已开始查看事业方向与关键时间,先给你一个阶段性的判断。";
@@ -2926,31 +3016,40 @@ export default function Home() {
return true;
}
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return false;
try {
await persistSession(userSession);
} catch (caught) {
if (!resuming) {
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return false;
updateSession(sessionId, () => currentSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
}
if (!resuming || !questionAlreadyPresent) {
if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession);
try {
await persistSession(userSession);
} catch (caught) {
if (controller.signal.aborted) return false;
updateSession(sessionId, () => currentSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
}
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
});
setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。");
completeConsultationInterface(requestId);
window.requestAnimationFrame(() => composerInput.current?.focus());
return false;
}
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
});
setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。");
completeConsultationInterface(requestId);
window.requestAnimationFrame(() => composerInput.current?.focus());
return false;
}
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
question: originalQuestion,
entrypoint,
theme,
optimisticSession: userSession,
phase: "streaming",
};
setConsultationPhase("streaming");
@@ -3099,7 +3198,18 @@ export default function Home() {
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && caught instanceof ConsultationResponseError) {
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
if (caught.message === "request_conflict") {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "recovering",
partialReply,
};
setConsultationPhase("recovering");
setRequestError(null);
setComposerNotice("回答仍在后台生成,正在自动恢复。");
return Boolean(partialReply);
}
setRequestError({ sessionId, message: caught.message });
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(caught.message);
@@ -3138,6 +3248,33 @@ export default function Home() {
}
}
consultationReplay.current = () => {
const pending = pendingConsultation.current;
if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return;
if (consultationReplayStarted.current === pending.requestId) return;
consultationReplayStarted.current = pending.requestId;
setComposerNotice("后台尚未开始本次咨询,正在重新发起…");
void send(
pending.question,
pending.theme,
pending.entrypoint,
null,
pending.sessionId,
{ resumeRequestId: pending.requestId },
).then((started) => {
if (started || pendingConsultation.current?.requestId !== pending.requestId) return;
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError({
sessionId: pending.sessionId,
message: "后台未找到本次咨询请求,请重新发送。",
});
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
});
};
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();