64 lines
3.1 KiB
TypeScript
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 },
|
|
};
|
|
}
|