mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
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>
1415 lines
40 KiB
TypeScript
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",
|
|
});
|
|
}
|