fix: make rectification question handoff durable

This commit is contained in:
Jesse_Chen
2026-07-21 11:58:42 +08:00
parent 47f2e9c989
commit 53dddb8b43
16 changed files with 2310 additions and 105 deletions
@@ -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 },
);
}
}
+166 -23
View File
@@ -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<void> | 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,
+137 -37
View File
@@ -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<Theme>());
const durableRectificationQuestionHandoff = useRef(
createDurableRectificationQuestionHandoffClient(),
);
const rectificationContinuationInFlight = useRef(false);
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(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<boolean> {
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) {
@@ -105,7 +105,9 @@ export function ConversationalRectificationSurface({
const restoreAbandonFocus = useRef(false);
const focusTerminalForCase = useRef<string | null>(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;
@@ -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<HTMLButtonElement, SidebarSessionRow
>
<MoreHorizontal aria-hidden="true" />
</button>
{menuOpen ? (
{sessionMutationMenuVisible(menuOpen, disabled) ? (
<div className="session-actions" role="menu" aria-label={`${session.title} 操作`}>
<button type="button" role="menuitem" onClick={() => runAction(onTogglePinned)}>
{session.pinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
@@ -0,0 +1,23 @@
export type ChatSessionWriteResult = Readonly<{
found: boolean;
error: string | null;
}>;
export function sessionMutationMenuVisible(menuOpen: boolean, pending: boolean) {
return menuOpen && !pending;
}
/**
* Updates an already-created session. A missing row is a durable deletion, not
* an invitation to upsert: late model responses must never resurrect a chat
* removed on another device.
*/
export async function persistExistingChatSession(
write: () => PromiseLike<ChatSessionWriteResult>,
): Promise<void> {
const result = await write();
if (result.error) throw new Error(`云端同步失败:${result.error}`);
if (!result.found) {
throw new Error("聊天记录已在另一设备删除,晚到内容不会重新创建该记录。");
}
}
@@ -0,0 +1,102 @@
import { z } from "zod";
import {
RectificationHandoffServiceError,
type RectificationHandoffService,
} from "./rectification-handoff-service.ts";
const identity = {
caseId: z.string().uuid(),
turnVersion: z.number().int().nonnegative(),
actionId: z.string().uuid(),
question: z.string().trim().min(1).max(500),
} as const;
const commandSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("attach"), ...identity }).strict(),
z.object({ type: z.literal("claim"), ...identity }).strict(),
]);
type Authenticated = Readonly<{ userId: string }>;
export type RectificationHandoffRouteDependencies = Readonly<{
authenticate(): Promise<Authenticated | null>;
service(): RectificationHandoffService;
}>;
function publicFailure(error: unknown) {
if (error instanceof RectificationHandoffServiceError) {
if (error.code === "not_found") {
return Response.json(
{ code: "handoff_not_found", message: "没有找到可继续的原问题。" },
{ status: 404 },
);
}
if (error.code === "stale") {
return Response.json(
{ code: "stale_turn", message: "校正状态已经更新,请刷新后重试。" },
{ status: 409 },
);
}
if (error.code === "conflict") {
return Response.json(
{ code: "handoff_conflict", message: "原问题状态已经变化,请刷新后查看。" },
{ status: 409 },
);
}
}
return Response.json(
{ code: "handoff_unavailable", message: "暂时无法保存或继续原问题,请稍后重试。" },
{ status: 503 },
);
}
export function createRectificationHandoffHandlers(
dependencies: RectificationHandoffRouteDependencies,
) {
return Object.freeze({
async get() {
const authenticated = await dependencies.authenticate();
if (!authenticated) {
return Response.json(
{ code: "authentication_required", message: "登录后才能继续原问题。" },
{ status: 401 },
);
}
try {
const handoff = await dependencies.service().load({ userId: authenticated.userId });
return handoff
? Response.json(handoff)
: new Response(null, { status: 204 });
} catch (error) {
return publicFailure(error);
}
},
async post(request: Request) {
const authenticated = await dependencies.authenticate();
if (!authenticated) {
return Response.json(
{ code: "authentication_required", message: "登录后才能保存或继续原问题。" },
{ status: 401 },
);
}
const parsed = commandSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return Response.json(
{ code: "invalid_command", message: "原问题交接请求格式不正确。" },
{ status: 400 },
);
}
try {
const service = dependencies.service();
const input = { userId: authenticated.userId, ...parsed.data };
const result = parsed.data.type === "attach"
? await service.attach(input)
: await service.claim(input);
return Response.json(result);
} catch (error) {
return publicFailure(error);
}
},
});
}
@@ -0,0 +1,209 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { conversationalRectificationTurnSchema } from "./conversational-rectification/contracts.ts";
import { storedCaseRowSchema } from "./conversational-rectification/persistence-contracts.ts";
const uuidSchema = z.string().uuid();
const fingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/);
const handoffProjectionSchema = z.object({
caseId: uuidSchema,
turnVersion: z.number().int().nonnegative(),
question: z.string().trim().min(1).max(500),
questionFingerprint: fingerprintSchema,
requestId: uuidSchema,
status: z.enum(["pending", "claimed", "in_progress", "consumed"]),
turn: conversationalRectificationTurnSchema,
}).strict();
const executionProjectionSchema = z.object({
status: z.enum(["ready", "in_progress", "consumed", "released"]),
requestId: uuidSchema,
billingReused: z.boolean().optional().default(false),
credits: z.number().int().nonnegative().nullable().optional(),
}).strict();
const settlementProjectionSchema = z.object({
status: z.enum(["pending", "consumed"]),
requestId: uuidSchema,
credits: z.number().int().nonnegative().nullable(),
}).strict();
export type RectificationHandoffProjection = z.infer<typeof handoffProjectionSchema>;
export type RectificationHandoffExecution = z.infer<typeof executionProjectionSchema>;
export type RectificationHandoffSettlement = z.infer<typeof settlementProjectionSchema>;
export type RectificationHandoffRpcClient = Readonly<{
rpc(
name: string,
args: Readonly<Record<string, unknown>>,
): PromiseLike<Readonly<{ data: unknown; error: unknown }>>;
}>;
export class RectificationHandoffServiceError extends Error {
readonly name = "RectificationHandoffServiceError";
constructor(readonly code: "not_found" | "stale" | "conflict" | "unavailable") {
super(`Rectification handoff failed: ${code}`);
}
}
function rpcMessage(error: unknown): string {
if (!error || typeof error !== "object") return "";
try {
const message = (error as { message?: unknown }).message;
return typeof message === "string" ? message : "";
} catch {
return "";
}
}
function mappedError(error: unknown): RectificationHandoffServiceError {
const message = rpcMessage(error);
if (message === "conversational_case_not_found") {
return new RectificationHandoffServiceError("not_found");
}
if (message === "conversational_stale_turn") {
return new RectificationHandoffServiceError("stale");
}
if (message === "conversational_action_conflict") {
return new RectificationHandoffServiceError("conflict");
}
return new RectificationHandoffServiceError("unavailable");
}
function single(value: unknown): unknown {
if (!Array.isArray(value)) return value;
if (value.length !== 1) throw new RectificationHandoffServiceError("unavailable");
return value[0];
}
async function rpc(
client: RectificationHandoffRpcClient,
name: string,
args: Readonly<Record<string, unknown>>,
): Promise<unknown> {
try {
const result = await client.rpc(name, args);
if (result.error) throw mappedError(result.error);
return single(result.data);
} catch (error) {
if (error instanceof RectificationHandoffServiceError) throw error;
throw new RectificationHandoffServiceError("unavailable");
}
}
export function rectificationQuestionFingerprint(question: string): string {
return createHash("sha256").update(question, "utf8").digest("hex");
}
export function createRectificationHandoffService(client: RectificationHandoffRpcClient) {
return Object.freeze({
async attach(input: Readonly<{
userId: string;
caseId: string;
turnVersion: number;
actionId: string;
question: string;
}>) {
const question = input.question.trim();
const parsed = storedCaseRowSchema.safeParse(await rpc(
client,
"attach_conversational_rectification_question",
{
p_user_id: input.userId,
p_case_id: input.caseId,
p_expected_version: input.turnVersion,
p_action_id: input.actionId,
p_question: question,
p_question_fingerprint: rectificationQuestionFingerprint(question),
},
));
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
return parsed.data.latest_turn;
},
async load(input: Readonly<{ userId: string; caseId?: string }>) {
const value = await rpc(client, "load_conversational_rectification_handoff", {
p_user_id: input.userId,
p_case_id: input.caseId ?? null,
});
if (value === null) return null;
const parsed = handoffProjectionSchema.safeParse(value);
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
return parsed.data;
},
async claim(input: Readonly<{
userId: string;
caseId: string;
turnVersion: number;
actionId: string;
question: string;
}>) {
const question = input.question.trim();
const parsed = handoffProjectionSchema.safeParse(await rpc(
client,
"claim_conversational_rectification_handoff",
{
p_user_id: input.userId,
p_case_id: input.caseId,
p_expected_version: input.turnVersion,
p_action_id: input.actionId,
p_question_fingerprint: rectificationQuestionFingerprint(question),
},
));
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
return parsed.data;
},
async beginExecution(input: Readonly<{
userId: string;
caseId: string;
turnVersion: number;
claimActionId: string;
requestId: string;
question: string;
}>): Promise<RectificationHandoffExecution> {
const question = input.question.trim();
const parsed = executionProjectionSchema.safeParse(await rpc(
client,
"begin_conversational_rectification_handoff_execution",
{
p_user_id: input.userId,
p_case_id: input.caseId,
p_expected_version: input.turnVersion,
p_claim_action_id: input.claimActionId,
p_request_id: input.requestId,
p_question_fingerprint: rectificationQuestionFingerprint(question),
},
));
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
return parsed.data;
},
async settle(input: Readonly<{
userId: string;
caseId: string;
claimActionId: string;
requestId: string;
emitted: boolean;
}>): Promise<RectificationHandoffSettlement> {
const parsed = settlementProjectionSchema.safeParse(await rpc(
client,
"settle_conversational_rectification_handoff",
{
p_user_id: input.userId,
p_case_id: input.caseId,
p_claim_action_id: input.claimActionId,
p_request_id: input.requestId,
p_emitted: input.emitted,
},
));
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
return parsed.data;
},
});
}
export type RectificationHandoffService = ReturnType<typeof createRectificationHandoffService>;
@@ -1,3 +1,9 @@
import { z } from "zod";
import {
conversationalRectificationTurnSchema,
type ConversationalRectificationTurn,
} from "./conversational-rectification/contracts.ts";
export type RectificationQuestionHandoff<Theme extends string> = Readonly<{
question: string;
sessionId: string;
@@ -104,3 +110,154 @@ export function createRectificationQuestionHandoffCoordinator<Theme extends stri
export type RectificationQuestionHandoffCoordinator<Theme extends string> = ReturnType<
typeof createRectificationQuestionHandoffCoordinator<Theme>
>;
const durableHandoffSchema = z.object({
caseId: z.string().uuid(),
turnVersion: z.number().int().nonnegative(),
question: z.string().trim().min(1).max(500),
questionFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
requestId: z.string().uuid(),
status: z.enum(["pending", "claimed", "in_progress", "consumed"]),
turn: conversationalRectificationTurnSchema,
}).strict();
export type DurableRectificationQuestionHandoff = z.infer<typeof durableHandoffSchema>;
export type ClaimedRectificationQuestionHandoff = DurableRectificationQuestionHandoff & Readonly<{
claimActionId: string;
}>;
export class DurableRectificationHandoffError extends Error {
readonly name = "DurableRectificationHandoffError";
constructor(
readonly status: number,
readonly code: string | null,
message: string,
) {
super(message);
}
}
type HandoffFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
async function handoffPayload(response: Response): Promise<unknown> {
if (response.status === 204) return null;
const contentType = response.headers.get("content-type") ?? "";
return contentType.includes("application/json")
? response.json().catch(() => null)
: null;
}
function errorFields(value: unknown): { code: string | null; message: string } {
if (!value || typeof value !== "object") {
return { code: null, message: "暂时无法保存或继续原问题,请稍后重试。" };
}
const record = value as { code?: unknown; message?: unknown };
return {
code: typeof record.code === "string" ? record.code : null,
message: typeof record.message === "string"
? record.message
: "暂时无法保存或继续原问题,请稍后重试。",
};
}
/**
* Browser transport for the account-level handoff. A claim keeps one action id
* until the server returns a terminal response, so a lost HTTP response replays
* the same durable identity rather than creating another consultation attempt.
*/
export function createDurableRectificationQuestionHandoffClient(input: Readonly<{
fetch?: HandoffFetch;
createActionId?: () => string;
}> = {}) {
const fetcher = input.fetch ?? globalThis.fetch.bind(globalThis);
const createActionId = input.createActionId ?? (() => globalThis.crypto.randomUUID());
const claimActions = new Map<string, string>();
async function post(command: Readonly<Record<string, unknown>>): Promise<unknown> {
const body = JSON.stringify(command);
let lastError: unknown = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await fetcher("/api/birth-time-conversation/handoff", {
method: "POST",
headers: { "content-type": "application/json" },
body,
});
const payload = await handoffPayload(response);
if (!response.ok) {
const fields = errorFields(payload);
throw new DurableRectificationHandoffError(
response.status,
fields.code,
fields.message,
);
}
return payload;
} catch (error) {
lastError = error;
if (error instanceof DurableRectificationHandoffError || attempt > 0) throw error;
}
}
throw lastError;
}
return Object.freeze({
async load(): Promise<DurableRectificationQuestionHandoff | null> {
const response = await fetcher("/api/birth-time-conversation/handoff", {
method: "GET",
headers: { accept: "application/json" },
});
const payload = await handoffPayload(response);
if (response.status === 204) return null;
if (!response.ok) {
const fields = errorFields(payload);
throw new DurableRectificationHandoffError(response.status, fields.code, fields.message);
}
return durableHandoffSchema.parse(payload);
},
async attach(request: Readonly<{
caseId: string;
turnVersion: number;
question: string;
actionId?: string;
}>): Promise<ConversationalRectificationTurn> {
const payload = await post({
type: "attach",
caseId: request.caseId,
turnVersion: request.turnVersion,
actionId: request.actionId ?? createActionId(),
question: request.question.trim(),
});
return conversationalRectificationTurnSchema.parse(payload);
},
async claim(request: Readonly<{
caseId: string;
turnVersion: number;
question: string;
}>): Promise<ClaimedRectificationQuestionHandoff> {
const identity = JSON.stringify([
request.caseId,
request.turnVersion,
request.question.trim(),
]);
const actionId = claimActions.get(identity) ?? createActionId();
claimActions.set(identity, actionId);
const payload = durableHandoffSchema.parse(await post({
type: "claim",
caseId: request.caseId,
turnVersion: request.turnVersion,
actionId,
question: request.question.trim(),
}));
if (payload.status !== "claimed") claimActions.delete(identity);
return Object.freeze({ ...payload, claimActionId: actionId });
},
});
}
export type DurableRectificationQuestionHandoffClient = ReturnType<
typeof createDurableRectificationQuestionHandoffClient
>;
+13 -1
View File
@@ -1,4 +1,5 @@
type StreamHooks = {
readonly onFirstOutput?: () => Promise<void>;
readonly onComplete?: () => Promise<void>;
readonly onError?: (error: unknown, emitted: boolean) => Promise<void>;
readonly onCancel?: (emitted: boolean) => Promise<void>;
@@ -157,6 +158,13 @@ export function streamTextResponse(
let pending = "";
let settled = false;
let emitted = false;
let firstOutputSettled = false;
async function settleFirstOutput(value: string) {
if (!value || firstOutputSettled) return;
firstOutputSettled = true;
await options.onFirstOutput?.();
}
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
@@ -167,7 +175,10 @@ export function streamTextResponse(
const finalText = visibleTransformer
? visibleTransformer.finish(pending)
: pending;
if (finalText) controller.enqueue(encoder.encode(finalText));
if (finalText) {
await settleFirstOutput(finalText);
controller.enqueue(encoder.encode(finalText));
}
settled = true;
if (!emitted) {
const error = new Error("empty_stream");
@@ -190,6 +201,7 @@ export function streamTextResponse(
? visibleTransformer.push(stable)
: stable;
if (transformed) {
await settleFirstOutput(transformed);
controller.enqueue(encoder.encode(transformed));
return;
}