440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { parseAgentReply } from "@/lib/agent-reply";
|
|
import type { ChatMessage } from "@/lib/chat-message-view";
|
|
import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification";
|
|
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
|
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
|
|
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
|
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
|
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
|
import {
|
|
AgenticRectificationProfileError,
|
|
acceptAgenticRectificationCandidate,
|
|
createAgenticRectificationContext,
|
|
loadAgenticRectificationProfile,
|
|
loadLatestAgenticRectificationResult,
|
|
} from "@/lib/rectification-agentic/session";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 120;
|
|
|
|
const agenticRectificationConversationFields = {
|
|
requestId: z.string().uuid(),
|
|
sessionId: z.string().uuid(),
|
|
modelId: z.string().trim().min(1).max(64).optional(),
|
|
name: z.string().trim().max(80).optional().default(""),
|
|
history: z
|
|
.array(
|
|
z.object({
|
|
role: z.enum(["user", "assistant"]),
|
|
text: z.string().max(4000),
|
|
}),
|
|
)
|
|
.max(30)
|
|
.default([]),
|
|
};
|
|
|
|
const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
|
|
z.object({
|
|
...agenticRectificationConversationFields,
|
|
action: z.literal("opening"),
|
|
}).strict(),
|
|
z.object({
|
|
...agenticRectificationConversationFields,
|
|
action: z.literal("message"),
|
|
message: z.string().trim().min(1).max(4000),
|
|
}).strict(),
|
|
z.object({
|
|
action: z.literal("accept_candidate"),
|
|
sessionId: z.string().uuid(),
|
|
resultId: z.string().uuid(),
|
|
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
|
|
}).strict(),
|
|
]);
|
|
|
|
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
|
|
const agenticRectificationMaxSteps = 8;
|
|
|
|
function readPersistedMessages(value: unknown): ChatMessage[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.flatMap((item): ChatMessage[] => {
|
|
if (!item || typeof item !== "object") return [];
|
|
const message = item as Partial<ChatMessage>;
|
|
if ((message.role !== "user" && message.role !== "assistant") || typeof message.text !== "string") return [];
|
|
return [{
|
|
role: message.role,
|
|
text: message.text.slice(0, 100_000),
|
|
...(Array.isArray(message.suggestions)
|
|
? { suggestions: message.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string").slice(0, 3) }
|
|
: {}),
|
|
}];
|
|
});
|
|
}
|
|
|
|
function currentTimeContext(now = new Date()) {
|
|
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.replace("T", " ")
|
|
.slice(0, 19);
|
|
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
|
|
}
|
|
|
|
async function rectificationBillingRequestId(
|
|
accounting: ReturnType<typeof createAdminSupabaseClient>,
|
|
userId: string,
|
|
sessionId: string,
|
|
) {
|
|
const billingRequestPrefix = `rectification:${sessionId}`;
|
|
const { data, error } = await accounting
|
|
.from("usage_reservations")
|
|
.select("request_id,status")
|
|
.eq("user_id", userId)
|
|
.eq("feature_key", "rectification")
|
|
.like("request_id", `${billingRequestPrefix}%`);
|
|
if (error) throw new Error("RectificationBillingLookupError");
|
|
const reservations = (data ?? []) as Array<{ request_id: string; status: string }>;
|
|
const active = reservations.find((item) => item.status === "completed")
|
|
?? reservations.find((item) => item.status === "reserved");
|
|
if (active) return active.request_id;
|
|
return reservations.length === 0
|
|
? billingRequestPrefix
|
|
: `${billingRequestPrefix}:retry:${reservations.length}`;
|
|
}
|
|
|
|
export async function GET(request: Request) {
|
|
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
|
let accounting: ReturnType<typeof createAdminSupabaseClient>;
|
|
try {
|
|
supabase = await createServerSupabaseClient();
|
|
accounting = createAdminSupabaseClient();
|
|
} 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 sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
|
|
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "请求格式不正确" }, { status: 400 });
|
|
const { data: session, error } = await supabase
|
|
.from("chat_sessions")
|
|
.select("id,session_type")
|
|
.eq("id", sessionId)
|
|
.eq("user_id", user.id)
|
|
.maybeSingle();
|
|
if (error) return NextResponse.json({ error: "暂时无法读取生时校正会话" }, { status: 503 });
|
|
if (!session || session.session_type !== "birth_time_rectification") return NextResponse.json({ error: "生时校正会话不存在" }, { status: 404 });
|
|
try {
|
|
return NextResponse.json({ result: await loadLatestAgenticRectificationResult(accounting, user.id, sessionId) });
|
|
} catch {
|
|
return NextResponse.json({ error: "暂时无法读取候选结果" }, { status: 503 });
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
|
let accounting: ReturnType<typeof createAdminSupabaseClient>;
|
|
try {
|
|
supabase = await createServerSupabaseClient();
|
|
accounting = createAdminSupabaseClient();
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await supabase.auth.getUser();
|
|
if (authError || !user) {
|
|
return NextResponse.json(
|
|
{ error: "请先登录", message: "登录后才能开始生时校正。" },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
const parsed = agenticRectificationRequestSchema.safeParse(
|
|
await request.json().catch(() => null),
|
|
);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "请求格式不正确", details: parsed.error.flatten() },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const promptSource = parsed.data.action === "accept_candidate" ? "" : [
|
|
parsed.data.action === "message" ? parsed.data.message : "",
|
|
...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text),
|
|
].join("\n");
|
|
if (blocksPromptExtraction(promptSource)) {
|
|
return NextResponse.json(
|
|
{ error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续描述人生事件。" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const userId = user.id;
|
|
const requestTime = new Date();
|
|
|
|
const { data: chatSession, error: chatSessionError } = await supabase
|
|
.from("chat_sessions")
|
|
.select("id,messages,session_type,model_id,model_config_version")
|
|
.eq("id", parsed.data.sessionId)
|
|
.eq("user_id", userId)
|
|
.maybeSingle();
|
|
if (chatSessionError) {
|
|
return NextResponse.json(
|
|
{ error: "暂时无法读取生时校正会话", message: "请稍后重试。" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
if (!chatSession || chatSession.session_type !== "birth_time_rectification") {
|
|
return NextResponse.json(
|
|
{ error: "生时校正会话不存在", message: "请重新进入生时校正。" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
if (parsed.data.action === "accept_candidate") {
|
|
const accepted = await acceptAgenticRectificationCandidate(
|
|
accounting,
|
|
userId,
|
|
parsed.data.sessionId,
|
|
parsed.data.time,
|
|
parsed.data.resultId,
|
|
);
|
|
if (!accepted.ok) {
|
|
return NextResponse.json(
|
|
{ error: "暂时无法采用该候选时间", message: accepted.reason },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
return NextResponse.json(accepted);
|
|
}
|
|
const conversation = parsed.data;
|
|
const requestId = conversation.requestId;
|
|
const persistedMessages = readPersistedMessages(chatSession.messages);
|
|
if (conversation.action === "opening" && persistedMessages.length > 0) {
|
|
return NextResponse.json(
|
|
{ code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
let profile;
|
|
try {
|
|
profile = await loadAgenticRectificationProfile(accounting, userId);
|
|
} catch (error) {
|
|
if (error instanceof AgenticRectificationProfileError) {
|
|
if (error.code === "profile_unavailable") {
|
|
return NextResponse.json(
|
|
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
return NextResponse.json(
|
|
{
|
|
code: "profile_incomplete",
|
|
error: "出生资料尚未完成",
|
|
message: "请先完成出生日期、出生时间线索和出生地点资料。",
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
return NextResponse.json(
|
|
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
if (!chatSession.model_id || (conversation.modelId && conversation.modelId !== chatSession.model_id)) {
|
|
return NextResponse.json(
|
|
{ error: "会话模型已经变化", message: "请刷新生时校正会话后重试,本次不会扣除点数。" },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
const selectedModel = await resolveSessionLanguageModel(
|
|
chatSession.model_id,
|
|
chatSession.model_config_version,
|
|
);
|
|
if (!selectedModel) {
|
|
return NextResponse.json(
|
|
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
let reserveResult;
|
|
let billingRequestId: string;
|
|
try {
|
|
billingRequestId = await rectificationBillingRequestId(
|
|
accounting,
|
|
userId,
|
|
conversation.sessionId,
|
|
);
|
|
reserveResult = await authorizeUsage(accounting, {
|
|
userId,
|
|
requestId: billingRequestId,
|
|
featureKey: "rectification",
|
|
requestedModelId: selectedModel.id,
|
|
creditCost: selectedModel.creditCost,
|
|
});
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.name : "UnknownError";
|
|
console.error(`[agentic-rectification] credit reserve failed request=${requestId} reason=${reason}`);
|
|
return NextResponse.json(
|
|
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
if (!reserveResult.success) {
|
|
const insufficient = reserveResult.reason === "insufficient_credits";
|
|
return NextResponse.json(
|
|
{
|
|
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
|
|
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.reason || "请稍后重试。",
|
|
},
|
|
{ status: insufficient ? 402 : 503 },
|
|
);
|
|
}
|
|
|
|
const ctx = createAgenticRectificationContext(accounting, userId, profile, conversation.sessionId);
|
|
const agent = getAgenticRectificationAgent(selectedModel, ctx);
|
|
|
|
const encoder = new TextEncoder();
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
let emitted = false;
|
|
let raw = "";
|
|
let settled = false;
|
|
const usageStartedAt = Date.now();
|
|
const settle = async (complete: boolean, usage?: Promise<{ inputTokens?: number; outputTokens?: number }>) => {
|
|
if (settled) return true;
|
|
settled = true;
|
|
try {
|
|
let settlement;
|
|
if (complete) {
|
|
const resolved = await usage;
|
|
const inputTokens = Math.max(0, Math.trunc(resolved?.inputTokens ?? 0));
|
|
const outputTokens = Math.max(0, Math.trunc(resolved?.outputTokens ?? 0));
|
|
settlement = await completeUsage(accounting, userId, billingRequestId, {
|
|
eventKey: requestId,
|
|
actualModelId: selectedModel.id,
|
|
modelConfigVersion: selectedModel.configVersion,
|
|
inputTokens,
|
|
outputTokens,
|
|
costMicrousd: Math.round((
|
|
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
|
|
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
|
|
) / 1_000_000),
|
|
durationMs: Date.now() - usageStartedAt,
|
|
});
|
|
} else {
|
|
settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled");
|
|
}
|
|
if (!settlement.success) throw new Error(settlement.error_code ?? "usage_settlement_failed");
|
|
return true;
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : "UnknownError";
|
|
console.warn(`[agentic-rectification] usage settle failed request=${requestId} complete=${complete} reason=${reason}`);
|
|
return false;
|
|
}
|
|
};
|
|
const send = (event: Record<string, unknown>) => {
|
|
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
|
};
|
|
try {
|
|
const result = await agent.stream(
|
|
[
|
|
...conversation.history.map((message) => message.role === "user"
|
|
? { role: "user" as const, content: message.text }
|
|
: { role: "assistant" as const, content: message.text }),
|
|
{
|
|
role: "user",
|
|
content: [
|
|
currentTimeContext(requestTime),
|
|
conversation.name ? `用户称呼:${conversation.name}` : "",
|
|
conversation.action === "opening" ? openingContext : conversation.message,
|
|
].filter(Boolean).join("\n"),
|
|
},
|
|
],
|
|
{ maxSteps: agenticRectificationMaxSteps },
|
|
);
|
|
for await (const chunk of result.textStream) {
|
|
if (/\S/.test(chunk)) emitted = true;
|
|
raw += chunk;
|
|
send({ type: "delta", text: chunk });
|
|
}
|
|
const reply = parseAgentReply(raw, "general");
|
|
if (!emitted || !reply.text) {
|
|
console.warn(`[agentic-rectification] empty response request=${requestId}`);
|
|
send({ type: "error", message: "生时校正没有生成有效回复,本次不会扣除点数,请重新发送。" });
|
|
await settle(false);
|
|
controller.close();
|
|
return;
|
|
}
|
|
const requestHistory = conversation.history.map((message) => ({
|
|
role: message.role,
|
|
text: message.text,
|
|
} satisfies ChatMessage));
|
|
const baseMessages = requestHistory.length > persistedMessages.length
|
|
? requestHistory
|
|
: persistedMessages;
|
|
const nextMessages: ChatMessage[] = [
|
|
...baseMessages,
|
|
...(conversation.action === "message"
|
|
? [{ role: "user" as const, text: conversation.message }]
|
|
: []),
|
|
{ role: "assistant" as const, text: reply.text, suggestions: reply.suggestions },
|
|
].slice(-500);
|
|
const { data: savedSession, error: saveError } = await supabase
|
|
.from("chat_sessions")
|
|
.update({ messages: nextMessages, updated_at: new Date().toISOString() })
|
|
.eq("id", conversation.sessionId)
|
|
.eq("user_id", userId)
|
|
.eq("session_type", "birth_time_rectification")
|
|
.select("id")
|
|
.maybeSingle();
|
|
if (saveError || !savedSession) throw new Error("RectificationSessionPersistenceError");
|
|
try {
|
|
const candidateResult = await loadLatestAgenticRectificationResult(accounting, userId, conversation.sessionId);
|
|
if (candidateResult) send({ type: "candidates", result: candidateResult });
|
|
} catch {
|
|
console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`);
|
|
}
|
|
if (!await settle(true, result.totalUsage)) {
|
|
send({ type: "error", message: "生时校正回复已生成,但用量结算失败,请稍后重试。" });
|
|
controller.close();
|
|
return;
|
|
}
|
|
send({ type: "done", emitted: true });
|
|
controller.close();
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.name : "UnknownError";
|
|
console.error(`[agentic-rectification] generation failed request=${requestId} reason=${reason}`);
|
|
try {
|
|
send({ type: "error", message: "生时校正暂时不可用,请稍后再试。" });
|
|
} catch {
|
|
// controller may already be errored
|
|
}
|
|
await settle(false);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(body, {
|
|
headers: {
|
|
"cache-control": "no-cache, no-transform",
|
|
"content-type": "application/x-ndjson; charset=utf-8",
|
|
"x-accel-buffering": "no",
|
|
"x-ayanam-request-id": requestId,
|
|
},
|
|
});
|
|
}
|