mike/frontend/src/app/lib/mikeApi.ts
QA Runner 36cddb2566 fix: batch directory-modal project fetch instead of N+1 getProject burst
Every directory picker (AddDocumentsModal, UseWorkflowModal, the assistant
project selector) loads its "Projects" tab through useDirectoryData, which
fired one GET /projects/:id for EVERY existing project the moment the modal
opened. Each of those requests costs an auth verification against GoTrue
plus ~6 PostgREST queries, so an account with N projects produced an
~8xN-request burst on the Supabase gateway per modal open.

Under that burst the gateway genuinely falls over: measured locally against
the Supabase CLI stack, overlapping modal-open storms drove GoTrue into
Postgres connection exhaustion ("failed to connect ... context deadline
exceeded") and produced 467x 500 + 641x 504 on /auth/v1/user in a single
run — surfacing to users as failed project creates/loads, and to the e2e
suite as the intermittent Kong 502s its specs currently retry around.

Fix: GET /projects now accepts ?include=documents and returns each
project's documents from one batched query (same attach helpers as
GET /projects/:id, run once across all documents), and useDirectoryData
uses it. A modal open is now 1 API request and a fixed number of DB
queries regardless of project count. After the change the same storm
harness produced zero 5xx and zero auth failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:48 -07:00

1415 lines
40 KiB
TypeScript

/**
* Mike API client — all requests to the Node.js backend.
* Attaches the Supabase auth token for user authentication.
*/
import { supabase } from "@/app/lib/supabase";
import type {
AssistantEvent,
Chat,
ChatDetailOut,
Citation,
Document,
Folder,
LibraryFolder,
Message,
OpenSourceWorkflowContributorMode,
OpenSourceWorkflowResponse,
Project,
Workflow,
WorkflowContributor,
TabularReview,
TabularReviewDetailOut,
} from "@/app/components/shared/types";
// Server-side shape before mapping
interface ServerMessage {
id: string;
chat_id: string;
role: "user" | "assistant";
content: string | AssistantEvent[] | null;
files?: { filename: string; document_id?: string }[] | null;
workflow?: { id: string; title: string } | null;
citations?: Citation[] | null;
created_at: string;
}
interface ServerChatDetailOut {
chat: Chat;
messages: ServerMessage[];
}
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
export class MikeApiError extends Error {
status: number;
code: string | null;
constructor(args: { message: string; status: number; code?: string | null }) {
super(args.message);
this.name = "MikeApiError";
this.status = args.status;
this.code = args.code ?? null;
}
}
export function isMfaRequiredError(error: unknown) {
return (
error instanceof MikeApiError &&
error.status === 403 &&
error.code === "mfa_verification_required"
);
}
async function getAuthHeader(): Promise<Record<string, string>> {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.access_token) return {};
return { Authorization: `Bearer ${session.access_token}` };
}
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const authHeaders = await getAuthHeader();
const { headers: initHeaders, ...restInit } = init ?? {};
const response = await fetch(`${API_BASE}${path}`, {
cache: "no-store",
...restInit,
headers: {
Accept: "application/json",
...authHeaders,
...(initHeaders as Record<string, string> | undefined),
},
});
if (!response.ok) {
throw await toApiError(response, path);
}
if (
response.status === 204 ||
response.headers.get("content-length") === "0"
) {
return undefined as T;
}
return (await response.json()) as T;
}
async function apiBlobRequest(path: string): Promise<{
blob: Blob;
filename: string | null;
}> {
const authHeaders = await getAuthHeader();
const response = await fetch(`${API_BASE}${path}`, {
cache: "no-store",
headers: {
Accept: "application/json",
...authHeaders,
},
});
if (!response.ok) {
throw await toApiError(response, path);
}
const disposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? null,
};
}
async function toApiError(response: Response, path: string) {
const text = await response.text();
try {
const parsed = JSON.parse(text) as {
detail?: unknown;
code?: unknown;
};
devLog("[mike-api] non-ok response", {
path,
status: response.status,
code: parsed.code,
detail: parsed.detail,
});
return new MikeApiError({
status: response.status,
code: typeof parsed.code === "string" ? parsed.code : null,
message:
typeof parsed.detail === "string" && parsed.detail
? parsed.detail
: `API error: ${response.status}`,
});
} catch {
devLog("[mike-api] non-ok non-json response", {
path,
status: response.status,
bodyPreview: text.slice(0, 200),
});
return new MikeApiError({
status: response.status,
message: text || `API error: ${response.status}`,
});
}
}
// ---------------------------------------------------------------------------
// Projects
// ---------------------------------------------------------------------------
export async function listProjects(options?: {
includeDocuments?: boolean;
}): Promise<Project[]> {
const query = options?.includeDocuments ? "?include=documents" : "";
return apiRequest<Project[]>(`/projects${query}`);
}
export async function createProject(
name: string,
cm_number?: string,
practice?: string,
shared_with?: string[],
): Promise<Project> {
return apiRequest<Project>("/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, cm_number, practice, shared_with }),
});
}
export async function deleteAccount(): Promise<void> {
return apiRequest<void>("/user/account", { method: "DELETE" });
}
export async function deleteAllChats(): Promise<void> {
return apiRequest<void>("/user/chats", { method: "DELETE" });
}
export async function deleteAllProjects(): Promise<void> {
return apiRequest<void>("/user/projects", { method: "DELETE" });
}
export async function deleteAllTabularReviews(): Promise<void> {
return apiRequest<void>("/user/tabular-reviews", { method: "DELETE" });
}
export async function exportAccountData(): Promise<{
blob: Blob;
filename: string | null;
}> {
return apiBlobRequest("/user/export");
}
export async function exportChatData(): Promise<{
blob: Blob;
filename: string | null;
}> {
return apiBlobRequest("/user/chats/export");
}
export async function exportTabularReviewsData(): Promise<{
blob: Blob;
filename: string | null;
}> {
return apiBlobRequest("/user/tabular-reviews/export");
}
export interface UserProfile {
displayName: string | null;
organisation: string | null;
messageCreditsUsed: number;
creditsResetDate: string;
creditsRemaining: number;
tier: string;
titleModel: string;
tabularModel: string;
mfaOnLogin: boolean;
legalResearchUs: boolean;
apiKeyStatus: ApiKeyStatus;
}
export interface UserLookupResult {
exists: boolean;
email: string;
display_name: string | null;
}
export async function getUserProfile(): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/profile");
}
export async function lookupUserByEmail(
email: string,
): Promise<UserLookupResult> {
return apiRequest<UserLookupResult>(
`/user/lookup?email=${encodeURIComponent(email)}`,
);
}
export async function updateUserProfile(payload: {
displayName?: string | null;
organisation?: string | null;
titleModel?: string;
tabularModel?: string;
legalResearchUs?: boolean;
}): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function updateUserMfaOnLogin(
enabled: boolean,
): Promise<UserProfile> {
return apiRequest<UserProfile>("/user/security/mfa-login", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
}
export type ApiKeyProvider =
| "claude"
| "gemini"
| "openai"
| "openrouter"
| "courtlistener";
export type ApiKeySource = "user" | "env" | null;
export type ApiKeyState = Record<
ApiKeyProvider,
{
configured: boolean;
source: ApiKeySource;
}
>;
export type ApiKeyStatus = Record<ApiKeyProvider, boolean> & {
sources?: Partial<Record<ApiKeyProvider, ApiKeySource>>;
};
export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
return apiRequest<ApiKeyStatus>("/user/api-keys");
}
export async function saveApiKey(
provider: ApiKeyProvider,
apiKey: string | null,
): Promise<ApiKeyStatus> {
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
}
export interface McpToolSummary {
id: string;
toolName: string;
openaiToolName: string;
title: string | null;
description: string | null;
enabled: boolean;
readOnly: boolean;
destructive: boolean;
requiresConfirmation: boolean;
lastSeenAt: string;
}
export interface McpConnectorSummary {
id: string;
name: string;
transport: "streamable_http";
serverUrl: string;
authType: "none" | "bearer" | "oauth";
enabled: boolean;
hasAuthConfig: boolean;
customHeaderKeys: string[];
oauthConnected: boolean;
toolPolicy: Record<string, unknown>;
tools: McpToolSummary[];
toolCount: number;
createdAt: string;
updatedAt: string;
}
export async function listMcpConnectors(): Promise<McpConnectorSummary[]> {
return apiRequest<McpConnectorSummary[]>("/user/mcp-connectors");
}
export async function getMcpConnector(
connectorId: string,
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}`,
);
}
export async function createMcpConnector(payload: {
name: string;
serverUrl: string;
bearerToken?: string | null;
headers?: Record<string, string>;
}): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>("/user/mcp-connectors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function updateMcpConnector(
connectorId: string,
payload: {
name?: string;
serverUrl?: string;
enabled?: boolean;
bearerToken?: string | null;
headers?: Record<string, string>;
},
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
}
export async function deleteMcpConnector(connectorId: string): Promise<void> {
return apiRequest<void>(`/user/mcp-connectors/${connectorId}`, {
method: "DELETE",
});
}
export async function refreshMcpConnectorTools(
connectorId: string,
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}/refresh-tools`,
{ method: "POST" },
);
}
export async function startMcpConnectorOAuth(
connectorId: string,
): Promise<{ authorizationUrl: string | null; alreadyAuthorized: boolean }> {
return apiRequest<{ authorizationUrl: string | null; alreadyAuthorized: boolean }>(
`/user/mcp-connectors/${connectorId}/oauth/start`,
{ method: "POST" },
);
}
export async function setMcpToolEnabled(
connectorId: string,
toolId: string,
enabled: boolean,
): Promise<McpConnectorSummary> {
return apiRequest<McpConnectorSummary>(
`/user/mcp-connectors/${connectorId}/tools/${toolId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
);
}
export async function getProject(projectId: string): Promise<Project> {
return apiRequest<Project>(`/projects/${projectId}`);
}
export async function updateProject(
projectId: string,
payload: {
name?: string;
cm_number?: string;
practice?: string | null;
shared_with?: string[];
},
): Promise<Project> {
return apiRequest<Project>(`/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function deleteProject(projectId: string): Promise<void> {
await apiRequest(`/projects/${projectId}`, { method: "DELETE" });
}
export interface ProjectPeople {
owner: {
user_id: string;
email: string | null;
display_name: string | null;
};
members: { email: string; display_name: string | null }[];
}
export async function getProjectPeople(
projectId: string,
): Promise<ProjectPeople> {
return apiRequest<ProjectPeople>(`/projects/${projectId}/people`);
}
// ---------------------------------------------------------------------------
// Documents
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Folders
// ---------------------------------------------------------------------------
export async function createProjectFolder(
projectId: string,
name: string,
parentFolderId?: string | null,
): Promise<Folder> {
return apiRequest<Folder>(`/projects/${projectId}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
parent_folder_id: parentFolderId ?? null,
}),
});
}
export async function renameProjectFolder(
projectId: string,
folderId: string,
name: string,
): Promise<Folder> {
return apiRequest<Folder>(
`/projects/${projectId}/folders/${folderId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
},
);
}
export async function deleteProjectFolder(
projectId: string,
folderId: string,
): Promise<void> {
await apiRequest(`/projects/${projectId}/folders/${folderId}`, {
method: "DELETE",
});
}
export async function moveSubfolderToFolder(
projectId: string,
folderId: string,
parentFolderId: string | null,
): Promise<Folder> {
return apiRequest<Folder>(
`/projects/${projectId}/folders/${folderId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_folder_id: parentFolderId }),
},
);
}
export async function moveDocumentToFolder(
projectId: string,
documentId: string,
folderId: string | null,
): Promise<Document> {
return apiRequest<Document>(
`/projects/${projectId}/documents/${documentId}/folder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_id: folderId }),
},
);
}
export async function renameProjectDocument(
projectId: string,
documentId: string,
filename: string,
): Promise<Document> {
return apiRequest<Document>(
`/projects/${projectId}/documents/${documentId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
},
);
}
export type LibraryKind = "files" | "templates";
export interface LibraryCollection {
documents: Document[];
folders: LibraryFolder[];
}
export async function getLibrary(
kind: LibraryKind,
): Promise<LibraryCollection> {
return apiRequest<LibraryCollection>(`/library/${kind}`);
}
export async function uploadLibraryDocument(
kind: LibraryKind,
file: File,
): Promise<Document> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE}/library/${kind}/documents`, {
method: "POST",
headers: { ...authHeaders },
body: form,
});
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<Document>;
}
export async function createLibraryFolder(
kind: LibraryKind,
name: string,
parentFolderId?: string | null,
): Promise<LibraryFolder> {
return apiRequest<LibraryFolder>(`/library/${kind}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
parent_folder_id: parentFolderId ?? null,
}),
});
}
export async function renameLibraryFolder(
kind: LibraryKind,
folderId: string,
name: string,
): Promise<LibraryFolder> {
return apiRequest<LibraryFolder>(`/library/${kind}/folders/${folderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
}
export async function deleteLibraryFolder(
kind: LibraryKind,
folderId: string,
): Promise<void> {
await apiRequest(`/library/${kind}/folders/${folderId}`, {
method: "DELETE",
});
}
export async function moveLibraryFolder(
kind: LibraryKind,
folderId: string,
parentFolderId: string | null,
): Promise<LibraryFolder> {
return apiRequest<LibraryFolder>(`/library/${kind}/folders/${folderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_folder_id: parentFolderId }),
});
}
export async function moveLibraryDocument(
kind: LibraryKind,
documentId: string,
folderId: string | null,
): Promise<Document> {
return apiRequest<Document>(
`/library/${kind}/documents/${documentId}/folder`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_id: folderId }),
},
);
}
export async function renameLibraryDocument(
kind: LibraryKind,
documentId: string,
filename: string,
): Promise<Document> {
return apiRequest<Document>(`/library/${kind}/documents/${documentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
}
export async function addDocumentToProject(
projectId: string,
documentId: string,
): Promise<Document> {
return apiRequest<Document>(
`/projects/${projectId}/documents/${documentId}`,
{ method: "POST" },
);
}
export interface DocumentVersion {
id: string;
version_number: number | null;
source: string;
created_at: string;
filename: string | null;
file_type?: string | null;
size_bytes?: number | null;
page_count?: number | null;
deleted_at?: string | null;
deleted_by?: string | null;
}
export async function listDocumentVersions(documentId: string): Promise<{
current_version_id: string | null;
versions: DocumentVersion[];
}> {
return apiRequest(`/single-documents/${documentId}/versions`);
}
export async function uploadDocumentVersion(
documentId: string,
file: File,
filename?: string,
): Promise<DocumentVersion> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
if (filename) form.append("filename", filename);
const response = await fetch(
`${API_BASE}/single-documents/${documentId}/versions`,
{
method: "POST",
headers: { ...authHeaders },
body: form,
},
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<DocumentVersion>;
}
export async function replaceDocumentVersionFile(
documentId: string,
versionId: string,
file: File,
filename?: string,
): Promise<DocumentVersion> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
if (filename) form.append("filename", filename);
const response = await fetch(
`${API_BASE}/single-documents/${documentId}/versions/${versionId}/file`,
{
method: "PUT",
headers: { ...authHeaders },
body: form,
},
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<DocumentVersion>;
}
export async function copyDocumentVersionFromDocument(
documentId: string,
sourceDocumentId: string,
filename?: string,
): Promise<DocumentVersion> {
return apiRequest<DocumentVersion>(
`/single-documents/${documentId}/versions/from-document`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_document_id: sourceDocumentId,
filename,
}),
},
);
}
export async function renameDocumentVersion(
documentId: string,
versionId: string,
filename: string | null,
): Promise<DocumentVersion> {
return apiRequest<DocumentVersion>(
`/single-documents/${documentId}/versions/${versionId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
},
);
}
export async function deleteDocumentVersion(
documentId: string,
versionId: string,
): Promise<{
deleted_version_id: string;
current_version_id: string | null;
}> {
return apiRequest(`/single-documents/${documentId}/versions/${versionId}`, {
method: "DELETE",
});
}
export async function uploadProjectDocument(
projectId: string,
file: File,
): Promise<Document> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
const response = await fetch(
`${API_BASE}/projects/${projectId}/documents`,
{
method: "POST",
headers: { ...authHeaders },
body: form,
},
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<Document>;
}
export async function uploadStandaloneDocument(
file: File,
): Promise<Document> {
const authHeaders = await getAuthHeader();
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE}/single-documents`, {
method: "POST",
headers: { ...authHeaders },
body: form,
});
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<Document>;
}
export async function listStandaloneDocuments(): Promise<Document[]> {
return apiRequest<Document[]>("/single-documents");
}
export async function deleteDocument(documentId: string): Promise<void> {
await apiRequest(`/single-documents/${documentId}`, { method: "DELETE" });
}
export async function getDocumentUrl(
documentId: string,
versionId?: string | null,
): Promise<{ url: string; filename: string; version_id: string | null }> {
const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiRequest(`/single-documents/${documentId}/url${qs}`);
}
export async function downloadDocumentsZip(
documentIds: string[],
): Promise<Blob> {
const authHeaders = await getAuthHeader();
const response = await fetch(`${API_BASE}/single-documents/download-zip`, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
...authHeaders,
},
body: JSON.stringify({ document_ids: documentIds }),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `API error: ${response.status}`);
}
return response.blob();
}
// ---------------------------------------------------------------------------
// Chat
// ---------------------------------------------------------------------------
export async function createChat(payload?: {
project_id?: string;
}): Promise<{ id: string }> {
return apiRequest<{ id: string }>("/chat/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload ?? {}),
});
}
export async function listChats(options?: { limit?: number }): Promise<Chat[]> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
const query = params.toString();
return apiRequest<Chat[]>(`/chat${query ? `?${query}` : ""}`);
}
export async function listProjectChats(projectId: string): Promise<Chat[]> {
return apiRequest<Chat[]>(`/projects/${projectId}/chats`);
}
export async function getChat(chatId: string): Promise<ChatDetailOut> {
const raw = await apiRequest<ServerChatDetailOut>(`/chat/${chatId}`);
const messages: Message[] = raw.messages.map((m) => {
if (m.role === "user") {
return {
id: m.id,
role: "user",
content: typeof m.content === "string" ? m.content : "",
files: m.files ?? undefined,
workflow: m.workflow ?? undefined,
};
}
const events = Array.isArray(m.content)
? (m.content as AssistantEvent[])
: undefined;
return {
id: m.id,
role: "assistant",
content:
events
?.filter((e) => e.type === "content")
.map((e) => (e as { type: "content"; text: string }).text)
.join("") ?? "",
citations: m.citations ?? undefined,
events,
};
});
return { chat: raw.chat, messages };
}
export async function renameChat(chatId: string, title: string): Promise<void> {
await apiRequest(`/chat/${chatId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
}
export async function deleteChat(chatId: string): Promise<void> {
await apiRequest(`/chat/${chatId}`, { method: "DELETE" });
}
export async function generateChatTitle(
chatId: string,
message: string,
): Promise<{ title: string }> {
return apiRequest<{ title: string }>(`/chat/${chatId}/generate-title`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
}
export type CaseLawOpinion = {
opinionId: number | null;
apiUrl?: string | null;
type: string | null;
author: string | null;
url: string | null;
text?: string | null;
html?: string | null;
};
export async function getCourtlistenerOpinions(
clusterId: number,
): Promise<CaseLawOpinion[]> {
const result = await apiRequest<{ opinions: CaseLawOpinion[] }>(
"/case-law/case-opinions",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clusterId,
}),
},
);
return result.opinions;
}
export async function streamChat(payload: {
messages: {
role: string;
content: string;
files?: { filename: string; document_id?: string }[];
workflow?: { id: string; title: string };
}[];
chat_id?: string;
project_id?: string;
model?: string;
ask_inputs_response?: {
responses: (
| {
id: string;
kind: "choice";
question: string;
answer?: string;
skipped?: boolean;
}
| {
id: string;
kind: "documents";
filenames: string[];
skipped?: boolean;
}
)[];
};
signal?: AbortSignal;
}): Promise<Response> {
const { signal, ...body } = payload;
const authHeaders = await getAuthHeader();
return fetch(`${API_BASE}/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...authHeaders,
},
body: JSON.stringify(body),
signal,
});
}
type StreamChatMessage = {
role: string;
content: string;
files?: { filename: string; document_id?: string }[];
workflow?: { id: string; title: string };
};
export async function streamProjectChat(payload: {
projectId: string;
messages: StreamChatMessage[];
chat_id?: string;
model?: string;
displayed_doc?: { filename: string; document_id: string };
attached_documents?: { filename: string; document_id: string }[];
ask_inputs_response?: {
responses: (
| {
id: string;
kind: "choice";
question: string;
answer?: string;
skipped?: boolean;
}
| {
id: string;
kind: "documents";
filenames: string[];
skipped?: boolean;
}
)[];
};
signal?: AbortSignal;
}): Promise<Response> {
const { projectId, signal, ...body } = payload;
const authHeaders = await getAuthHeader();
return fetch(`${API_BASE}/projects/${projectId}/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...authHeaders,
},
body: JSON.stringify(body),
signal,
});
}
// ---------------------------------------------------------------------------
// Tabular Review
// ---------------------------------------------------------------------------
export async function listTabularReviews(
projectId?: string,
): Promise<TabularReview[]> {
const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
return apiRequest<TabularReview[]>(`/tabular-review${qs}`);
}
export async function createTabularReview(payload: {
title?: string;
document_ids: string[];
columns_config: { index: number; name: string; prompt: string }[];
workflow_id?: string;
project_id?: string;
}): Promise<TabularReview> {
return apiRequest<TabularReview>("/tabular-review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function getTabularReview(
reviewId: string,
): Promise<TabularReviewDetailOut> {
return apiRequest<TabularReviewDetailOut>(`/tabular-review/${reviewId}`);
}
export async function updateTabularReview(
reviewId: string,
payload: {
title?: string;
columns_config?: { index: number; name: string; prompt: string }[];
document_ids?: string[];
project_id?: string | null;
shared_with?: string[];
},
): Promise<TabularReview> {
return apiRequest<TabularReview>(`/tabular-review/${reviewId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function getTabularReviewPeople(
reviewId: string,
): Promise<ProjectPeople> {
return apiRequest<ProjectPeople>(`/tabular-review/${reviewId}/people`);
}
export async function generateTabularColumnPrompt(
title: string,
options?: { format?: string; documentName?: string; tags?: string[] },
): Promise<{ prompt: string; source: "preset" | "llm" | "fallback" }> {
return apiRequest<{
prompt: string;
source: "preset" | "llm" | "fallback";
}>("/tabular-review/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
format: options?.format,
documentName: options?.documentName,
tags: options?.tags,
}),
});
}
export async function uploadReviewDocument(
reviewId: string,
file: File,
options?: {
projectId?: string;
documentIds?: string[];
columnsConfig?: { index: number; name: string; prompt: string }[];
},
): Promise<Document> {
const uploaded = options?.projectId
? await uploadProjectDocument(options.projectId, file)
: await uploadStandaloneDocument(file);
await updateTabularReview(reviewId, {
columns_config: options?.columnsConfig,
document_ids: [...(options?.documentIds ?? []), uploaded.id],
});
return uploaded;
}
export async function deleteTabularReview(reviewId: string): Promise<void> {
await apiRequest(`/tabular-review/${reviewId}`, { method: "DELETE" });
}
export async function streamTabularGeneration(
reviewId: string,
): Promise<Response> {
const authHeaders = await getAuthHeader();
return fetch(`${API_BASE}/tabular-review/${reviewId}/generate`, {
method: "POST",
headers: { ...authHeaders },
});
}
export async function streamTabularChat(
reviewId: string,
messages: { role: string; content: string }[],
chat_id?: string | null,
signal?: AbortSignal,
context?: { reviewTitle?: string | null; projectName?: string | null },
): Promise<Response> {
const authHeaders = await getAuthHeader();
return fetch(`${API_BASE}/tabular-review/${reviewId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders },
body: JSON.stringify({
messages,
chat_id: chat_id ?? undefined,
review_title: context?.reviewTitle ?? undefined,
project_name: context?.projectName ?? undefined,
}),
signal: signal ?? undefined,
});
}
export interface TRCitationAnnotation {
type: "tabular_citation";
ref: number;
col_index: number;
row_index: number;
col_name: string;
doc_name: string;
quote: string;
}
interface RawTRMessage {
id: string;
chat_id: string;
role: "user" | "assistant";
content: string | AssistantEvent[] | null;
annotations?: TRCitationAnnotation[] | null;
created_at: string;
}
export interface TRDisplayMessage {
role: "user" | "assistant";
content: string;
events?: AssistantEvent[];
annotations?: TRCitationAnnotation[];
}
export interface TRChat {
id: string;
title: string | null;
created_at: string;
updated_at: string;
}
export function mapTRMessages(raw: RawTRMessage[]): TRDisplayMessage[] {
return raw.map((m) => {
if (m.role === "user") {
return {
role: "user" as const,
content: typeof m.content === "string" ? m.content : "",
};
}
const events = Array.isArray(m.content)
? (m.content as AssistantEvent[])
: undefined;
const content =
events
?.filter((e) => e.type === "content")
.map((e) => (e as { type: "content"; text: string }).text)
.join("") ?? "";
return {
role: "assistant" as const,
content,
events,
annotations: m.annotations ?? undefined,
};
});
}
export async function getTabularChats(reviewId: string): Promise<TRChat[]> {
return apiRequest<TRChat[]>(`/tabular-review/${reviewId}/chats`);
}
export async function getTabularChatMessages(
reviewId: string,
chatId: string,
): Promise<RawTRMessage[]> {
return apiRequest<RawTRMessage[]>(
`/tabular-review/${reviewId}/chats/${chatId}/messages`,
);
}
export async function deleteTabularChat(
reviewId: string,
chatId: string,
): Promise<void> {
await apiRequest(`/tabular-review/${reviewId}/chats/${chatId}`, {
method: "DELETE",
});
}
export async function renameTabularChat(
reviewId: string,
chatId: string,
title: string,
): Promise<void> {
await apiRequest(`/tabular-review/${reviewId}/chats/${chatId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
}
export async function regenerateTabularCell(
reviewId: string,
documentId: string,
columnIndex: number,
): Promise<{
summary: string;
flag: "green" | "grey" | "yellow" | "red";
reasoning: string;
}> {
return apiRequest(`/tabular-review/${reviewId}/regenerate-cell`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
document_id: documentId,
column_index: columnIndex,
}),
});
}
export async function clearTabularCells(
reviewId: string,
documentIds: string[],
): Promise<void> {
await apiRequest(`/tabular-review/${reviewId}/clear-cells`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document_ids: documentIds }),
});
}
// ---------------------------------------------------------------------------
// Workflows
// ---------------------------------------------------------------------------
type WorkflowType = Workflow["metadata"]["type"];
export async function listWorkflows(
type: WorkflowType,
): Promise<Workflow[]> {
return apiRequest<Workflow[]>(`/workflows?type=${type}`);
}
export async function getWorkflow(workflowId: string): Promise<Workflow> {
return apiRequest<Workflow>(`/workflows/${workflowId}`);
}
export async function createWorkflow(payload: {
metadata: {
title: string;
type: "assistant" | "tabular";
language?: string | null;
practice?: string | null;
jurisdictions?: string[] | null;
};
skill_md?: string;
columns_config?: { index: number; name: string; prompt: string }[];
}): Promise<Workflow> {
return apiRequest<Workflow>("/workflows", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function updateWorkflow(
workflowId: string,
payload: {
metadata?: {
title?: string;
language?: string | null;
practice?: string | null;
jurisdictions?: string[] | null;
};
skill_md?: string;
columns_config?: { index: number; name: string; prompt: string }[];
},
): Promise<Workflow> {
return apiRequest<Workflow>(`/workflows/${workflowId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function deleteWorkflow(workflowId: string): Promise<void> {
await apiRequest(`/workflows/${workflowId}`, { method: "DELETE" });
}
export async function openSourceWorkflow(
workflowId: string,
payload: {
contributor_mode: OpenSourceWorkflowContributorMode;
contributor?: WorkflowContributor | null;
},
): Promise<OpenSourceWorkflowResponse> {
return apiRequest<OpenSourceWorkflowResponse>(
`/workflows/${workflowId}/open-source`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
}
export async function listHiddenWorkflows(): Promise<string[]> {
return apiRequest<string[]>("/workflows/hidden");
}
export async function hideWorkflow(workflowId: string): Promise<void> {
await apiRequest("/workflows/hidden", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workflow_id: workflowId }),
});
}
export async function unhideWorkflow(workflowId: string): Promise<void> {
await apiRequest(`/workflows/hidden/${workflowId}`, { method: "DELETE" });
}
export async function shareWorkflow(
workflowId: string,
payload: { emails: string[]; allow_edit: boolean },
): Promise<void> {
await apiRequest<void>(`/workflows/${workflowId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function listWorkflowShares(workflowId: string): Promise<
{
id: string;
shared_with_email: string;
allow_edit: boolean;
created_at: string;
}[]
> {
return apiRequest(`/workflows/${workflowId}/shares`);
}
export async function deleteWorkflowShare(
workflowId: string,
shareId: string,
): Promise<void> {
await apiRequest(`/workflows/${workflowId}/shares/${shareId}`, {
method: "DELETE",
});
}