diff --git a/frontend/src/app/api/birth-time-conversation/handoff/route.ts b/frontend/src/app/api/birth-time-conversation/handoff/route.ts new file mode 100644 index 00000000..7039e2df --- /dev/null +++ b/frontend/src/app/api/birth-time-conversation/handoff/route.ts @@ -0,0 +1,44 @@ +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { createRectificationHandoffService } from "@/lib/rectification-handoff-service"; +import { + createRectificationHandoffHandlers, + type RectificationHandoffRouteDependencies, +} from "@/lib/rectification-handoff-route"; + +export const runtime = "nodejs"; + +const productionDependencies: RectificationHandoffRouteDependencies = { + async authenticate() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + return error || !user ? null : { userId: user.id }; + }, + service() { + return createRectificationHandoffService(createAdminSupabaseClient()); + }, +}; + +const handlers = createRectificationHandoffHandlers(productionDependencies); + +export async function GET() { + try { + return await handlers.get(); + } catch { + return Response.json( + { code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" }, + { status: 503 }, + ); + } +} + +export async function POST(request: Request) { + try { + return await handlers.post(request); + } catch { + return Response.json( + { code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" }, + { status: 503 }, + ); + } +} diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 4ea3728b..1918e1ff 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -31,6 +31,11 @@ import { ConsultationProfileTruthError, prepareConsultationRoute, } from "@/lib/consultation-route-service"; +import { + createRectificationHandoffService, + type RectificationHandoffExecution, + type RectificationHandoffService, +} from "@/lib/rectification-handoff-service"; import { z } from "zod"; export const runtime = "nodejs"; @@ -51,12 +56,20 @@ const chatRequestMetadataSchema = z.object({ .default([]), }); +const rectificationHandoffSchema = z.object({ + caseId: z.string().uuid(), + turnVersion: z.number().int().nonnegative(), + claimActionId: z.string().uuid(), + requestId: z.string().uuid(), +}).strict(); + const chartChatRequestSchema = consultationInputSchema.extend({ ...chatRequestMetadataSchema.shape, consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"]) .optional() .default("verified_chart"), entrypoint: consultationEntrypointSchema.optional(), + rectificationHandoff: rectificationHandoffSchema.optional(), }).strict(); const generalChatRequestSchema = z.object({ @@ -157,23 +170,6 @@ export async function POST(request: Request) { ); } - const userControlledPrompt = [ - parsed.data.question, - ...parsed.data.history - .filter((message) => message.role === "user") - .map((message) => message.text), - ].join("\n"); - if (blocksPromptExtraction(userControlledPrompt)) { - return NextResponse.json( - { - error: "无法处理该请求", - message: - "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。", - }, - { status: 400 }, - ); - } - const requestTime = new Date(); const resolvedQuestion = resolveConsultationQuestion({ visibleQuestion: parsed.data.question, @@ -183,6 +179,97 @@ export async function POST(request: Request) { const userId = user.id; const requestId = parsed.data.requestId; + const handoff = "rectificationHandoff" in parsed.data + ? parsed.data.rectificationHandoff + : undefined; + let handoffService: RectificationHandoffService | null = null; + let handoffExecution: RectificationHandoffExecution | null = null; + let handoffSettlement: Promise | null = null; + + async function settleHandoff(emitted: boolean) { + if (!handoff || !handoffService || !handoffExecution + || handoffExecution.status !== "ready") return; + handoffSettlement ??= handoffService.settle({ + userId, + caseId: handoff.caseId, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + emitted, + }).then(() => undefined); + await handoffSettlement; + } + + if (handoff) { + if (parsed.data.consultationMode !== "verified_chart" + || parsed.data.entrypoint !== undefined + || requestId !== handoff.requestId) { + return NextResponse.json( + { + error: "原问题交接请求不一致", + message: "请刷新校正结果后重新点击继续,本次不会扣点。", + }, + { status: 409 }, + ); + } + try { + handoffService = createRectificationHandoffService(accounting); + handoffExecution = await handoffService.beginExecution({ + userId, + caseId: handoff.caseId, + turnVersion: handoff.turnVersion, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + question: parsed.data.question, + }); + } catch { + return NextResponse.json( + { + error: "原问题状态已经变化", + message: "请刷新后查看最新状态,本次不会扣点。", + }, + { status: 409 }, + ); + } + if (handoffExecution.status !== "ready") { + const consumed = handoffExecution.status === "consumed"; + return NextResponse.json( + { + error: consumed ? "原问题已经继续回答" : "原问题正在另一处继续", + message: consumed + ? "刷新后即可查看最新状态,不会再次扣点。" + : "请等待当前回答完成后刷新,本次不会重复扣点。", + }, + { status: consumed ? 410 : 409 }, + ); + } + } + + const userControlledPrompt = [ + parsed.data.question, + ...parsed.data.history + .filter((message) => message.role === "user") + .map((message) => message.text), + ].join("\n"); + if (blocksPromptExtraction(userControlledPrompt)) { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { error: "暂时无法释放原问题", message: "请稍后刷新状态。" }, + { status: 503 }, + ); + } + } + return NextResponse.json( + { + error: "无法处理该请求", + message: + "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。", + }, + { status: 400 }, + ); + } let prepared; try { prepared = await prepareConsultationRoute({ @@ -200,15 +287,34 @@ export async function POST(request: Request) { reserve: () => reserveConsultationModel( parsed.data.modelId, resolveLanguageModel, - () => runCreditRpc( - accounting, - "begin_consultation_credit", - userId, - requestId, - ), + () => handoffExecution?.billingReused + ? Promise.resolve({ + success: true, + credits: handoffExecution.credits ?? null, + error_code: null, + }) + : runCreditRpc( + accounting, + "begin_consultation_credit", + userId, + requestId, + ), ), }); } catch (error) { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { + error: "暂时无法释放原问题", + message: "请稍后刷新状态,本次不会重复扣点。", + }, + { status: 503 }, + ); + } + } if (error instanceof ConsultationProfileTruthError) { const modeChanged = error.code === "mode_changed"; return NextResponse.json( @@ -237,6 +343,16 @@ export async function POST(request: Request) { const modelSelection = prepared.reservation; if (modelSelection.status === "unavailable") { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { error: "暂时无法释放原问题", message: "请稍后刷新状态。" }, + { status: 503 }, + ); + } + } return NextResponse.json( { error: "模型暂不可用", @@ -250,6 +366,16 @@ export async function POST(request: Request) { const reserveResult = modelSelection.reservation; if (!reserveResult.success) { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch { + return NextResponse.json( + { error: "暂时无法释放原问题", message: "请稍后刷新状态。" }, + { status: 503 }, + ); + } + } const insufficient = reserveResult.error_code === "insufficient_credits"; return NextResponse.json( { @@ -263,6 +389,17 @@ export async function POST(request: Request) { } async function cancel() { + if (handoffExecution?.status === "ready") { + try { + await settleHandoff(false); + } catch (error) { + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error( + `[billing] handoff release failed request=${requestId} reason=${reason}`, + ); + } + return; + } try { await runCreditRpc( accounting, @@ -279,6 +416,10 @@ export async function POST(request: Request) { } async function complete() { + if (handoffExecution?.status === "ready") { + await settleHandoff(true); + return; + } const result = await runCreditRpc( accounting, "complete_consultation_credit", @@ -335,6 +476,7 @@ export async function POST(request: Request) { "x-jyotish-missing-layers": "birth-minute", "x-jyotish-birth-time-mode": consultationMode, }, + ...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}), onComplete: () => settle(completeAndRecordUsage), onError: (_error, emitted) => settleInterrupted(emitted), onCancel: settleInterrupted, @@ -398,6 +540,7 @@ export async function POST(request: Request) { "x-jyotish-missing-layers": workflowReceipt.missingLayers, "x-jyotish-birth-time-mode": consultationMode, }, + ...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}), onComplete: () => settle(completeAndRecordUsage), onError: (_error, emitted) => settleInterrupted(emitted), onCancel: settleInterrupted, diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 15477c5e..a3699f9e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -50,7 +50,11 @@ import { import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client"; import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; -import { createRectificationQuestionHandoffCoordinator } from "@/lib/rectification-question-handoff"; +import { + createDurableRectificationQuestionHandoffClient, + createRectificationQuestionHandoffCoordinator, + DurableRectificationHandoffError, +} from "@/lib/rectification-question-handoff"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; import { requestBirthTimeAssessment, @@ -63,6 +67,7 @@ import { } from "@/lib/birth-time-guided-preview"; import { keepFocusWithin } from "@/lib/focus-trap"; import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view"; +import { persistExistingChatSession } from "@/lib/chat-session-persistence"; import { OnboardingAuthenticationError, type OnboardingContent, @@ -172,12 +177,19 @@ type PendingBirthTimeChoice = Readonly<{ entrypoint: ConsultationEntrypoint | null; theme: Theme; }>; +type ConsultationRectificationHandoff = Readonly<{ + caseId: string; + turnVersion: number; + claimActionId: string; + requestId: string; +}>; type PendingConsultation = { readonly requestId: string; readonly sessionId: string; readonly question: string; readonly entrypoint: ConsultationEntrypoint | null; readonly theme: Theme; + readonly rectificationHandoff: ConsultationRectificationHandoff | null; readonly previousSession: ChatSession; readonly optimisticSession: ChatSession; readonly previousOnboardingState: boolean; @@ -784,6 +796,9 @@ export default function Home() { const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); + const durableRectificationQuestionHandoff = useRef( + createDurableRectificationQuestionHandoffClient(), + ); const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); @@ -1260,7 +1275,7 @@ export default function Home() { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); } - async function persistSession(session: ChatSession) { + async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; const supabase = createBrowserSupabaseClient(); @@ -1271,22 +1286,26 @@ export default function Home() { messages: session.messages, updated_at: new Date(session.updatedAt).toISOString(), }; - const { data, error } = await supabase - .from("chat_sessions") - .update(values) - .eq("id", session.id) - .eq("user_id", account.user.id) - .select("id") - .maybeSingle(); - if (error) throw new Error(`云端同步失败:${error.message}`); - if (data) return; + if (mode === "create") { + const { error } = await supabase.from("chat_sessions").insert({ + id: session.id, + user_id: account.user.id, + ...values, + }); + if (error) throw new Error(`云端同步失败:${error.message}`); + return; + } - const { error: insertError } = await supabase.from("chat_sessions").insert({ - id: session.id, - user_id: account.user.id, - ...values, + await persistExistingChatSession(async () => { + const { data, error } = await supabase + .from("chat_sessions") + .update(values) + .eq("id", session.id) + .eq("user_id", account.user.id) + .select("id") + .maybeSingle(); + return { found: Boolean(data), error: error?.message ?? null }; }); - if (insertError) throw new Error(`云端同步失败:${insertError.message}`); } async function renameSession(session: ChatSession) { @@ -1368,7 +1387,7 @@ export default function Home() { setComposerNotice(""); setRequestError(null); try { - await persistSession(nextSession); + await persistSession(nextSession, "create"); } catch (caught) { setSessions((current) => current.filter((session) => session.id !== nextSession.id)); setActiveSessionId(previousSessionId); @@ -1810,17 +1829,47 @@ export default function Home() { setRectificationInitialTurn(null); setRectificationError(""); setRectificationSurfaceOpen(true); - if (action !== "resume" || !account.rectificationCase) return; - setRectificationLoading(true); try { - const current = account.rectificationCase; - const turn = await sendConversationalRectificationCommand({ - type: "resume", - caseId: current.caseId, - actionId: globalThis.crypto.randomUUID(), - turnVersion: current.turnVersion, - }); + if (action !== "resume" || !account.rectificationCase) { + const durable = await durableRectificationQuestionHandoff.current.load(); + if (!durable || durable.status === "consumed") return; + setRectificationInitialTurn(durable.turn); + synchronizeRectificationQuestion(durable.turn); + return; + } + + let current = account.rectificationCase; + let turn: ConversationalRectificationTurn; + if (pendingConsultationQuestion) { + try { + turn = await durableRectificationQuestionHandoff.current.attach({ + caseId: current.caseId, + turnVersion: current.turnVersion, + question: pendingConsultationQuestion, + }); + } catch (error) { + if (!(error instanceof DurableRectificationHandoffError) + || error.status !== 409) throw error; + const latest = await fetchAccount(); + if (!latest.rectificationCase + || latest.rectificationCase.caseId !== current.caseId) throw error; + current = latest.rectificationCase; + setAccount(latest); + turn = await durableRectificationQuestionHandoff.current.attach({ + caseId: current.caseId, + turnVersion: current.turnVersion, + question: pendingConsultationQuestion, + }); + } + } else { + turn = await sendConversationalRectificationCommand({ + type: "resume", + caseId: current.caseId, + actionId: globalThis.crypto.randomUUID(), + turnVersion: current.turnVersion, + }); + } setRectificationInitialTurn(turn); synchronizeRectificationQuestion(turn); } catch (caught) { @@ -2039,8 +2088,10 @@ export default function Home() { setPendingSessionId(null); setConsultationPhase(null); setRequestError(null); - cancellationFeedbackRequest.current = pending.requestId; - setComposerNotice("已停止,问题已放回输入框,正在确认点数…"); + cancellationFeedbackRequest.current = pending.rectificationHandoff ? null : pending.requestId; + setComposerNotice(pending.rectificationHandoff + ? "已停止,原问题仍由校正案例保留;正在释放本次继续操作…" + : "已停止,问题已放回输入框,正在确认点数…"); window.requestAnimationFrame(() => composerInput.current?.focus()); if (pending.phase === "undo" || isPreview) { @@ -2051,11 +2102,15 @@ export default function Home() { return; } - await confirmCancellation( - pending.requestId, - pending.sessionId, - "已停止,问题已放回输入框,本次未扣点。", - ); + if (pending.rectificationHandoff) { + setComposerNotice("已停止;原问题仍保留,可刷新校正状态后重试。"); + } else { + await confirmCancellation( + pending.requestId, + pending.sessionId, + "已停止,问题已放回输入框,本次未扣点。", + ); + } } function completeConsultationInterface(requestId: string) { @@ -2072,6 +2127,7 @@ export default function Home() { entrypoint: ConsultationEntrypoint | null = null, consentGrantedForRequest: ConsultationBirthTimeMode | null = null, targetSessionId: string | null = null, + rectificationHandoff: ConsultationRectificationHandoff | null = null, ): Promise { const originalQuestion = text; const question = text.trim(); @@ -2140,7 +2196,7 @@ export default function Home() { messages: [...preservedMessages, { role: "user", text: question }], updatedAt: timestamp(), }; - const requestId = globalThis.crypto.randomUUID(); + const requestId = rectificationHandoff?.requestId ?? globalThis.crypto.randomUUID(); const controller = new AbortController(); const previousOnboardingState = onboardingJustCompleted; cancellationFeedbackRequest.current = null; @@ -2154,6 +2210,7 @@ export default function Home() { question: originalQuestion, entrypoint, theme, + rectificationHandoff, previousSession: currentSession, optimisticSession: userSession, previousOnboardingState, @@ -2246,6 +2303,7 @@ export default function Home() { role: message.role, text: message.text.slice(0, 4000), })), + ...(rectificationHandoff ? { rectificationHandoff } : {}), }), signal: controller.signal, }); @@ -2324,12 +2382,14 @@ export default function Home() { } } } - if (ownsInterface && !partialReply) { + if (ownsInterface && !partialReply && !rectificationHandoff) { await confirmCancellation( requestId, sessionId, "问题已放回输入框,本次未扣点。", ); + } else if (ownsInterface && !partialReply && rectificationHandoff) { + setComposerNotice("原问题仍保留;请刷新校正状态后重试,本次不会重复扣点。"); } else if (!cancelled && ownsInterface) { const interruptedSession: ChatSession = { ...userSession, @@ -2373,7 +2433,15 @@ export default function Home() { async function continueRectificationOriginalQuestion(question: string) { if (rectificationContinuationInFlight.current || rectificationMutationPending - || rectificationLoading || !activeSession) return; + || rectificationLoading || !activeSession || !account) return; + const confirmedTurn = rectificationInitialTurn; + if (!confirmedTurn || confirmedTurn.status !== "completed" + || confirmedTurn.pendingConsultationQuestion !== question + || !confirmedTurn.actions.includes("continue_original_question")) return; + if (account.credits <= 0) { + openAccountDialog("redeem", creditTrigger.current); + return; + } if (rectificationQuestionHandoff.current.peek() && !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) { rectificationQuestionHandoff.current.clear(); @@ -2383,6 +2451,26 @@ export default function Home() { setRectificationContinuationPending(true); setRectificationError(""); try { + const durableClaim = await durableRectificationQuestionHandoff.current.claim({ + caseId: confirmedTurn.caseId, + turnVersion: confirmedTurn.turnVersion, + question, + }); + if (durableClaim.status === "in_progress") { + setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。"); + return; + } + if (durableClaim.status === "consumed") { + setRectificationSurfaceOpen(false); + setRectificationPendingQuestion(null); + setRectificationInitialTurn(null); + setComposerNotice("原问题已经继续回答,不会再次发送或扣点。"); + return; + } + if (durableClaim.status !== "claimed") { + setComposerNotice("原问题仍保留,请刷新校正状态后重试。"); + return; + } const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion( question, { sessionId: activeSession.id, theme: activeSession.theme }, @@ -2393,7 +2481,19 @@ export default function Home() { current, context.sessionId, )); - return send(context.question, context.theme, null, null, context.sessionId); + return send( + context.question, + context.theme, + null, + null, + context.sessionId, + { + caseId: durableClaim.caseId, + turnVersion: durableClaim.turnVersion, + claimActionId: durableClaim.claimActionId, + requestId: durableClaim.requestId, + }, + ); }, ); if (completed) { diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index a4c445a4..54b69f68 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -105,7 +105,9 @@ export function ConversationalRectificationSurface({ const restoreAbandonFocus = useRef(false); const focusTerminalForCase = useRef(null); const turn = controller.turn; - const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; + const pendingQuestion = turn?.status === "completed" + ? turn.pendingConsultationQuestion + : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; const abandonIdentity = turn ? `${turn.caseId}:${turn.turnVersion}:${turn.status}` : null; diff --git a/frontend/src/components/sidebar-session-row.tsx b/frontend/src/components/sidebar-session-row.tsx index b0d8e474..f1adcc78 100644 --- a/frontend/src/components/sidebar-session-row.tsx +++ b/frontend/src/components/sidebar-session-row.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { forwardRef } from "react"; import { SidebarMenuButton } from "@/components/ui/sidebar"; +import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence"; export type SidebarSession = { readonly id: string; @@ -100,7 +101,7 @@ export const SidebarSessionRow = forwardRef