Files
next-tjwater-agent/src/sessions/history-page.ts
T
jiang 7d47a2df86
Generic Container CI/CD / test-build-publish (push) Failing after 8m25s
Agent CI/CD / build-test-publish-and-deploy (push) Failing after 8m25s
feat: add scoped history and artifacts
2026-10-22 18:40:20 +08:00

64 lines
3.1 KiB
TypeScript

import { createHash } from "node:crypto";
export class HistoryCursorError extends Error {}
type Cursor = { scope: string; kind: string; position?: number; anchor?: string; updatedAt?: string; id?: string };
export function historyScope(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function encode(value: Cursor) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function decode(value: string, scope: string, kind: string): Cursor {
try {
const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Cursor;
if (cursor.scope !== scope || cursor.kind !== kind) throw new Error();
return cursor;
} catch {
throw new HistoryCursorError("invalid history cursor");
}
}
export function sessionHistoryPage<T extends { sessionId: string; updatedAt: string; title?: string; searchSnippet?: string }>(
records: T[], options: { scope: string; q?: string; limit: number; cursor?: string },
) {
const query = (options.q ?? "").trim().toLocaleLowerCase();
const scope = historyScope([options.scope, query]);
const boundary = options.cursor ? decode(options.cursor, scope, "sessions") : null;
if (boundary && (typeof boundary.updatedAt !== "string" || typeof boundary.id !== "string")) {
throw new HistoryCursorError("invalid history cursor");
}
const ordered = records.filter((record) => (record.title ?? "新对话").toLocaleLowerCase().includes(query) || Boolean(query && record.searchSnippet))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.sessionId.localeCompare(a.sessionId));
const remaining = boundary ? ordered.filter((record) => record.updatedAt < boundary.updatedAt!
|| (record.updatedAt === boundary.updatedAt && record.sessionId.localeCompare(boundary.id!) < 0)) : ordered;
const sessions = remaining.slice(0, options.limit);
const has_more = remaining.length > sessions.length;
const last = sessions.at(-1);
return { sessions, has_more, next_cursor: has_more && last
? encode({ scope, kind: "sessions", updatedAt: last.updatedAt, id: last.sessionId }) : null };
}
function messageAnchor(message: unknown) {
if (message && typeof message === "object" && "id" in message && typeof message.id === "string") return message.id;
return historyScope(message);
}
export function sessionMessagePage(messages: unknown[], options: { scope: string; limit: number; before?: string }) {
const boundary = options.before ? decode(options.before, options.scope, "messages") : null;
const end = boundary?.position ?? messages.length;
if (!Number.isInteger(end) || end < 0 || end > messages.length || (boundary &&
(end >= messages.length || messageAnchor(messages[end]) !== boundary.anchor))) {
throw new HistoryCursorError("message history changed; reload this conversation");
}
const start = Math.max(0, end - options.limit);
return {
messages: messages.slice(start, end),
message_page: { has_more: start > 0, before_cursor: start > 0
? encode({ scope: options.scope, kind: "messages", position: start, anchor: messageAnchor(messages[start]) }) : null },
};
}