feat: add scoped history and artifacts
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Search only visible user/assistant text, never tool arguments or hidden reasoning. */
|
||||
export function matchHistoryText(messages: unknown[], query: string): string | undefined {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
if (!needle) return;
|
||||
for (const value of messages) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const message = value as Record<string, unknown>;
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [message];
|
||||
for (const value of parts) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const part = value as Record<string, unknown>;
|
||||
if (part.type !== "text" || typeof part.text !== "string") continue;
|
||||
const index = part.text.toLocaleLowerCase().indexOf(needle);
|
||||
if (index < 0) continue;
|
||||
const start = Math.max(0, index - 36);
|
||||
const end = Math.min(part.text.length, index + needle.length + 80);
|
||||
return `${start ? "…" : ""}${part.text.slice(start, end).replace(/\s+/g, " ")}${end < part.text.length ? "…" : ""}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SessionTopicStore } from "./topic-store.js";
|
||||
import { config } from "../config.js";
|
||||
import { type SessionWorkspaceStore } from "./workspace-store.js";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ export type SessionRecord = {
|
||||
requestId?: string;
|
||||
modelId?: string;
|
||||
title?: string;
|
||||
topicId?: string | null;
|
||||
triggerId?: string;
|
||||
triggerSource?: string;
|
||||
triggerSubjectType?: string;
|
||||
@@ -63,14 +65,17 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
|
||||
runId?: string;
|
||||
requestId?: string;
|
||||
modelId?: string;
|
||||
topicId?: string;
|
||||
};
|
||||
|
||||
export class SessionMetadataStore {
|
||||
readonly topics: SessionTopicStore;
|
||||
private readonly ensureQueues = new Map<string, Promise<void>>();
|
||||
private index?: Map<string, Map<string, SessionRecord>>;
|
||||
private indexLoading?: Promise<void>;
|
||||
private indexVersion = 0;
|
||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
|
||||
this.topics = new SessionTopicStore(join(baseDir, "topics"));
|
||||
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
|
||||
}
|
||||
|
||||
@@ -133,6 +138,7 @@ export class SessionMetadataStore {
|
||||
runId: input.runId?.trim(),
|
||||
requestId: input.requestId?.trim(),
|
||||
modelId: input.modelId?.trim(),
|
||||
topicId: input.topicId,
|
||||
...(input.trigger ? {
|
||||
triggerId: input.trigger.triggerId,
|
||||
triggerSource: input.trigger.source,
|
||||
@@ -175,7 +181,7 @@ export class SessionMetadataStore {
|
||||
|
||||
async touch(
|
||||
record: SessionRecord,
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {},
|
||||
) {
|
||||
return this.serialize(record.sessionId, async () => {
|
||||
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
|
||||
@@ -314,12 +320,13 @@ const matchesContext = (record: SessionRecord, context: SessionMetadataContext)
|
||||
(context.projectId === undefined || record.projectId === context.projectId);
|
||||
|
||||
const normalizeSessionUpdates = (
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status">>,
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">>,
|
||||
) => {
|
||||
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
|
||||
const normalized: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {};
|
||||
if (updates.status === "active" || updates.status === "archived") {
|
||||
normalized.status = updates.status;
|
||||
}
|
||||
if (updates.topicId === null || typeof updates.topicId === "string") normalized.topicId = updates.topicId;
|
||||
if (typeof updates.title === "string") {
|
||||
const trimmed = updates.title.trim();
|
||||
if (trimmed) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
||||
|
||||
export type TopicScope = { actorKey: string; projectKey: string; userId?: string };
|
||||
export type SessionTopic = { id: string; name: string; createdAt: string; updatedAt: string };
|
||||
|
||||
/** Topics organize one user's chats within an authorized data scope. */
|
||||
export class SessionTopicStore {
|
||||
private queues = new Map<string, Promise<void>>();
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
async list(scope: TopicScope): Promise<SessionTopic[]> {
|
||||
return (await readJsonFile<{ topics: SessionTopic[] }>(this.path(scope)))?.topics ?? [];
|
||||
}
|
||||
|
||||
async get(scope: TopicScope, id: string) {
|
||||
return (await this.list(scope)).find((topic) => topic.id === id);
|
||||
}
|
||||
|
||||
create(scope: TopicScope, name: string) {
|
||||
return this.update(scope, (topics) => {
|
||||
const now = new Date().toISOString();
|
||||
const topic = { id: randomUUID(), name, createdAt: now, updatedAt: now };
|
||||
topics.push(topic);
|
||||
return topic;
|
||||
});
|
||||
}
|
||||
|
||||
rename(scope: TopicScope, id: string, name: string) {
|
||||
return this.update(scope, (topics) => {
|
||||
const topic = topics.find((entry) => entry.id === id);
|
||||
if (topic) { topic.name = name; topic.updatedAt = new Date().toISOString(); }
|
||||
return topic;
|
||||
});
|
||||
}
|
||||
|
||||
remove(scope: TopicScope, id: string) {
|
||||
return this.update(scope, (topics) => {
|
||||
const index = topics.findIndex((topic) => topic.id === id);
|
||||
if (index < 0) return false;
|
||||
topics.splice(index, 1);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async update<T>(scope: TopicScope, change: (topics: SessionTopic[]) => T): Promise<T> {
|
||||
const path = this.path(scope);
|
||||
const previous = this.queues.get(path) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(async () => {
|
||||
const topics = await this.list(scope);
|
||||
const value = change(topics);
|
||||
await atomicWriteJson(path, { topics }, 0o600);
|
||||
return value;
|
||||
});
|
||||
const pending = result.then(() => undefined, () => undefined);
|
||||
this.queues.set(path, pending);
|
||||
try { return await result; }
|
||||
finally { if (this.queues.get(path) === pending) this.queues.delete(path); }
|
||||
}
|
||||
|
||||
private path(scope: TopicScope) {
|
||||
const hash = createHash("sha256").update(JSON.stringify([scope.actorKey, scope.projectKey, scope.userId ?? null])).digest("hex");
|
||||
return join(this.directory, `${hash}.json`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user