BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot. BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000). BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id.
252 lines
9.2 KiB
TypeScript
252 lines
9.2 KiB
TypeScript
export const CONSULTATION_HISTORY_LIMIT = 12;
|
|
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
|
|
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
|
|
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
|
|
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
|
|
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
|
|
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
|
|
// 0.4 of the history budget. At the default 128k window the budget is 40,000,
|
|
// so the checkpoint fires at 16,000 — the previous hard-coded threshold.
|
|
// Smaller windows then checkpoint before the tail can exceed the budget.
|
|
export const CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO = 0.4;
|
|
|
|
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
|
|
|
|
export type ConsultationHistoryMessage = Readonly<{
|
|
role: "user" | "assistant";
|
|
text: string;
|
|
}>;
|
|
|
|
export type SessionContextSummaryV1 = Readonly<{
|
|
version: 1;
|
|
text: string;
|
|
throughRequestId: string;
|
|
throughMessageIndex: number;
|
|
messageCount: number;
|
|
updatedAt: string;
|
|
}>;
|
|
|
|
export type ConsultationHistoryWindow = Readonly<{
|
|
tail: ConsultationHistoryMessage[];
|
|
summaryText: string | null;
|
|
droppedCount: number;
|
|
}>;
|
|
|
|
type StoredTurn = Readonly<{
|
|
index: number;
|
|
role: "user" | "assistant";
|
|
text: string;
|
|
requestId: string | null;
|
|
}>;
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function historyBudgetChars(contextWindow: number | null | undefined): number {
|
|
const window = typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
|
|
? contextWindow
|
|
: DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
return clamp(
|
|
(window - CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS) * CONSULTATION_HISTORY_CHAR_PER_TOKEN,
|
|
CONSULTATION_HISTORY_BUDGET_MIN_CHARS,
|
|
CONSULTATION_HISTORY_BUDGET_MAX_CHARS,
|
|
);
|
|
}
|
|
|
|
export function consultationHistoryCheckpointChars(contextWindow: number | null | undefined): number {
|
|
const budget = historyBudgetChars(contextWindow);
|
|
const threshold = Math.floor(budget * CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO);
|
|
if (!(threshold < budget)) {
|
|
throw new Error("consultation history checkpoint threshold must stay below the history budget");
|
|
}
|
|
return threshold;
|
|
}
|
|
|
|
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const row = value as Record<string, unknown>;
|
|
if (row.version !== 1) return null;
|
|
if (typeof row.text !== "string" || !row.text.trim()) return null;
|
|
if (typeof row.throughRequestId !== "string") return null;
|
|
if (!Number.isInteger(row.throughMessageIndex) || (row.throughMessageIndex as number) < 0) return null;
|
|
if (!Number.isInteger(row.messageCount) || (row.messageCount as number) < 0) return null;
|
|
if (typeof row.updatedAt !== "string" || !row.updatedAt) return null;
|
|
return {
|
|
version: 1,
|
|
text: row.text,
|
|
throughRequestId: row.throughRequestId,
|
|
throughMessageIndex: row.throughMessageIndex as number,
|
|
messageCount: row.messageCount as number,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
export function omissionMarker(omittedChars: number): string {
|
|
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
|
|
}
|
|
|
|
export function droppedRoundsMarker(droppedCount: number, hasSummary: boolean): string {
|
|
if (droppedCount <= 0) return "";
|
|
return hasSummary
|
|
? `……(更早的 ${droppedCount} 轮问答已并入上面的会话摘要)`
|
|
: `……(更早的 ${droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要)`;
|
|
}
|
|
|
|
export function clipConsultationHistoryText(text: string): string {
|
|
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
|
|
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
|
|
return `${text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS)}${omissionMarker(omitted)}`;
|
|
}
|
|
|
|
export function storedConsultationTurns(
|
|
messages: unknown,
|
|
options: { excludeRequestId?: string } = {},
|
|
): StoredTurn[] {
|
|
if (!Array.isArray(messages)) return [];
|
|
const rows: StoredTurn[] = [];
|
|
messages.forEach((message, index) => {
|
|
if (!message || typeof message !== "object") return;
|
|
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
|
|
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return;
|
|
if (stored.role !== "user" && stored.role !== "assistant") return;
|
|
if (typeof stored.text !== "string" || !stored.text) return;
|
|
rows.push({
|
|
index,
|
|
role: stored.role,
|
|
text: stored.text,
|
|
requestId: typeof stored.requestId === "string" ? stored.requestId : null,
|
|
});
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
export function lastConsultationPair<T extends { role: "user" | "assistant" }>(
|
|
rows: readonly T[],
|
|
): T[] {
|
|
if (rows.length <= 2) return [...rows];
|
|
for (let index = rows.length - 1; index >= 1; index -= 1) {
|
|
if (rows[index]?.role === "assistant" && rows[index - 1]?.role === "user") {
|
|
return rows.slice(index - 1, index + 1);
|
|
}
|
|
}
|
|
return rows.slice(-2);
|
|
}
|
|
|
|
function turnsAfterSummary(
|
|
turns: readonly StoredTurn[],
|
|
summary: SessionContextSummaryV1 | null,
|
|
): StoredTurn[] {
|
|
if (!summary) return [...turns];
|
|
return turns.filter((turn) => turn.index > summary.throughMessageIndex);
|
|
}
|
|
|
|
export function consultationHistoryWindow(
|
|
messages: unknown,
|
|
summary: SessionContextSummaryV1 | null,
|
|
options: {
|
|
contextWindow?: number | null;
|
|
excludeRequestId?: string;
|
|
overflow?: boolean;
|
|
} = {},
|
|
): ConsultationHistoryWindow {
|
|
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
|
|
const afterSummary = turnsAfterSummary(turns, summary);
|
|
const selected = options.overflow ? lastConsultationPair(afterSummary) : afterSummary;
|
|
const clipped = selected.map((turn) => ({
|
|
role: turn.role,
|
|
text: clipConsultationHistoryText(turn.text),
|
|
}));
|
|
const budget = historyBudgetChars(options.contextWindow);
|
|
let droppedCount = 0;
|
|
let kept = clipped;
|
|
while (kept.length > 0 && kept.reduce((sum, message) => sum + message.text.length, 0) > budget) {
|
|
kept = kept.slice(1);
|
|
droppedCount += 1;
|
|
}
|
|
const summaryText = summary?.text.trim() || null;
|
|
return { tail: kept, summaryText, droppedCount };
|
|
}
|
|
|
|
export function consultationHistoryFromStoredMessages(
|
|
messages: unknown,
|
|
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
|
|
): ConsultationHistoryMessage[] {
|
|
return consultationHistoryWindow(messages, null, {
|
|
contextWindow: options.contextWindow,
|
|
excludeRequestId: options.excludeRequestId,
|
|
}).tail;
|
|
}
|
|
|
|
export function consultationUserTurnContent(input: {
|
|
currentTime: string;
|
|
name?: string;
|
|
instruction: string;
|
|
extra?: string;
|
|
summaryText?: string | null;
|
|
droppedCount?: number;
|
|
question: string;
|
|
}): string {
|
|
const summary = input.summaryText?.trim() ?? "";
|
|
const dropped = droppedRoundsMarker(input.droppedCount ?? 0, Boolean(summary));
|
|
return [
|
|
input.currentTime,
|
|
input.name ? `用户称呼:${input.name}` : "",
|
|
input.instruction,
|
|
input.extra ?? "",
|
|
summary ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${summary}` : "",
|
|
dropped,
|
|
input.question,
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
const CONTEXT_OVERFLOW_MARKERS = [
|
|
"context_length_exceeded",
|
|
"maximum context length",
|
|
"prompt is too long",
|
|
"input is too long",
|
|
"too many tokens",
|
|
] as const;
|
|
|
|
function errorHaystack(error: unknown): { hay: string; status: number | null } {
|
|
if (typeof error === "string") return { hay: error.toLowerCase(), status: null };
|
|
if (!error || typeof error !== "object") return { hay: String(error).toLowerCase(), status: null };
|
|
const row = error as Record<string, unknown>;
|
|
const status = typeof row.status === "number"
|
|
? row.status
|
|
: typeof row.statusCode === "number"
|
|
? row.statusCode
|
|
: null;
|
|
const parts = [
|
|
typeof row.message === "string" ? row.message : "",
|
|
typeof row.code === "string" ? row.code : "",
|
|
typeof row.type === "string" ? row.type : "",
|
|
error instanceof Error ? error.message : "",
|
|
error instanceof Error ? error.name : "",
|
|
];
|
|
const nested = row.data && typeof row.data === "object" ? row.data as Record<string, unknown> : null;
|
|
if (nested) {
|
|
if (typeof nested.message === "string") parts.push(nested.message);
|
|
if (typeof nested.code === "string") parts.push(nested.code);
|
|
}
|
|
const cause = "cause" in row ? row.cause : null;
|
|
if (cause && typeof cause === "object") {
|
|
const nestedCause = cause as Record<string, unknown>;
|
|
if (typeof nestedCause.message === "string") parts.push(nestedCause.message);
|
|
if (typeof nestedCause.code === "string") parts.push(nestedCause.code);
|
|
}
|
|
return { hay: parts.join(" ").toLowerCase(), status };
|
|
}
|
|
|
|
export function isContextOverflowError(error: unknown): boolean {
|
|
const { hay, status } = errorHaystack(error);
|
|
if (CONTEXT_OVERFLOW_MARKERS.some((marker) => hay.includes(marker))) return true;
|
|
if (hay.includes("max_tokens") && (hay.includes("context") || hay.includes("prompt") || hay.includes("input") || status === 400)) {
|
|
return true;
|
|
}
|
|
if (status === 400 && (hay.includes("context") || hay.includes("prompt") || hay.includes("token"))) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|