learning_ai_notes/web/src/lib/notes-client.ts

508 lines
15 KiB
TypeScript

"use client";
import { createNotesApiClient } from "@/lib/api-helpers";
import { withMutationRetry } from "@/lib/mutation-retry";
import type {
AgentTimelineItem,
ArtifactSummary,
LinkedNote,
NoteArtifactDoc,
NoteArtifactListResponse,
NoteAgentActionDoc,
NoteAgentActionListResponse,
NoteDetail,
NoteDoc,
NoteListResponse,
NoteRelationshipDoc,
NoteRelationshipListResponse,
NoteSummary,
NoteTask,
NoteTaskDoc,
NoteTaskListResponse,
WorkspaceDoc,
WorkspaceListResponse,
WorkspaceSummary,
} from "@/lib/types";
function buildWorkspaceMap(workspaces: WorkspaceDoc[]) {
return new Map(workspaces.map((workspace) => [workspace.id, workspace]));
}
function buildNoteMap(notes: NoteDoc[]) {
return new Map(notes.map((note) => [note.id, note]));
}
function toNoteSummary(note: NoteDoc): NoteSummary {
return {
id: note.id,
workspaceId: note.workspaceId,
title: note.title,
excerpt: note.body.slice(0, 160),
status: note.status,
tags: note.tags,
updatedAt: note.updatedAt,
updatedBy: note.updatedBy,
};
}
function toWorkspaceSummary(workspace: WorkspaceDoc, notes: NoteDoc[]): WorkspaceSummary {
const owner = workspace.members.find((member) => member.role === "owner")?.userId ?? workspace.updatedBy;
const noteCount = notes.filter((note) => note.workspaceId === workspace.id).length;
return {
id: workspace.id,
name: workspace.name,
description: workspace.description ?? "",
owner,
noteCount,
visibility: workspace.members.length > 1 ? "shared" : "private",
updatedAt: workspace.updatedAt,
tags: [],
};
}
function toNoteTask(task: NoteTaskDoc): NoteTask {
return {
id: task.id,
title: task.title,
status:
task.status === "open"
? "todo"
: task.status === "completed"
? "done"
: "in_progress",
source: task.source === "extracted" ? "agent" : "manual",
};
}
function toArtifactSummary(artifact: NoteArtifactDoc): ArtifactSummary {
return {
id: artifact.id,
name: artifact.title,
type: artifact.artifactType,
status: artifact.description ? "ready" : "processing",
blobPath: artifact.blobPath,
contentType: artifact.contentType,
sizeBytes: artifact.sizeBytes,
};
}
function toTimelineItem(action: NoteAgentActionDoc): AgentTimelineItem {
return {
id: action.id,
actor: action.actorId,
action: `${action.actorType} ${action.actionType.replaceAll("_", " ")}`,
timestamp: action.updatedAt,
status: action.state,
summary: action.afterSummary ?? action.reason ?? action.actionType,
};
}
function toReviewState(
status: AgentTimelineItem["status"] | undefined
): NoteDetail["metadata"]["reviewState"] {
if (status === "proposed" || status === "approved" || status === "rejected") {
return status;
}
return "none";
}
function toLinkedNotes(
noteId: string,
relationships: NoteRelationshipDoc[],
noteMap: Map<string, NoteDoc>,
): LinkedNote[] {
return relationships.flatMap((relationship) => {
const relatedNoteId =
relationship.fromNoteId === noteId
? relationship.toNoteId
: relationship.toNoteId === noteId
? relationship.fromNoteId
: null;
if (!relatedNoteId) {
return [];
}
const relatedNote = noteMap.get(relatedNoteId);
if (!relatedNote) {
return [];
}
return [
{
id: relatedNote.id,
title: relatedNote.title,
relationship: relationship.relationshipType,
},
];
});
}
type WorkspaceSummaryDoc = WorkspaceDoc & { noteCount: number };
type WorkspaceSummaryListResponse = { items: WorkspaceSummaryDoc[]; total: number };
export async function listWorkspaceSummaries(): Promise<WorkspaceSummary[]> {
const api = createNotesApiClient();
const response = await api.fetch<WorkspaceSummaryListResponse>("/workspaces/summaries");
return response.items.map((ws) => {
const owner = ws.members.find((m) => m.role === "owner")?.userId ?? ws.updatedBy;
return {
id: ws.id,
name: ws.name,
description: ws.description ?? "",
owner,
noteCount: ws.noteCount,
visibility: ws.members.length > 1 ? "shared" : "private",
updatedAt: ws.updatedAt,
tags: [],
};
});
}
export async function listNoteSummaries(): Promise<NoteSummary[]> {
const api = createNotesApiClient();
const response = await api.fetch<NoteListResponse>("/notes");
return response.items.map(toNoteSummary);
}
export async function searchNoteSummaries(query: string): Promise<NoteSummary[]> {
const api = createNotesApiClient();
const search = query.trim();
const path = search ? `/notes?search=${encodeURIComponent(search)}` : "/notes";
const response = await api.fetch<NoteListResponse>(path);
return response.items.map(toNoteSummary);
}
export interface SearchRankedHit {
noteId: string;
workspaceId: string;
title: string;
score: number;
matchKind: string;
snippet: string;
}
export async function searchNotesRanked(
q: string,
mode: "lexical" | "hybrid",
options?: { limit?: number; offset?: number; workspaceId?: string },
): Promise<{ items: SearchRankedHit[]; mode: string; total: number }> {
const api = createNotesApiClient();
return api.fetch("/notes/search", {
method: "POST",
body: JSON.stringify({
q: q.trim(),
mode,
workspaceId: options?.workspaceId,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
}),
});
}
export async function createNoteShare(noteId: string, workspaceId: string): Promise<{ shareToken: string; path: string }> {
const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/share`, {
method: "POST",
body: JSON.stringify({ workspaceId }),
});
}
export interface NoteVersionRow {
id: string;
noteId: string;
title: string;
body: string;
savedAt: string;
source: string;
}
export async function listNoteVersions(
noteId: string,
workspaceId: string,
): Promise<{ items: NoteVersionRow[]; total: number }> {
const api = createNotesApiClient();
const qs = new URLSearchParams({ workspaceId, limit: "30", offset: "0" });
return api.fetch(`/notes/${encodeURIComponent(noteId)}/versions?${qs.toString()}`);
}
export async function seedOnboardingWorkspace(): Promise<{ workspaceId: string; noteIds: string[] }> {
const api = createNotesApiClient();
return api.fetch("/workspaces/onboarding-seed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
}
export async function chatOverWorkspace(workspaceId: string, message: string): Promise<{
answer: string;
citations: Array<{ noteId: string; title: string; snippet: string; workspaceId: string }>;
}> {
const api = createNotesApiClient();
return api.fetch("/notes/chat", {
method: "POST",
body: JSON.stringify({ workspaceId, message }),
});
}
export async function listNotesForWorkspace(workspaceId: string): Promise<NoteSummary[]> {
const api = createNotesApiClient();
const response = await api.fetch<NoteListResponse>(`/notes?workspaceId=${encodeURIComponent(workspaceId)}`);
return response.items.map(toNoteSummary);
}
export async function updateNoteDetail(
noteId: string,
workspaceId: string,
updates: {
title?: string;
body?: string;
},
): Promise<void> {
const api = createNotesApiClient();
const path = `/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(workspaceId)}`;
await withMutationRetry({
run: () => api.fetch(path, {
method: "PATCH",
body: JSON.stringify(updates),
}),
queue: { id: noteId, action: "patch", path, payload: updates },
});
}
export async function createNoteArtifact(input: {
id: string;
workspaceId: string;
noteId: string;
artifactType: "file" | "summary" | "extraction" | "citation" | "export";
title: string;
description?: string;
blobPath?: string;
contentType?: string;
sizeBytes?: number;
}): Promise<void> {
const api = createNotesApiClient();
await withMutationRetry({
run: () => api.fetch("/note-artifacts", {
method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/note-artifacts", payload: input },
});
}
export async function createNoteTask(input: {
id: string;
workspaceId: string;
noteId: string;
title: string;
description?: string;
dueAt?: string;
source?: "manual" | "extracted";
}): Promise<void> {
const api = createNotesApiClient();
await withMutationRetry({
run: () => api.fetch("/note-tasks", {
method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/note-tasks", payload: input },
});
}
export async function getNoteDetail(noteId: string, knownWorkspaceId?: string): Promise<NoteDetail | null> {
const api = createNotesApiClient();
let note: NoteDoc | undefined;
let workspaceId: string;
if (knownWorkspaceId) {
try {
note = await api.fetch<NoteDoc>(
`/notes/${encodeURIComponent(noteId)}?workspaceId=${encodeURIComponent(knownWorkspaceId)}`
);
workspaceId = knownWorkspaceId;
} catch {
return null;
}
} else {
try {
const noteResponse = await api.fetch<NoteListResponse>(`/notes?search=${encodeURIComponent(noteId)}&limit=1`);
note = noteResponse.items.find((item) => item.id === noteId);
if (!note) return null;
workspaceId = note.workspaceId;
} catch {
return null;
}
}
const wsId = encodeURIComponent(workspaceId);
const nId = encodeURIComponent(noteId);
const [workspaceResponse, notesForLinked, taskResponse, artifactResponse, actionResponse] = await Promise.all([
api.fetch<WorkspaceListResponse>("/workspaces"),
api.fetch<NoteListResponse>(`/notes?workspaceId=${wsId}`),
api.fetch<NoteTaskListResponse>(`/note-tasks?workspaceId=${wsId}&noteId=${nId}`),
api.fetch<NoteArtifactListResponse>(`/note-artifacts?workspaceId=${wsId}&noteId=${nId}`),
api.fetch<NoteAgentActionListResponse>(`/note-agent-actions?workspaceId=${wsId}&noteId=${nId}`),
]);
const workspaceMap = buildWorkspaceMap(workspaceResponse.items);
const noteMap = buildNoteMap(notesForLinked.items);
const workspace = workspaceMap.get(workspaceId);
let relationshipResponse: NoteRelationshipListResponse = { items: [] };
try {
const fetched = await api.fetch<NoteRelationshipListResponse>(
`/note-relationships?workspaceId=${wsId}&noteId=${nId}`
);
relationshipResponse = fetched && Array.isArray(fetched.items) ? fetched : { items: [] };
} catch {
relationshipResponse = { items: [] };
}
const tasks = taskResponse.items.map(toNoteTask);
const artifacts = artifactResponse.items.map(toArtifactSummary);
const timeline = actionResponse.items
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(toTimelineItem);
const linkedNotes = toLinkedNotes(note.id, relationshipResponse.items, noteMap);
return {
...toNoteSummary(note),
body: note.body,
metadata: {
owner: workspace?.members.find((member) => member.role === "owner")?.userId ?? note.updatedBy,
source: note.sourceType ?? "manual",
reviewState: toReviewState(timeline[0]?.status),
taskCount: tasks.length,
artifactCount: artifacts.length,
},
linkedNotes,
tasks,
artifacts,
timeline,
};
}
export async function createNote(input: {
id: string;
workspaceId: string;
title: string;
body: string;
tags?: string[];
sourceType?: string;
}): Promise<NoteDoc> {
const api = createNotesApiClient();
return withMutationRetry({
run: () => api.fetch<NoteDoc>("/notes", {
method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/notes", payload: input },
});
}
export async function archiveNote(noteId: string, workspaceId: string): Promise<void> {
const api = createNotesApiClient();
const path = `/notes/${encodeURIComponent(noteId)}/archive`;
await withMutationRetry({
run: () => api.fetch(path, {
method: "POST",
body: JSON.stringify({ workspaceId }),
}),
queue: { id: `${noteId}:archive`, action: "post", path, payload: { workspaceId } },
});
}
export async function restoreNote(noteId: string, workspaceId: string): Promise<void> {
const api = createNotesApiClient();
const path = `/notes/${encodeURIComponent(noteId)}/restore`;
await withMutationRetry({
run: () => api.fetch(path, {
method: "POST",
body: JSON.stringify({ workspaceId }),
}),
queue: { id: `${noteId}:restore`, action: "post", path, payload: { workspaceId } },
});
}
export async function summarizeNote(noteId: string, workspaceId: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/summarize`, {
method: "POST",
body: JSON.stringify({ workspaceId }),
});
}
export async function exportNotes(format: "json" | "markdown", workspaceId?: string): Promise<string> {
const api = createNotesApiClient();
const params = new URLSearchParams({ format });
if (workspaceId) params.set("workspaceId", workspaceId);
const res = await api.fetch<string>(`/notes/export?${params.toString()}`);
return typeof res === "string" ? res : JSON.stringify(res, null, 2);
}
export async function createWorkspace(input: {
id: string;
name: string;
description?: string;
}): Promise<WorkspaceDoc> {
const api = createNotesApiClient();
return withMutationRetry({
run: () => api.fetch<WorkspaceDoc>("/workspaces", {
method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/workspaces", payload: input },
});
}
export async function updateWorkspace(
workspaceId: string,
updates: { name?: string; description?: string },
): Promise<void> {
const api = createNotesApiClient();
const path = `/workspaces/${encodeURIComponent(workspaceId)}`;
await withMutationRetry({
run: () => api.fetch(path, {
method: "PATCH",
body: JSON.stringify(updates),
}),
queue: { id: workspaceId, action: "patch", path, payload: updates },
});
}
export async function deleteWorkspace(workspaceId: string): Promise<void> {
const api = createNotesApiClient();
const path = `/workspaces/${encodeURIComponent(workspaceId)}`;
await withMutationRetry({
run: () => api.fetch(path, {
method: "DELETE",
}),
queue: { id: workspaceId, action: "delete", path, payload: {} },
});
}
export async function createNoteRelationship(input: {
id: string;
workspaceId: string;
fromNoteId: string;
toNoteId: string;
relationshipType: string;
}): Promise<void> {
const api = createNotesApiClient();
await withMutationRetry({
run: () => api.fetch("/note-relationships", {
method: "POST",
body: JSON.stringify(input),
}),
queue: { id: input.id, action: "post", path: "/note-relationships", payload: input },
});
}