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

This commit is contained in:
Jesse_Chen
2026-08-08 21:34:03 +08:00
parent c16c9bfbde
commit 32b683ebef
9 changed files with 1529 additions and 179 deletions
+182 -44
View File
@@ -7,16 +7,12 @@ import {
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { parseAgentReply } from "@/lib/agent-reply";
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
} from "@/lib/consultation-entrypoint";
import {
authorizeUsage,
completeUsage,
CreditRpcError,
releaseUsage,
} from "@/lib/consultation-billing";
import { CreditRpcError } from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
@@ -72,6 +68,43 @@ const generalChatRequestSchema = z.object({
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
const consultationReservationSchema = z.object({
success: z.boolean(),
reservation_id: z.string().uuid().nullable(),
source: z.enum(["subscription", "credits"]).nullable(),
credits: z.number().int().nullable(),
subscription_id: z.string().uuid().nullable(),
reason: z.string().nullable(),
retry_after_seconds: z.number().int().nullable(),
});
const consultationCompletionSchema = z.object({
success: z.boolean(),
credits: z.number().int().nullable(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
const detachedSettlementAttempts = 3;
// ponytail: Staging MVP ceiling—without a queue/worker, detached settlement gets only three short retries.
async function retryDetachedSettlement<T>(action: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= detachedSettlementAttempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = error;
if (attempt < detachedSettlementAttempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
}
throw lastError;
}
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -176,6 +209,8 @@ export async function POST(request: Request) {
const userId = user.id;
const requestId = parsed.data.requestId;
const sessionId = parsed.data.sessionId;
const consultationTheme = parsed.data.theme;
const userControlledPrompt = [
parsed.data.question,
@@ -210,17 +245,24 @@ export async function POST(request: Request) {
reserve: () => reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
(model) => authorizeUsage(accounting, {
userId,
requestId,
featureKey: "chat.standard",
requestedModelId: model.id,
creditCost: model.creditCost,
}).then((result) => ({
success: result.success,
credits: result.credits,
error_code: result.reason,
})),
async (model) => {
const { data, error } = await accounting.rpc("reserve_consultation_usage", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_requested_model_id: model.id,
p_credit_cost: model.creditCost,
});
const reservation = consultationReservationSchema.safeParse(first(data ?? []));
if (error || !reservation.success) {
throw new CreditRpcError(error?.message || "invalid_reservation_response");
}
return {
success: reservation.data.success,
credits: reservation.data.credits,
error_code: reservation.data.reason,
};
},
),
});
} catch (error) {
@@ -277,9 +319,23 @@ export async function POST(request: Request) {
);
}
async function cancel() {
try {
await releaseUsage(accounting, userId, requestId, "consultation_cancelled");
const result = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("cancel_consultation_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const parsedCancellation = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCancellation.success) {
throw new CreditRpcError(error?.message || "invalid_cancellation_response");
}
return parsedCancellation.data;
});
if (!result.success && result.error_code !== "request_completed") {
throw new CreditRpcError(result.error_code || "cancellation_rejected");
}
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
@@ -289,25 +345,67 @@ export async function POST(request: Request) {
}
const usageStartedAt = Date.now();
async function complete(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0));
const costMicrousd = Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000);
const result = await completeUsage(accounting, userId, requestId, {
return {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
costMicrousd,
costMicrousd: Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000),
durationMs: Date.now() - usageStartedAt,
});
if (!result.success)
throw new CreditRpcError(result.error_code || "completion_rejected");
};
}
async function completeResponse(
rawTransformedText: string,
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
techniqueTruth: string,
workflowReceipt: {
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
},
) {
try {
const reply = parseAgentReply(rawTransformedText, consultationTheme);
if (!reply.text) throw new Error("empty_agent_reply");
const responseMessage = {
role: "assistant" as const,
text: reply.text,
suggestions: reply.suggestions,
techniqueTruth,
workflowReceipt,
};
const actualUsage = await usagePayload(usage);
const completion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("complete_consultation_response", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_response_message: responseMessage,
p_actual_usage: actualUsage,
});
const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCompletion.success) {
throw new CreditRpcError(error?.message || "invalid_completion_response");
}
return parsedCompletion.data;
});
if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "completion_rejected");
}
} catch (error) {
await cancel();
throw error;
}
}
let settlement: Promise<void> | null = null;
@@ -332,24 +430,43 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeWithUsage : cancel);
const workflowReceipt = {
route: "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
} as const;
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
"not-applicable",
workflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": "general-no-birth-time",
"x-jyotish-workflow-status": "ready",
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": "not-applicable",
"x-jyotish-precise-timing": "blocked",
"x-jyotish-missing-layers": "birth-minute",
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers.join(","),
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
"not-applicable",
workflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
}
@@ -383,9 +500,24 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
const completeWithUsage = () => complete(result.totalUsage);
const settleInterrupted = (emitted: boolean) =>
settle(emitted ? completeWithUsage : cancel);
const responseWorkflowReceipt = {
route: workflowReceipt.route,
status: workflowReceipt.status,
preciseTiming: workflowReceipt.preciseTiming,
missingLayers: workflowReceipt.missingLayers === "none"
? []
: workflowReceipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean),
};
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
consultationMode,
@@ -393,6 +525,7 @@ export async function POST(request: Request) {
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
@@ -401,9 +534,14 @@ export async function POST(request: Request) {
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: () => settle(completeWithUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
} catch (error) {
await cancel();
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { runCreditRpc } from "@/lib/consultation-billing";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const idSchema = z.string().uuid();
const reservedLeaseMs = 15 * 60 * 1000;
// ponytail: Staging MVP ceiling—without a queue/worker, status lazily refunds reservations after this lease.
function reservationLeaseExpired(updatedAt: unknown, now = Date.now()) {
if (typeof updatedAt !== "string") return false;
const timestamp = Date.parse(updatedAt);
return Number.isFinite(timestamp) && now - timestamp >= reservedLeaseMs;
}
export async function GET(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
try {
supabase = await createServerSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const params = new URL(request.url).searchParams;
const sessionIdValue = params.get("sessionId");
const requestIdValue = params.get("requestId");
const activeLookup = sessionIdValue === null && requestIdValue === null;
if (!activeLookup && (sessionIdValue === null || requestIdValue === null)) {
return NextResponse.json({ error: "咨询请求格式不正确" }, { status: 400 });
}
const sessionId = sessionIdValue === null ? null : idSchema.safeParse(sessionIdValue);
const requestId = requestIdValue === null ? null : idSchema.safeParse(requestIdValue);
if ((sessionId && !sessionId.success) || (requestId && !requestId.success)) {
return NextResponse.json({ error: "咨询请求格式不正确" }, { status: 400 });
}
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
let query = accounting
.from("consultation_requests")
.select("request_id,session_id,status,response_message,updated_at")
.eq("user_id", user.id);
if (activeLookup) {
query = query.eq("status", "reserved").order("created_at", { ascending: false }).limit(1);
} else {
query = query
.eq("session_id", sessionId!.data)
.eq("request_id", requestId!.data);
}
const { data, error } = await query.maybeSingle();
if (error) {
return NextResponse.json({ error: "暂时无法读取咨询状态" }, { status: 503 });
}
if (!data || typeof data.session_id !== "string") {
return NextResponse.json({ error: "咨询请求不存在" }, { status: 404 });
}
let statusData = data;
if (statusData.status === "reserved" && reservationLeaseExpired(statusData.updated_at)) {
let cancellation;
try {
cancellation = await runCreditRpc(
accounting,
"cancel_consultation_credit",
user.id,
statusData.request_id,
);
} catch {
return NextResponse.json({ error: "暂时无法回收超时咨询" }, { status: 503 });
}
if (!cancellation.success && cancellation.error_code !== "request_completed") {
return NextResponse.json({ error: "暂时无法回收超时咨询" }, { status: 503 });
}
const { data: settledData, error: settledError } = await accounting
.from("consultation_requests")
.select("request_id,session_id,status,response_message,updated_at")
.eq("user_id", user.id)
.eq("session_id", statusData.session_id)
.eq("request_id", statusData.request_id)
.maybeSingle();
if (settledError || !settledData || typeof settledData.session_id !== "string") {
return NextResponse.json({ error: "暂时无法读取咨询状态" }, { status: 503 });
}
statusData = settledData;
}
return NextResponse.json({
requestId: statusData.request_id,
sessionId: statusData.session_id,
status: statusData.status,
responseMessage: statusData.response_message,
updatedAt: statusData.updated_at,
});
}