import { Agent } from "@mastra/core/agent"; import { CONSULTATION_HISTORY_TAIL_MAX_CHARS, lastConsultationPair, parseSessionContextSummary, storedConsultationTurns, type SessionContextSummaryV1, } from "@/lib/consultation-session-history"; import type { ResolvedLanguageModel } from "@/mastra/model"; export const SESSION_CONTEXT_SUMMARY_TIMEOUT_MS = 15_000; export const SESSION_CONTEXT_SUMMARY_MAX_HAN = 800; export const SESSION_CONTEXT_SUMMARY_MAX_TOKENS = 600; export const SESSION_CONTEXT_SUMMARY_INSTRUCTIONS = `你在维护咨询会话的滚动摘要,只供下一轮模型使用。 只根据给定的问答文本写作,不要发明没出现过的事实。 输出不超过 800 个汉字,必须使用下面四个标题,每个标题下用短句: 已问过的问题 已给出的结论(含应期、置信度、blocked 项) 用户补充的事实 未决与待追问 不要写出生日期、出生时间、出生地、姓名、邮箱。`; type DisposableAbort = Readonly<{ signal: AbortSignal; dispose: () => void; }>; function composedAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): DisposableAbort { const controller = new AbortController(); // Must stay ref'd. The platform timeout helper uses an unref timer (BUG-523). const timeoutId = globalThis.setTimeout(() => { if (!controller.signal.aborted) { controller.abort(new DOMException("session context summary timed out", "TimeoutError")); } }, timeoutMs); const onExternalAbort = () => { if (!controller.signal.aborted) { controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError")); } }; if (signal) { if (signal.aborted) onExternalAbort(); else signal.addEventListener("abort", onExternalAbort); } return { signal: controller.signal, dispose: () => { globalThis.clearTimeout(timeoutId); signal?.removeEventListener("abort", onExternalAbort); }, }; } function whenAborted(signal: AbortSignal): { promise: Promise; dispose: () => void } { let onAbort: (() => void) | undefined; const promise = new Promise((_, reject) => { const fail = () => { reject(signal.reason ?? new Error("aborted")); }; if (signal.aborted) { fail(); return; } onAbort = fail; signal.addEventListener("abort", fail, { once: true }); }); return { promise, dispose: () => { if (onAbort) signal.removeEventListener("abort", onAbort); }, }; } function clipHan(value: string, maxChars: number): string { const characters = Array.from(value); return characters.length > maxChars ? characters.slice(0, maxChars).join("") : value; } const ISO_DATE = /\d{4}-\d{2}-\d{2}/g; const CLOCK_TIME = /\b\d{1,2}:\d{2}(?::\d{2})?\b/g; const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; export function sanitizeSessionContextSummary(raw: string): string | null { const stripped = raw .replace(EMAIL, "") .replace(ISO_DATE, "") .replace(CLOCK_TIME, "") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); if (!stripped) return null; return clipHan(stripped, SESSION_CONTEXT_SUMMARY_MAX_HAN); } export function tailCharCount( messages: unknown, summary: SessionContextSummaryV1 | null, options: { excludeRequestId?: string } = {}, ): number { const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId }); const tail = summary ? turns.filter((turn) => turn.index > summary.throughMessageIndex) : turns; return tail.reduce((sum, turn) => sum + turn.text.length, 0); } export function shouldCheckpoint( messages: unknown, summary: SessionContextSummaryV1 | null, options: { excludeRequestId?: string } = {}, ): boolean { return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS; } export function messagesForSummaryInput( messages: unknown, summary: SessionContextSummaryV1 | null, options: { excludeRequestId?: string } = {}, ): Array<{ role: "user" | "assistant"; text: string; index: number; requestId: string | null }> { const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId }); const tail = summary ? turns.filter((turn) => turn.index > summary.throughMessageIndex) : turns; const lastPair = lastConsultationPair(tail); const pairStart = lastPair[0]; if (!pairStart) return []; return tail.filter((turn) => turn.index < pairStart.index); } export function buildSummaryPrompt( previous: SessionContextSummaryV1 | null, messages: unknown, options: { excludeRequestId?: string } = {}, ): string { const input = messagesForSummaryInput(messages, previous, options); const lines = input.map((turn) => `${turn.role === "user" ? "用户" : "助手"}:${turn.text}`); return [ previous?.text.trim() ? `上一份摘要:\n${previous.text.trim()}` : "上一份摘要:无", "需要并入摘要的问答(不含最后一对):", lines.join("\n") || "(无)", ].join("\n\n"); } export async function generateSessionContextSummaryText( model: ResolvedLanguageModel, prompt: string, signal?: AbortSignal, ): Promise { const agent = new Agent({ id: `session-context-summary-${model.id}`, name: "Session Context Summary", model: model.model, instructions: SESSION_CONTEXT_SUMMARY_INSTRUCTIONS, }); const result = await agent.generate([{ role: "user", content: prompt }], { abortSignal: signal, modelSettings: { maxOutputTokens: SESSION_CONTEXT_SUMMARY_MAX_TOKENS }, }); return typeof result.text === "string" ? result.text : ""; } export async function generateSessionContextSummary(input: { model?: ResolvedLanguageModel | null; previous: SessionContextSummaryV1 | null; messages: unknown; excludeRequestId?: string; signal?: AbortSignal; timeoutMs?: number; generateText?: (prompt: string, signal?: AbortSignal) => Promise; }): Promise { const prompt = buildSummaryPrompt(input.previous, input.messages, { excludeRequestId: input.excludeRequestId, }); const generate = input.generateText ?? (input.model ? (nextPrompt: string, signal?: AbortSignal) => generateSessionContextSummaryText( input.model as ResolvedLanguageModel, nextPrompt, signal, ) : null); if (!generate) return null; const composed = composedAbortSignal(input.signal, input.timeoutMs ?? SESSION_CONTEXT_SUMMARY_TIMEOUT_MS); const aborted = whenAborted(composed.signal); try { const raw = await Promise.race([generate(prompt, composed.signal), aborted.promise]); return sanitizeSessionContextSummary(raw); } catch { return null; } finally { aborted.dispose(); composed.dispose(); } } export function nextSessionContextSummary( messages: unknown, previous: SessionContextSummaryV1 | null, text: string, updatedAt: string, options: { excludeRequestId?: string } = {}, ): SessionContextSummaryV1 | null { const covered = messagesForSummaryInput(messages, previous, options); const last = covered.at(-1); if (!last) return null; return { version: 1, text, throughRequestId: last.requestId ?? previous?.throughRequestId ?? "", throughMessageIndex: last.index, messageCount: last.index + 1, updatedAt, }; } export async function writeSessionContextSummary(input: { seenUpdatedAt: string | null; summary: SessionContextSummaryV1; update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise; }): Promise<"written" | "abandoned"> { const written = await input.update(input.summary, input.seenUpdatedAt); return written ? "written" : "abandoned"; } export async function checkpointSessionContextSummary(input: { messages: unknown; summary: unknown; excludeRequestId?: string; now?: () => Date; generateText: (prompt: string, signal?: AbortSignal) => Promise; update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise; timeoutMs?: number; }): Promise<"written" | "skipped" | "abandoned" | "failed"> { const previous = parseSessionContextSummary(input.summary); if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) { return "skipped"; } try { const text = await generateSessionContextSummary({ previous, messages: input.messages, excludeRequestId: input.excludeRequestId, generateText: input.generateText, timeoutMs: input.timeoutMs, }); if (!text) return "failed"; const next = nextSessionContextSummary( input.messages, previous, text, (input.now ?? (() => new Date()))().toISOString(), { excludeRequestId: input.excludeRequestId }, ); if (!next) return "skipped"; return writeSessionContextSummary({ seenUpdatedAt: previous?.updatedAt ?? null, summary: next, update: input.update, }); } catch { return "failed"; } }