fix(consult): preserve streaming across disconnects
Staging Backend Quality Gate / validate (push) Successful in 12m47s
Staging Backend Quality Gate / publish (push) Successful in 31m1s

This commit is contained in:
Jesse_Chen
2026-08-08 21:34:03 +08:00
parent c16c9bfbde
commit 32b683ebef
9 changed files with 1529 additions and 179 deletions
+182 -44
View File
@@ -7,16 +7,12 @@ import {
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { parseAgentReply } from "@/lib/agent-reply";
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
} from "@/lib/consultation-entrypoint";
import {
authorizeUsage,
completeUsage,
CreditRpcError,
releaseUsage,
} from "@/lib/consultation-billing";
import { CreditRpcError } from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
@@ -72,6 +68,43 @@ const generalChatRequestSchema = z.object({
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
const consultationReservationSchema = z.object({
success: z.boolean(),
reservation_id: z.string().uuid().nullable(),
source: z.enum(["subscription", "credits"]).nullable(),
credits: z.number().int().nullable(),
subscription_id: z.string().uuid().nullable(),
reason: z.string().nullable(),
retry_after_seconds: z.number().int().nullable(),
});
const consultationCompletionSchema = z.object({
success: z.boolean(),
credits: z.number().int().nullable(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
const detachedSettlementAttempts = 3;
// ponytail: Staging MVP ceiling—without a queue/worker, detached settlement gets only three short retries.
async function retryDetachedSettlement<T>(action: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= detachedSettlementAttempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = error;
if (attempt < detachedSettlementAttempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
}
throw lastError;
}
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -176,6 +209,8 @@ export async function POST(request: Request) {
const userId = user.id;
const requestId = parsed.data.requestId;
const sessionId = parsed.data.sessionId;
const consultationTheme = parsed.data.theme;
const userControlledPrompt = [
parsed.data.question,
@@ -210,17 +245,24 @@ export async function POST(request: Request) {
reserve: () => reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
(model) => authorizeUsage(accounting, {
userId,
requestId,
featureKey: "chat.standard",
requestedModelId: model.id,
creditCost: model.creditCost,
}).then((result) => ({
success: result.success,
credits: result.credits,
error_code: result.reason,
})),
async (model) => {
const { data, error } = await accounting.rpc("reserve_consultation_usage", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_requested_model_id: model.id,
p_credit_cost: model.creditCost,
});
const reservation = consultationReservationSchema.safeParse(first(data ?? []));
if (error || !reservation.success) {
throw new CreditRpcError(error?.message || "invalid_reservation_response");
}
return {
success: reservation.data.success,
credits: reservation.data.credits,
error_code: reservation.data.reason,
};
},
),
});
} catch (error) {
@@ -277,9 +319,23 @@ export async function POST(request: Request) {
);
}
async function cancel() {
try {
await releaseUsage(accounting, userId, requestId, "consultation_cancelled");
const result = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("cancel_consultation_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const parsedCancellation = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCancellation.success) {
throw new CreditRpcError(error?.message || "invalid_cancellation_response");
}
return parsedCancellation.data;
});
if (!result.success && result.error_code !== "request_completed") {
throw new CreditRpcError(result.error_code || "cancellation_rejected");
}
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
@@ -289,25 +345,67 @@ export async function POST(request: Request) {
}
const usageStartedAt = Date.now();
async function complete(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0));
const costMicrousd = Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000);
const result = await completeUsage(accounting, userId, requestId, {
return {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
costMicrousd,
costMicrousd: Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000),
durationMs: Date.now() - usageStartedAt,
});
if (!result.success)
throw new CreditRpcError(result.error_code || "completion_rejected");
};
}
async function completeResponse(
rawTransformedText: string,
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
techniqueTruth: string,
workflowReceipt: {
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
},
) {
try {
const reply = parseAgentReply(rawTransformedText, consultationTheme);
if (!reply.text) throw new Error("empty_agent_reply");
const responseMessage = {
role: "assistant" as const,
text: reply.text,
suggestions: reply.suggestions,
techniqueTruth,
workflowReceipt,
};
const actualUsage = await usagePayload(usage);
const completion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("complete_consultation_response", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_response_message: responseMessage,
p_actual_usage: actualUsage,
});
const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCompletion.success) {
throw new CreditRpcError(error?.message || "invalid_completion_response");
}
return parsedCompletion.data;
});
if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "completion_rejected");
}
} catch (error) {
await cancel();
throw error;
}
}
let settlement: Promise<void> | null = null;
@@ -332,24 +430,43 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeWithUsage : cancel);
const workflowReceipt = {
route: "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
} as const;
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
"not-applicable",
workflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": "general-no-birth-time",
"x-jyotish-workflow-status": "ready",
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": "not-applicable",
"x-jyotish-precise-timing": "blocked",
"x-jyotish-missing-layers": "birth-minute",
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers.join(","),
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
"not-applicable",
workflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
}
@@ -383,9 +500,24 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeWithUsage : cancel);
const responseWorkflowReceipt = {
route: workflowReceipt.route,
status: workflowReceipt.status,
preciseTiming: workflowReceipt.preciseTiming,
missingLayers: workflowReceipt.missingLayers === "none"
? []
: workflowReceipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean),
};
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
consultationMode,
@@ -393,6 +525,7 @@ export async function POST(request: Request) {
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
@@ -401,9 +534,14 @@ export async function POST(request: Request) {
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
} catch (error) {
await cancel();
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { runCreditRpc } from "@/lib/consultation-billing";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const idSchema = z.string().uuid();
const reservedLeaseMs = 15 * 60 * 1000;
// ponytail: Staging MVP ceiling—without a queue/worker, status lazily refunds reservations after this lease.
function reservationLeaseExpired(updatedAt: unknown, now = Date.now()) {
if (typeof updatedAt !== "string") return false;
const timestamp = Date.parse(updatedAt);
return Number.isFinite(timestamp) && now - timestamp >= reservedLeaseMs;
}
export async function GET(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
try {
supabase = await createServerSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const params = new URL(request.url).searchParams;
const sessionIdValue = params.get("sessionId");
const requestIdValue = params.get("requestId");
const activeLookup = sessionIdValue === null && requestIdValue === null;
if (!activeLookup && (sessionIdValue === null || requestIdValue === null)) {
return NextResponse.json({ error: "咨询请求格式不正确" }, { status: 400 });
}
const sessionId = sessionIdValue === null ? null : idSchema.safeParse(sessionIdValue);
const requestId = requestIdValue === null ? null : idSchema.safeParse(requestIdValue);
if ((sessionId && !sessionId.success) || (requestId && !requestId.success)) {
return NextResponse.json({ error: "咨询请求格式不正确" }, { status: 400 });
}
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
let query = accounting
.from("consultation_requests")
.select("request_id,session_id,status,response_message,updated_at")
.eq("user_id", user.id);
if (activeLookup) {
query = query.eq("status", "reserved").order("created_at", { ascending: false }).limit(1);
} else {
query = query
.eq("session_id", sessionId!.data)
.eq("request_id", requestId!.data);
}
const { data, error } = await query.maybeSingle();
if (error) {
return NextResponse.json({ error: "暂时无法读取咨询状态" }, { status: 503 });
}
if (!data || typeof data.session_id !== "string") {
return NextResponse.json({ error: "咨询请求不存在" }, { status: 404 });
}
let statusData = data;
if (statusData.status === "reserved" && reservationLeaseExpired(statusData.updated_at)) {
let cancellation;
try {
cancellation = await runCreditRpc(
accounting,
"cancel_consultation_credit",
user.id,
statusData.request_id,
);
} catch {
return NextResponse.json({ error: "暂时无法回收超时咨询" }, { status: 503 });
}
if (!cancellation.success && cancellation.error_code !== "request_completed") {
return NextResponse.json({ error: "暂时无法回收超时咨询" }, { status: 503 });
}
const { data: settledData, error: settledError } = await accounting
.from("consultation_requests")
.select("request_id,session_id,status,response_message,updated_at")
.eq("user_id", user.id)
.eq("session_id", statusData.session_id)
.eq("request_id", statusData.request_id)
.maybeSingle();
if (settledError || !settledData || typeof settledData.session_id !== "string") {
return NextResponse.json({ error: "暂时无法读取咨询状态" }, { status: 503 });
}
statusData = settledData;
}
return NextResponse.json({
requestId: statusData.request_id,
sessionId: statusData.session_id,
status: statusData.status,
responseMessage: statusData.response_message,
updatedAt: statusData.updated_at,
});
}
+428 -76
View File
@@ -213,6 +213,13 @@ type DailyStarlanguageApiResponse = {
boundary?: "not_deterministic_prediction";
};
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type ConsultationStatus = {
readonly requestId: string;
readonly sessionId: string;
readonly status: "reserved" | "completed" | "cancelled";
readonly responseMessage?: unknown;
readonly updatedAt?: string;
};
type PendingConsultation = {
readonly requestId: string;
readonly sessionId: string;
@@ -224,10 +231,12 @@ type PendingConsultation = {
readonly previousOnboardingState: boolean;
readonly controller: AbortController;
readonly cancelled: boolean;
readonly phase: "undo" | "streaming";
readonly phase: "undo" | "streaming" | "recovering";
readonly partialReply: string;
};
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;
const china = chinaLocations.country;
const themes = defaultGuidedJyotishTopics;
@@ -850,6 +859,26 @@ class CancellationResponseError extends Error {
}
}
class ConsultationResponseError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ConsultationResponseError";
this.status = status;
}
}
class ConsultationStatusError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ConsultationStatusError";
this.status = status;
}
}
function waitForUndoWindow(signal: AbortSignal) {
return new Promise<void>((resolve) => {
const finish = () => {
@@ -891,6 +920,45 @@ async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null;
}
function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus {
if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效");
const status = payload as Partial<ConsultationStatus>;
if (typeof status.requestId !== "string"
|| typeof status.sessionId !== "string"
|| (requestId && status.requestId !== requestId)
|| (status.status !== "reserved" && status.status !== "completed" && status.status !== "cancelled")) {
throw new Error("后台回答状态无效");
}
return status as ConsultationStatus;
}
async function fetchConsultationStatus(sessionId: string, requestId: string, signal?: AbortSignal): Promise<ConsultationStatus> {
const response = await fetch(`/api/consult/status?sessionId=${encodeURIComponent(sessionId)}&requestId=${encodeURIComponent(requestId)}`, {
signal,
cache: "no-store",
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new ConsultationStatusError(
response.status,
payloadMessage(payload, "暂时无法恢复后台回答"),
);
}
const status = parseConsultationStatus(payload, requestId);
if (status.sessionId !== sessionId) throw new Error("后台回答状态无效");
return status;
}
async function fetchActiveConsultationStatus(signal?: AbortSignal): Promise<ConsultationStatus | null> {
const response = await fetch("/api/consult/status", { signal, cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (response.status === 404) return null;
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法恢复后台回答"));
const status = parseConsultationStatus(payload);
if (status.status !== "reserved") throw new Error("后台回答状态无效");
return status;
}
async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
@@ -938,9 +1006,10 @@ export default function Home() {
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
const [composerNotice, setComposerNotice] = useState("");
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | null>(null);
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
@@ -976,6 +1045,9 @@ export default function Home() {
const cancellationInFlight = useRef(false);
const stoppedRequestAwaitingSettlement = useRef<string | null>(null);
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
const consultationRecoveryWakeup = useRef<() => void>(() => undefined);
const consultationRecoveryCheck = useRef<() => void>(() => undefined);
const consultationStatusMissingCount = useRef(0);
const modelPersistence = useRef(new SessionModelPersistenceQueue());
const rectificationPersistence = useRef(new SessionModelPersistenceQueue());
const modelSyncFailures = useRef(new Set<string>());
@@ -1163,6 +1235,48 @@ export default function Home() {
&& !activeSession?.messages.length;
const daypartGreeting = greetingForHour(new Date().getHours());
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
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;
pendingConsultation.current = {
requestId,
sessionId: session.id,
question,
entrypoint: null,
theme: session.theme,
previousSession,
optimisticSession: session,
previousOnboardingState: false,
controller: new AbortController(),
cancelled: false,
phase: "recovering",
partialReply: "",
};
setPendingSessionId(session.id);
setPendingRequestId(requestId);
setConsultationPhase("recovering");
setStreamingReply({ sessionId: session.id, text: "" });
setComposerNotice(navigator.onLine
? "回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
}
consultationRecoveryCheck.current = () => {
consultationRecoveryWakeup.current();
if (pendingConsultation.current || uiPreview.current) return;
void fetchActiveConsultationStatus()
.then((status) => {
if (status?.status !== "reserved") return;
const session = sessions.find((item) => item.id === status.sessionId);
if (session) restoreConsultationRecovery(session, status.requestId);
})
.catch(() => undefined);
};
useEffect(() => {
const controller = new AbortController();
const bootstrapTimeout = window.setTimeout(() => {
@@ -1298,6 +1412,60 @@ export default function Home() {
nextSessions = [initialSession];
}
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);
}
}
if (storedPending) {
try {
const status = await fetchConsultationStatus(
storedPending.sessionId,
storedPending.requestId,
controller.signal,
);
if (status.status === "reserved") {
consultationStatusMissingCount.current = 0;
reservedConsultation = status;
} else {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
reservedConsultation = {
sessionId: storedPending.sessionId,
requestId: storedPending.requestId,
status: "reserved",
};
}
} else {
try {
reservedConsultation = await fetchActiveConsultationStatus(controller.signal);
consultationStatusMissingCount.current = 0;
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
}
}
if (controller.signal.aborted) return;
const nextProfile = readProfile(nextAccount.profile);
setAccount(nextAccount);
@@ -1308,6 +1476,10 @@ export default function Home() {
setOnboardingStep(missingProfileStep(nextProfile) ?? "name");
setSessions(nextSessions);
setActiveSessionId(nextSessions[0].id);
if (reservedConsultation?.status === "reserved") {
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId);
}
if (modelCatalogResult.unavailable) {
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
} else if (parsedSessions.fallbackSessionIds.length > 0) {
@@ -1343,6 +1515,111 @@ export default function Home() {
};
}, []);
useEffect(() => {
if (!hydrated || uiPreview.current) return;
if (pendingSessionId && pendingRequestId) {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId: pendingSessionId,
requestId: pendingRequestId,
}));
} else {
consultationStatusMissingCount.current = 0;
sessionStorage.removeItem(pendingConsultationStorageKey);
}
}, [hydrated, pendingRequestId, pendingSessionId]);
useEffect(() => {
if (consultationPhase !== "recovering" || !pendingSessionId || !pendingRequestId || uiPreview.current) return;
const controller = new AbortController();
let timer = 0;
let polling = false;
const poll = async () => {
if (polling || controller.signal.aborted) return;
polling = true;
try {
if (!navigator.onLine) {
setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
return;
}
const status = await fetchConsultationStatus(pendingSessionId, pendingRequestId, controller.signal);
if (status.status === "reserved") {
consultationStatusMissingCount.current = 0;
setComposerNotice("回答仍在后台生成,正在自动恢复。");
return;
}
if (status.status === "completed") {
const payload = await fetchSessions(controller.signal);
const parsed = readSessions(payload, modelCatalog);
setSessions(parsed.sessions);
setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current)
? current
: parsed.sessions[0]?.id ?? "");
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError(null);
setComposerNotice("回答已恢复。");
void refreshAccount();
return;
}
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError(null);
setComposerNotice("回答已取消;问题仍保留在聊天记录中,可重新发送。");
} catch (caught) {
if (controller.signal.aborted) return;
if (caught instanceof ConsultationStatusError && caught.status === 404) {
consultationStatusMissingCount.current += 1;
if (consultationStatusMissingCount.current >= 3) {
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError({
sessionId: pendingSessionId,
message: "后台未找到本次咨询请求,请重新发送。",
});
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
return;
}
setComposerNotice("正在确认本次咨询请求是否已开始…");
return;
}
consultationStatusMissingCount.current = 0;
setComposerNotice(navigator.onLine
? "回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
} finally {
polling = false;
if (!controller.signal.aborted && pendingConsultation.current?.phase === "recovering") {
timer = window.setTimeout(() => void poll(), 1_750);
}
}
};
consultationRecoveryWakeup.current = () => {
window.clearTimeout(timer);
void poll();
};
if (consultationStatusMissingCount.current > 0) {
timer = window.setTimeout(() => void poll(), 1_750);
} else {
void poll();
}
return () => {
consultationRecoveryWakeup.current = () => undefined;
window.clearTimeout(timer);
controller.abort();
};
}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId]);
useEffect(() => {
if (!hydrated || !shouldStreamOnboarding) return;
@@ -1459,14 +1736,25 @@ export default function Home() {
const onBalanceChanged = () => void refreshAccount();
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void refreshAccount();
consultationRecoveryCheck.current();
};
const onOnline = () => consultationRecoveryCheck.current();
const onOffline = () => {
if (pendingConsultation.current?.phase === "recovering") {
setComposerNotice("网络已断开,回答仍在后台生成;联网后会自动恢复。");
}
};
window.addEventListener("storage", onBalanceStorage);
window.addEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.addEventListener("pageshow", onPageShow);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("storage", onBalanceStorage);
window.removeEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.removeEventListener("pageshow", onPageShow);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, [accountId, hydrated]);
@@ -2225,7 +2513,7 @@ export default function Home() {
if (!pending || pending.cancelled) return;
const isPreview = process.env.NODE_ENV === "development" && uiPreview.current;
if (pending.phase === "streaming" && !isPreview) {
if (pending.phase !== "undo" && !isPreview) {
stoppedRequestAwaitingSettlement.current = pending.requestId;
cancellationInFlight.current = true;
setCancellationPending(true);
@@ -2239,26 +2527,90 @@ export default function Home() {
messages: [...pending.optimisticSession.messages, { role: "assistant", text: pending.partialReply }],
updatedAt: timestamp(),
};
if (isPreview) {
updateSession(pending.sessionId, () => stoppedSession);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
setComposerNotice("已停止回答,现有内容已保留。");
if (pendingConsultation.current?.requestId === pending.requestId) {
pendingConsultation.current = null;
}
return;
}
setComposerNotice("正在停止回答并申请退回本次点数…");
try {
await requestCancellation(pending.requestId);
} catch (error) {
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
pendingConsultation.current = {
...pending,
controller: new AbortController(),
cancelled: false,
phase: "recovering",
};
setPendingSessionId(pending.sessionId);
setConsultationPhase("recovering");
setStreamingReply({ sessionId: pending.sessionId, text: pending.partialReply });
setRequestError(null);
if (error instanceof CancellationResponseError && error.status === 409) {
setComposerNotice("回答已完成,正在恢复服务端完整内容。");
try {
const status = await fetchConsultationStatus(pending.sessionId, pending.requestId);
if (status.status === "completed") {
const payload = await fetchSessions();
const parsed = readSessions(payload, modelCatalog);
setSessions(parsed.sessions);
setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current)
? current
: parsed.sessions[0]?.id ?? "");
pendingConsultation.current = null;
setPendingSessionId(null);
setConsultationPhase(null);
setStreamingReply(null);
setComposerNotice("回答已恢复。");
void refreshAccount();
return;
}
} catch {
// The recovery poll retries status and session reload.
}
} else {
setComposerNotice("停止请求尚未确认,正在自动恢复后台回答。");
}
window.setTimeout(() => consultationRecoveryWakeup.current(), 0);
return;
}
updateSession(pending.sessionId, () => stoppedSession);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
setComposerNotice("已停止回答。模型已开始生成,本次将计费,现有内容已保留。");
if (!isPreview) {
const persistence = persistSession(stoppedSession).catch((error) => {
setRequestError({
sessionId: pending.sessionId,
message: error instanceof Error ? error.message : "已停止的回答暂时无法同步。",
});
try {
await persistSession(stoppedSession);
setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。");
} catch (error) {
setComposerNotice("本次点数已退回;现有内容暂时无法同步。");
setRequestError({
sessionId: pending.sessionId,
message: error instanceof Error ? error.message : "已停止的回答暂时无法同步。",
});
stoppedSessionPersistence.current.set(pending.requestId, persistence);
}
if (!isPreview) {
void refreshAccount();
} else if (pendingConsultation.current?.requestId === pending.requestId) {
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
if (pendingConsultation.current?.requestId === pending.requestId) {
pendingConsultation.current = null;
}
void refreshAccount();
return;
}
@@ -2288,6 +2640,11 @@ export default function Home() {
pending.sessionId,
"已停止,问题已放回输入框,本次未扣点。",
);
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
if (pendingConsultation.current?.requestId === pending.requestId) pendingConsultation.current = null;
}
function completeConsultationInterface(requestId: string) {
@@ -2295,6 +2652,7 @@ export default function Home() {
pendingConsultation.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
}
@@ -2358,7 +2716,9 @@ export default function Home() {
: currentSession.messages;
const userSession: ChatSession = {
...currentSession,
title: currentSession.title,
title: currentSession.messages.length === 0 && currentSession.title === "新对话"
? resolveSessionTitle(question)
: currentSession.title,
theme,
messages: [...preservedMessages, { role: "user", text: question }],
updatedAt: timestamp(),
@@ -2369,7 +2729,9 @@ export default function Home() {
cancellationFeedbackRequest.current = null;
setRequestError(null);
setComposerNotice("");
consultationStatusMissingCount.current = 0;
setPendingSessionId(sessionId);
setPendingRequestId(requestId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
@@ -2417,9 +2779,7 @@ export default function Home() {
].join("\n"), theme);
const previewSession: ChatSession = {
...userSession,
title: currentSession.messages.length === 0
? resolveSessionTitle(question, previewReply.title)
: userSession.title,
title: userSession.title,
messages: [...userSession.messages, {
role: "assistant",
text: previewReply.text,
@@ -2434,6 +2794,26 @@ export default function Home() {
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return false;
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;
}
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
@@ -2480,9 +2860,14 @@ export default function Home() {
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
if (response.status === 401) window.location.assign("/login");
if (response.status === 402) window.location.assign(membershipHref("insufficient-credits"));
throw new Error(payloadMessage(errorPayload, "服务暂时不可用"));
throw new ConsultationResponseError(
response.status,
payloadMessage(errorPayload, "服务暂时不可用"),
);
}
if (!response.body) {
throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流");
}
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
const techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown";
const workflowReceipt = {
route: response.headers.get("x-jyotish-workflow-route") ?? "unknown",
@@ -2520,76 +2905,43 @@ export default function Home() {
const completedSession: ChatSession = {
...userSession,
title: currentSession.messages.length === 0
? resolveSessionTitle(question, reply.title)
: userSession.title,
title: userSession.title,
messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions, techniqueTruth, workflowReceipt }],
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
completeConsultationInterface(requestId);
try {
await persistSession(completedSession);
} catch (caught) {
void caught;
setComposerNotice("回答已保留;网络恢复后,下一次对话会继续同步完整记录。");
}
void refreshAccount();
return true;
} catch (caught) {
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (ownsInterface && !partialReply) {
updateSession(sessionId, () => currentSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
}
if (!cancelled) {
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "服务暂时不可用,请稍后重试。"} 问题已放回输入框。`,
});
cancellationFeedbackRequest.current = requestId;
if (activeSessionIdRef.current === sessionId) {
setComposerNotice("问题已放回输入框,正在确认点数…");
}
}
if (!cancelled && ownsInterface && caught instanceof ConsultationResponseError) {
setRequestError({ sessionId, message: caught.message });
setComposerNotice(caught.message);
completeConsultationInterface(requestId);
return false;
}
if (ownsInterface && !partialReply) {
await confirmCancellation(
requestId,
sessionId,
"问题已放回输入框,本次未扣点。",
);
} else if (!cancelled && ownsInterface) {
const interruptedSession: ChatSession = {
...userSession,
messages: [...userSession.messages, { role: "assistant", text: partialReply }],
updatedAt: timestamp(),
if (!cancelled && ownsInterface && pendingConsultation.current) {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "recovering",
partialReply,
};
updateSession(sessionId, () => interruptedSession);
try {
await persistSession(interruptedSession);
setRequestError({
sessionId,
message: "回答中途断开,已保留生成内容;请复制现有内容或继续追问,系统正在以账户记录为准同步点数。",
});
} catch (persistError) {
void persistError;
setComposerNotice("已保留当前回答;网络恢复后,下一次对话会继续同步完整记录。");
}
if (activeSessionIdRef.current === sessionId) {
setComposerNotice("回答中途断开,已保留现有内容;请继续追问或复制保存。");
}
setConsultationPhase("recovering");
setRequestError(null);
setComposerNotice(navigator.onLine
? "连接中断,回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
}
return Boolean(partialReply);
} finally {
cancellationRequests.current.delete(requestId);
completeConsultationInterface(requestId);
const pending = pendingConsultation.current;
if (pending?.requestId !== requestId || pending.phase !== "recovering") {
completeConsultationInterface(requestId);
}
if (stoppedRequestAwaitingSettlement.current === requestId) {
const persistence = stoppedSessionPersistence.current.get(requestId);
if (persistence) {
@@ -2760,7 +3112,7 @@ export default function Home() {
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading
? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
? (consultationPhase === "undo" ? "即将发送,可撤回" : consultationPhase === "recovering" ? "正在恢复后台回答" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
: rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification")
? "正在校正出生时间"
: personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}</span>
@@ -3020,8 +3372,8 @@ export default function Home() {
{isLoading ? (
<Button
className="composer-stop"
aria-label={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容" : "停止回答并申请退回本次点数"}
title={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,本次已开始计费" : "停止回答"}
aria-label={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
title={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
size="icon"
type="button"
onClick={() => void stopResponse()}