diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index b81688da..bc4a6707 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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(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(action: () => Promise): Promise { + 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 | 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(); diff --git a/frontend/src/app/api/consult/status/route.ts b/frontend/src/app/api/consult/status/route.ts new file mode 100644 index 00000000..376e21a2 --- /dev/null +++ b/frontend/src/app/api/consult/status/route.ts @@ -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>; + 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; + 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, + }); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0da71693..a0a20d3a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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((resolve) => { const finish = () => { @@ -891,6 +920,45 @@ async function fetchSessions(signal?: AbortSignal): Promise { 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; + 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 { + 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 { + 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(null); const [draftEntrypoint, setDraftEntrypoint] = useState(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(null); + const [pendingRequestId, setPendingRequestId] = useState(null); const [streamingReply, setStreamingReply] = useState(null); const [requestError, setRequestError] = useState(null); const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( @@ -976,6 +1045,9 @@ export default function Home() { const cancellationInFlight = useRef(false); const stoppedRequestAwaitingSettlement = useRef(null); const stoppedSessionPersistence = useRef(new Map>()); + 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()); @@ -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; + 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() {
{activeSession?.title || "新对话"} {isLoading - ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") + ? (consultationPhase === "undo" ? "即将发送,可撤回" : consultationPhase === "recovering" ? "正在恢复后台回答" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification") ? "正在校正出生时间" : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"} @@ -3020,8 +3372,8 @@ export default function Home() { {isLoading ? (