fix(consult): replay an unstarted consultation after refresh
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:
+223
-86
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user