Files
Jyotisha/frontend/src/lib/consultation-session-history.ts
T
jesse-ux 149e1ec4c3
Independent Staging Quality Gate / validate (push) Canceled after 3m8s
Independent Staging Quality Gate / publish (push) Canceled after 0s
fix(consult): drop traces, budget checkpoints, silent summary inherit
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.
2026-09-16 07:38:36 +08:00

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;
}