fix: harden multi-model chat selection

This commit is contained in:
Jesse_Chen
2026-07-17 14:09:57 +08:00
parent 4968c961f7
commit 6c02f27d1c
13 changed files with 338 additions and 91 deletions
+32 -21
View File
@@ -9,6 +9,7 @@ import {
} from "@/mastra/model";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { CreditRpcError, runCreditRpc } from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
@@ -55,9 +56,10 @@ async function recordModelUsage(
.eq("transaction_type", "reserve")
.eq("request_id", requestId);
if (error) console.warn("[billing] unable to record model usage", error.message);
if (error) console.warn(`[billing] unable to record model usage request=${requestId} model=${modelId}`);
} catch (error) {
console.warn("[billing] unable to read model usage", error);
const reason = error instanceof Error ? error.name : "UnknownError";
console.warn(`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`);
}
}
@@ -103,26 +105,33 @@ export async function POST(request: Request) {
);
}
const selectedModel = resolveLanguageModel(parsed.data.modelId);
if (!selectedModel) {
const userId = user.id;
const requestId = parsed.data.requestId;
let modelSelection;
try {
modelSelection = await reserveConsultationModel(
parsed.data.modelId,
resolveLanguageModel,
() => runCreditRpc(accounting, "begin_consultation_credit", userId, requestId),
);
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[billing] reservation failed request=${requestId} reason=${reason}`);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
);
}
if (modelSelection.status === "unavailable") {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
);
}
const userId = user.id;
const requestId = parsed.data.requestId;
let reserveResult;
try {
reserveResult = await runCreditRpc(accounting, "begin_consultation_credit", userId, requestId);
} catch (error) {
console.error(`[billing] reservation failed for ${requestId}`, error);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
);
}
const selectedModel = modelSelection.model;
const reserveResult = modelSelection.reservation;
if (!reserveResult.success) {
const insufficient = reserveResult.error_code === "insufficient_credits";
@@ -139,7 +148,8 @@ export async function POST(request: Request) {
try {
await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId);
} catch (error) {
console.error(`[billing] cancellation failed for ${requestId}`, error);
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[billing] cancellation failed request=${requestId} reason=${reason}`);
}
}
@@ -175,7 +185,7 @@ export async function POST(request: Request) {
]);
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(accounting, userId, requestId, selectedModel.id, result.totalUsage);
void recordModelUsage(accounting, userId, requestId, modelSelection.usageModelId, result.totalUsage);
};
const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel);
return streamTextResponse(result.textStream, {
@@ -187,12 +197,13 @@ export async function POST(request: Request) {
});
} catch (error) {
await cancel();
const message = error instanceof Error ? error.message : "咨询服务暂时不可用";
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`);
return NextResponse.json(
{
error: "暂时无法生成解读",
message,
recovery: `请确认 Python API 已运行,并检查 JYOTISH_API_BASE 与模型配置。${languageModelConfigurationMessage() ? ` ${languageModelConfigurationMessage()}` : ""}`,
message: "咨询服务暂时不可用,请稍后再试。",
recovery: languageModelConfigurationMessage() ? "当前没有可用的咨询模型,请联系管理员。" : "稍后重试,或换一个模型继续。",
},
{ status: 503 },
);
+62 -21
View File
@@ -11,6 +11,10 @@ import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
SessionModelPersistenceQueue,
persistSessionModelSelection,
} from "@/lib/session-model-persistence";
import {
parsePublicModelCatalog,
resolveSessionModelId,
@@ -470,6 +474,9 @@ export default function Home() {
const cancellationInFlight = useRef(false);
const stoppedRequestAwaitingSettlement = useRef<string | null>(null);
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
const modelPersistence = useRef(new SessionModelPersistenceQueue());
const modelSyncFailures = useRef(new Set<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
@@ -616,16 +623,6 @@ export default function Home() {
nextSessions = [initialSession];
}
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
const { error } = await supabase
.from("chat_sessions")
.update({ model_id: nextModelCatalog.defaultModelId })
.eq("user_id", nextAccount.user.id)
.in("id", parsedSessions.fallbackSessionIds)
.abortSignal(controller.signal);
if (error) throw error;
}
if (controller.signal.aborted) return;
const nextProfile = readProfile(profileResult.data);
setAccount(nextAccount);
@@ -642,6 +639,18 @@ export default function Home() {
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
}
setAccountError("");
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
const { error } = await supabase
.from("chat_sessions")
.update({ model_id: nextModelCatalog.defaultModelId })
.eq("user_id", nextAccount.user.id)
.in("id", parsedSessions.fallbackSessionIds)
.abortSignal(controller.signal);
if (error && !controller.signal.aborted) {
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
}
}
} catch (caught) {
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
@@ -828,23 +837,55 @@ export default function Home() {
}
async function selectSessionModel(modelId: string) {
if (!activeSession || !modelCatalog || pendingSessionId || cancellationPending || creatingSession) return;
const userId = account?.user.id;
if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return;
const selectedModel = modelCatalog.models.find((model) => model.id === modelId);
if (!selectedModel || activeSession.modelId === modelId) return;
const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id);
if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return;
const nextSession: ChatSession = {
...activeSession,
modelId,
updatedAt: timestamp(),
};
updateSession(activeSession.id, () => nextSession);
const nextSession: ChatSession = retryingFailedSync
? activeSession
: { ...activeSession, modelId, updatedAt: timestamp() };
const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1;
modelSelectionVersions.current.set(nextSession.id, selectionVersion);
if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession);
setRequestError(null);
setComposerNotice(`已切换至 ${selectedModel.label},只影响之后的问题。`);
setComposerNotice(retryingFailedSync
? `正在重新同步 ${selectedModel.label}`
: `已切换至 ${selectedModel.label},只影响之后的问题。`);
try {
await persistSession(nextSession);
await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection(
async ({ values, sessionId, userId: ownerId }) => {
if (process.env.NODE_ENV === "development" && uiPreview.current) {
return { found: true, error: null };
}
const { data, error } = await createBrowserSupabaseClient()
.from("chat_sessions")
.update(values)
.eq("id", sessionId)
.eq("user_id", ownerId)
.select("id")
.maybeSingle();
return { found: Boolean(data), error: error?.message ?? null };
},
userId,
nextSession.id,
modelId,
));
if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return;
modelSelectionVersions.current.delete(nextSession.id);
modelSyncFailures.current.delete(nextSession.id);
if (retryingFailedSync && activeSessionIdRef.current === nextSession.id) {
setComposerNotice(`已同步 ${selectedModel.label}`);
}
} catch (caught) {
setComposerNotice(`已在当前页面切换至 ${selectedModel.label},但云端同步失败。`);
if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return;
modelSelectionVersions.current.delete(nextSession.id);
modelSyncFailures.current.add(nextSession.id);
if (activeSessionIdRef.current === nextSession.id) {
setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`);
}
setRequestError({
sessionId: nextSession.id,
message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。",