fix: harden multi-model chat selection
This commit is contained in:
@@ -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
@@ -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 : "模型选择暂时无法同步到云端。",
|
||||
|
||||
Reference in New Issue
Block a user