1788 lines
63 KiB
TypeScript
1788 lines
63 KiB
TypeScript
import {
|
|
isAiCorsFailure,
|
|
parseAiDirectTarget,
|
|
parsePreparedAiGeneration,
|
|
probeAiProviderCors,
|
|
streamDirectAiGeneration,
|
|
} from "./ai-direct-stream";
|
|
|
|
import { createPluginCapabilities } from './plugin-capabilities';
|
|
import { aiDirectTargetKey } from "@edgeever/shared";
|
|
import type {
|
|
CompanionMemory,
|
|
CompanionDiscoverySettings,
|
|
CompanionDiscoverySettingsInput,
|
|
CompanionDiscoveryItem,
|
|
CompanionAction,
|
|
CompanionTurn,
|
|
CompanionTurnInput,
|
|
CompanionTurnResume,
|
|
CompanionEvent,
|
|
CompanionToolExecuteResult,
|
|
ApiToken,
|
|
AuthSession,
|
|
LoginInput,
|
|
LoginDeviceSession,
|
|
InstanceUser,
|
|
CreatedApiToken,
|
|
JsonBackupMemo,
|
|
JsonBackupNotebook,
|
|
JsonBackupAiPrompt,
|
|
JsonBackupRevision,
|
|
MemoDetail,
|
|
MemoEditSession,
|
|
MemoRevision,
|
|
MemoSummary,
|
|
MemoShare,
|
|
PublicTableForm,
|
|
TableFormSettings,
|
|
TableFormUpdateInput,
|
|
MemoTemplate,
|
|
ScheduledTask,
|
|
ScheduledTaskRun,
|
|
WorkspaceExtension,
|
|
WorkspaceExtensionUpsertInput,
|
|
Notebook,
|
|
Resource,
|
|
ResourceListItem,
|
|
ResourceStorageSummary,
|
|
ObjectStorageSettings,
|
|
PublicMemoShare,
|
|
TagSummary,
|
|
TiptapDoc,
|
|
AiSettings,
|
|
AiDiscoveredModel,
|
|
AiProvider,
|
|
AiPromptTemplate,
|
|
AiPromptTemplateCreateInput,
|
|
AiPromptTemplateUpdateInput,
|
|
AiStreamEvent,
|
|
AiGenerateInput,
|
|
InfographicAgentEvent,
|
|
InfographicAgentRequest,
|
|
AiTagSuggestionsRequestInput,
|
|
AiTagSuggestionsResponse,
|
|
SyncBootstrapResponse,
|
|
SyncChange,
|
|
SyncChangesResponse,
|
|
DeploymentMetadata,
|
|
PluginAiGenerateRequest,
|
|
PluginPublicFetchRequest,
|
|
PluginPublicFetchResponse,
|
|
} from "@edgeever/shared";
|
|
|
|
const MAX_SINGLE_REQUEST_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
|
|
async function consumeEventStream<T>(body: ReadableStream<Uint8Array>, onEvent: (event: T) => void) {
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
const emit = (frame: string) => {
|
|
const data = frame.split("\n").find(line => line.startsWith("data: "))?.slice(6);
|
|
if (data) onEvent(JSON.parse(data) as T);
|
|
};
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
buffer += decoder.decode(value, { stream: !done });
|
|
const frames = buffer.split("\n\n");
|
|
buffer = frames.pop() ?? "";
|
|
for (const frame of frames) emit(frame);
|
|
if (done) break;
|
|
}
|
|
if (buffer) emit(buffer);
|
|
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
}
|
|
|
|
export type EdgeEverClientRequestContext = {
|
|
path: string;
|
|
token?: string;
|
|
};
|
|
|
|
export type EdgeEverClientOptions = {
|
|
baseUrl?: string | (() => string);
|
|
token?: string | null | (() => string | null | undefined);
|
|
fetch?: typeof fetch;
|
|
/** Native/desktop note AI and Agent calls the model API directly after a short prepare hop. */
|
|
directAiGeneration?: boolean;
|
|
/**
|
|
* Browser-only: probe whether the model host allows CORS, then direct-connect.
|
|
* Falls back to the instance proxy without using a prepared API key when CORS is blocked.
|
|
*/
|
|
tryDirectAiGeneration?: boolean;
|
|
/** Used only for the model provider request; defaults to `fetch`. */
|
|
providerFetch?: typeof fetch;
|
|
beforeRequest?: (context: EdgeEverClientRequestContext) => void | Promise<void>;
|
|
shouldAttachToken?: (path: string) => boolean;
|
|
onUnauthorized?: (context: EdgeEverClientRequestContext) => void | Promise<void>;
|
|
};
|
|
|
|
export type MultipartResourceUploadSource = {
|
|
filename: string;
|
|
mimeType: string;
|
|
byteSize: number;
|
|
readPart: (start: number, end: number) => Promise<Blob>;
|
|
};
|
|
|
|
export type InstanceHealth = {
|
|
ok: true;
|
|
name: string;
|
|
runtime?: string | null;
|
|
containerImageSource?: "official-ghcr" | "official-cn-mirror" | "custom" | "unknown" | string | null;
|
|
authMode?: string | null;
|
|
build?: string | null;
|
|
deployment?: DeploymentMetadata | null;
|
|
migration?: string | null;
|
|
storage?: {
|
|
database?: "d1" | "sqlite" | string | null;
|
|
resources?: "r2" | "filesystem" | "s3" | string | null;
|
|
} | null;
|
|
objectStorageProvider?: "builtin" | "s3" | "unknown" | string | null;
|
|
};
|
|
|
|
export type InstanceRelease = {
|
|
version: string;
|
|
changes: Record<string, readonly string[]>;
|
|
};
|
|
|
|
export type MemoFilterMode = "all" | "tagged" | "untagged" | "pinned";
|
|
export type MemoSortMode = "updated-desc" | "created-desc" | "title-asc";
|
|
|
|
export type ListNotebooksResponse = {
|
|
notebooks: Notebook[];
|
|
};
|
|
|
|
export type ListMemosResponse = {
|
|
memos: MemoSummary[];
|
|
totalCount: number;
|
|
nextCursor: string | null;
|
|
};
|
|
|
|
export type ListMemoRevisionsResponse = {
|
|
revisions: MemoRevision[];
|
|
};
|
|
|
|
export type ListResourcesResponse = {
|
|
resources: ResourceListItem[];
|
|
summary: ResourceStorageSummary;
|
|
};
|
|
|
|
export type ListTagsResponse = {
|
|
tags: TagSummary[];
|
|
};
|
|
|
|
export type ListApiTokensResponse = {
|
|
apiTokens: ApiToken[];
|
|
availableScopes: string[];
|
|
};
|
|
|
|
export type ListUsersResponse = {
|
|
users: InstanceUser[];
|
|
};
|
|
|
|
export type UserResponse = {
|
|
user: InstanceUser;
|
|
};
|
|
|
|
export type ListLoginDeviceSessionsResponse = {
|
|
sessions: LoginDeviceSession[];
|
|
};
|
|
|
|
export type ObjectStorageSettingsResponse = {
|
|
settings: ObjectStorageSettings;
|
|
externalSettings?: ObjectStorageSettings | null;
|
|
};
|
|
|
|
export type MemoResponse = {
|
|
memo: MemoDetail;
|
|
};
|
|
|
|
export type ListTemplatesResponse = {
|
|
templates: MemoTemplate[];
|
|
};
|
|
|
|
export type TemplateResponse = {
|
|
template: MemoTemplate;
|
|
};
|
|
|
|
export type ListScheduledTasksResponse = {
|
|
tasks: ScheduledTask[];
|
|
};
|
|
|
|
export type ListWorkspaceExtensionsResponse = {
|
|
extensions: WorkspaceExtension[];
|
|
};
|
|
|
|
export type WorkspaceExtensionResponse = {
|
|
extension: WorkspaceExtension;
|
|
};
|
|
|
|
export type ScheduledTaskResponse = {
|
|
task: ScheduledTask;
|
|
};
|
|
|
|
export type ScheduledTaskRunResponse = {
|
|
run: ScheduledTaskRun;
|
|
};
|
|
|
|
export type ScheduledTaskRunsResponse = {
|
|
runs: ScheduledTaskRun[];
|
|
totalCount: number;
|
|
nextOffset: number | null;
|
|
};
|
|
|
|
export type ScheduledTaskRunHistoryItem = ScheduledTaskRun & {
|
|
taskName: string;
|
|
pluginId: string;
|
|
ownerPluginId: string | null;
|
|
pluginScheduleKey: string | null;
|
|
};
|
|
|
|
export type ScheduledTaskRunHistoryResponse = {
|
|
runs: ScheduledTaskRunHistoryItem[];
|
|
totalCount: number;
|
|
nextOffset: number | null;
|
|
};
|
|
|
|
export type MemoShareResponse = {
|
|
share: MemoShare | null;
|
|
};
|
|
|
|
export type PublicMemoShareResponse = {
|
|
share: PublicMemoShare;
|
|
};
|
|
|
|
export type NotebookResponse = {
|
|
notebook: Notebook;
|
|
};
|
|
|
|
export type ResourceResponse = {
|
|
resource: Resource;
|
|
};
|
|
|
|
export type ResourceUploadResponse = {
|
|
upload: {
|
|
id: string;
|
|
resourceId: string;
|
|
partSize: number;
|
|
partCount: number;
|
|
byteSize: number;
|
|
expiresAt: string;
|
|
};
|
|
};
|
|
|
|
export type MarkdownExportPage = {
|
|
memos: MemoDetail[];
|
|
resources: Resource[];
|
|
totalCount: number;
|
|
nextOffset: number | null;
|
|
};
|
|
|
|
export type JsonBackupPage = MarkdownExportPage & {
|
|
revisions: JsonBackupRevision[];
|
|
};
|
|
|
|
export type AiProviderCreatePayload = {
|
|
provider: AiProvider;
|
|
displayName: string;
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
isEnabled: boolean;
|
|
initialModelId?: string;
|
|
};
|
|
|
|
export type AiProviderUpdatePayload = {
|
|
provider: AiProvider;
|
|
displayName: string;
|
|
baseUrl: string;
|
|
apiKey?: string;
|
|
isEnabled: boolean;
|
|
};
|
|
|
|
/** @deprecated Use SyncBootstrapResponse; kept for mobile source compatibility. */
|
|
export type MobileSyncBootstrapPage = SyncBootstrapResponse;
|
|
|
|
/** @deprecated Use SyncChange; kept for mobile source compatibility. */
|
|
export type MobileSyncChange = SyncChange;
|
|
|
|
/** @deprecated Use SyncChangesResponse; kept for mobile source compatibility. */
|
|
export type MobileSyncChangesPage = SyncChangesResponse;
|
|
|
|
export class ApiRequestError extends Error {
|
|
status: number;
|
|
code?: string;
|
|
details?: unknown;
|
|
responseDiagnostics?: ApiResponseDiagnostics;
|
|
|
|
constructor(
|
|
message: string,
|
|
status: number,
|
|
code?: string,
|
|
details?: unknown,
|
|
responseDiagnostics?: ApiResponseDiagnostics,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
this.status = status;
|
|
this.code = code;
|
|
this.details = details;
|
|
this.responseDiagnostics = responseDiagnostics;
|
|
}
|
|
}
|
|
|
|
export type ApiResponseDiagnostics = {
|
|
cloudflareMitigated: boolean;
|
|
isEdgeEverApiError: boolean;
|
|
rayId?: string;
|
|
};
|
|
|
|
export type { SyncBootstrapResponse, SyncChangesResponse };
|
|
|
|
export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
|
const getFetch = () => options.fetch ?? globalThis.fetch;
|
|
const corsCapability = new Map<string, boolean>();
|
|
const providerFetch = () => options.providerFetch ?? getFetch();
|
|
const getBaseUrl = () => normalizeBaseUrl(
|
|
typeof options.baseUrl === "function" ? options.baseUrl() : options.baseUrl,
|
|
);
|
|
const getToken = () => {
|
|
const token = typeof options.token === "function" ? options.token() : options.token;
|
|
return token || undefined;
|
|
};
|
|
|
|
const send = async (
|
|
path: string,
|
|
init?: RequestInit,
|
|
requestOptions?: { attachToken?: boolean; setJsonContentType?: boolean },
|
|
) => {
|
|
const token = getToken();
|
|
const context: EdgeEverClientRequestContext = { path, ...(token ? { token } : {}) };
|
|
await options.beforeRequest?.(context);
|
|
|
|
const headers = new Headers(init?.headers);
|
|
const attachToken = requestOptions?.attachToken
|
|
?? options.shouldAttachToken?.(path)
|
|
?? true;
|
|
|
|
if (token && attachToken && !headers.has("Authorization")) {
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
|
|
if (
|
|
requestOptions?.setJsonContentType !== false
|
|
&& !(init?.body instanceof FormData)
|
|
&& !headers.has("Content-Type")
|
|
) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
|
|
const requestUrl = isAbsoluteHttpUrl(path) ? path : `${getBaseUrl()}${path}`;
|
|
const response = await getFetch()(requestUrl, {
|
|
credentials: "include",
|
|
...init,
|
|
headers,
|
|
});
|
|
|
|
return { context, response };
|
|
};
|
|
|
|
const throwRequestError = async (
|
|
context: EdgeEverClientRequestContext,
|
|
response: Response,
|
|
fallbackMessage = "Request failed",
|
|
): Promise<never> => {
|
|
const body = await response.json().catch(() => null);
|
|
const rayId = response.headers.get("cf-ray")?.trim();
|
|
const error =
|
|
body && typeof body === "object" && "error" in body
|
|
? (body as { error?: { code?: string; message?: string; details?: unknown } }).error
|
|
: undefined;
|
|
const responseDiagnostics: ApiResponseDiagnostics = {
|
|
cloudflareMitigated: response.headers.get("cf-mitigated") === "challenge",
|
|
isEdgeEverApiError: Boolean(error && typeof error === "object"),
|
|
...(rayId ? { rayId } : {}),
|
|
};
|
|
|
|
if (response.status === 401) {
|
|
void options.onUnauthorized?.(context);
|
|
}
|
|
|
|
throw new ApiRequestError(
|
|
error?.message || response.statusText || fallbackMessage,
|
|
response.status,
|
|
error?.code,
|
|
error?.details,
|
|
responseDiagnostics,
|
|
);
|
|
};
|
|
|
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
const { context, response } = await send(path, init);
|
|
|
|
if (!response.ok) {
|
|
await throwRequestError(context, response);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
};
|
|
|
|
const requestPluginPublic = async (input: PluginPublicFetchRequest, signal?: AbortSignal): Promise<PluginPublicFetchResponse> => {
|
|
const { context, response } = await send("/api/v1/plugins/network/fetch", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
signal,
|
|
});
|
|
const upstreamStatus = response.headers.get("x-edgeever-upstream-status");
|
|
if (!response.ok || !upstreamStatus) await throwRequestError(context, response, "Public network request failed");
|
|
const status = Number(upstreamStatus);
|
|
if (!Number.isInteger(status) || status < 100 || status > 599) throw new Error("Invalid public network response status");
|
|
const headers: Record<string, string> = {};
|
|
for (const [key, value] of response.headers) {
|
|
const prefix = "x-edgeever-upstream-header-";
|
|
if (key.startsWith(prefix)) headers[key.slice(prefix.length)] = value;
|
|
}
|
|
return {
|
|
url: input.url,
|
|
status,
|
|
statusText: decodeURIComponent(response.headers.get("x-edgeever-upstream-status-text") ?? ""),
|
|
headers,
|
|
body: await response.arrayBuffer(),
|
|
};
|
|
};
|
|
|
|
const requestResourceResponse = async (path: string, init?: RequestInit) => {
|
|
const isAbsolute = isAbsoluteHttpUrl(path);
|
|
const { context, response } = await send(path, init, {
|
|
attachToken: !isAbsolute,
|
|
setJsonContentType: false,
|
|
});
|
|
if (!response.ok) await throwRequestError(context, response, "Resource download failed");
|
|
return response;
|
|
};
|
|
|
|
const requestBlob = async (path: string) => (await requestResourceResponse(path)).blob();
|
|
|
|
const requestArrayBuffer = async (path: string) => {
|
|
const { context, response } = await send(path, undefined, { setJsonContentType: false });
|
|
if (!response.ok) await throwRequestError(context, response, "Binary download failed");
|
|
return response.arrayBuffer();
|
|
};
|
|
|
|
const uploadMemoResourceParts = async (memoId: string, source: MultipartResourceUploadSource) => {
|
|
const filename = source.filename.trim() || "attachment";
|
|
const mimeType = source.mimeType || "application/octet-stream";
|
|
const { upload } = await request<ResourceUploadResponse>(
|
|
`/api/v1/memos/${encodeURIComponent(memoId)}/resource-uploads`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ filename, mimeType, byteSize: source.byteSize }),
|
|
},
|
|
);
|
|
|
|
try {
|
|
for (let partNumber = 1; partNumber <= upload.partCount; partNumber += 1) {
|
|
const start = (partNumber - 1) * upload.partSize;
|
|
const chunk = await source.readPart(start, Math.min(start + upload.partSize, source.byteSize));
|
|
let attempt = 0;
|
|
while (true) {
|
|
try {
|
|
await request<{ part: { partNumber: number; byteSize: number } }>(
|
|
`/api/v1/resource-uploads/${encodeURIComponent(upload.id)}/parts/${partNumber}`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body: chunk,
|
|
},
|
|
);
|
|
break;
|
|
} catch (error) {
|
|
attempt += 1;
|
|
const retryable = !(error instanceof ApiRequestError)
|
|
|| error.status === 408
|
|
|| error.status === 429
|
|
|| error.status >= 500;
|
|
if (!retryable || attempt >= 3) throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, attempt * 250));
|
|
}
|
|
}
|
|
}
|
|
return await request<ResourceResponse>(
|
|
`/api/v1/resource-uploads/${encodeURIComponent(upload.id)}/complete`,
|
|
{ method: "POST", body: JSON.stringify({}) },
|
|
);
|
|
} catch (error) {
|
|
await request<{ ok: true }>(`/api/v1/resource-uploads/${encodeURIComponent(upload.id)}`, {
|
|
method: "DELETE",
|
|
}).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const uploadMemoResourceMultipart = (memoId: string, file: Blob) =>
|
|
uploadMemoResourceParts(memoId, {
|
|
filename: "name" in file && typeof file.name === "string" && file.name.trim()
|
|
? file.name
|
|
: "attachment",
|
|
mimeType: file.type || "application/octet-stream",
|
|
byteSize: file.size,
|
|
readPart: async (start, end) => file.slice(start, end),
|
|
});
|
|
|
|
const createResourceMultipartSink = (upload: ResourceUploadResponse["upload"]) => {
|
|
let partNumber = 1;
|
|
let bufferedBytes = 0;
|
|
let receivedBytes = 0;
|
|
let chunks: ArrayBuffer[] = [];
|
|
let closed = false;
|
|
|
|
const abort = async () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
chunks = [];
|
|
bufferedBytes = 0;
|
|
await request<{ ok: true }>(`/api/v1/resource-uploads/${encodeURIComponent(upload.id)}`, {
|
|
method: "DELETE",
|
|
}).catch(() => undefined);
|
|
};
|
|
|
|
const sendPart = async () => {
|
|
if (bufferedBytes === 0) return;
|
|
const body = new Blob(chunks, { type: "application/octet-stream" });
|
|
const currentPart = partNumber;
|
|
chunks = [];
|
|
bufferedBytes = 0;
|
|
let attempt = 0;
|
|
while (true) {
|
|
try {
|
|
await request<{ part: { partNumber: number; byteSize: number } }>(
|
|
`/api/v1/resource-uploads/${encodeURIComponent(upload.id)}/parts/${currentPart}`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body,
|
|
},
|
|
);
|
|
partNumber += 1;
|
|
return;
|
|
} catch (error) {
|
|
attempt += 1;
|
|
const retryable = !(error instanceof ApiRequestError)
|
|
|| error.status === 408
|
|
|| error.status === 429
|
|
|| error.status >= 500;
|
|
if (!retryable || attempt >= 3) throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, attempt * 250));
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
write: async (chunk: Uint8Array) => {
|
|
if (closed) throw new Error("Resource restore upload is closed");
|
|
let offset = 0;
|
|
while (offset < chunk.byteLength) {
|
|
const available = upload.partSize - bufferedBytes;
|
|
const length = Math.min(available, chunk.byteLength - offset);
|
|
const copy = new Uint8Array(length);
|
|
copy.set(chunk.subarray(offset, offset + length));
|
|
chunks.push(copy.buffer);
|
|
bufferedBytes += length;
|
|
receivedBytes += length;
|
|
offset += length;
|
|
if (bufferedBytes === upload.partSize) await sendPart();
|
|
}
|
|
},
|
|
close: async () => {
|
|
if (closed) throw new Error("Resource restore upload is closed");
|
|
if (receivedBytes !== upload.byteSize) {
|
|
await abort();
|
|
throw new Error(`Resource restore size mismatch: expected ${upload.byteSize}, received ${receivedBytes}`);
|
|
}
|
|
try {
|
|
await sendPart();
|
|
const result = await request<ResourceResponse>(
|
|
`/api/v1/resource-uploads/${encodeURIComponent(upload.id)}/complete`,
|
|
{ method: "POST", body: JSON.stringify({}) },
|
|
);
|
|
closed = true;
|
|
return result;
|
|
} catch (error) {
|
|
await abort();
|
|
throw error;
|
|
}
|
|
},
|
|
abort,
|
|
};
|
|
};
|
|
|
|
const createJsonResourceRestoreSink = async (
|
|
resourceId: string,
|
|
metadata: JsonBackupMemo["resources"][number],
|
|
) => {
|
|
const { upload } = await request<ResourceUploadResponse>(
|
|
`/api/v1/restores/json/resources/${encodeURIComponent(resourceId)}/uploads`,
|
|
{ method: "POST", body: JSON.stringify(metadata) },
|
|
);
|
|
return createResourceMultipartSink(upload);
|
|
};
|
|
|
|
const consumeCompanionStream = async (path: string, body: string, signal: AbortSignal | undefined, onEvent: (event: CompanionEvent) => void) => {
|
|
const { context, response } = await send(path, { method: "POST", body, signal });
|
|
if (!response.ok) await throwRequestError(context, response);
|
|
if (!response.body) throw new ApiRequestError("Stream unavailable", 502, "companion_failed");
|
|
await consumeEventStream(response.body, onEvent);
|
|
};
|
|
|
|
const runPreparedCompanion = async (
|
|
prepared: import("@edgeever/shared").CompanionPreparedTurn,
|
|
streamOptions: { signal?: AbortSignal; onEvent: (event: CompanionEvent) => void },
|
|
) => {
|
|
const { streamDirectCompanion } = await import("./companion-direct-stream");
|
|
const turnId = prepared.turn.id;
|
|
await streamDirectCompanion(prepared, {
|
|
fetch: providerFetch(),
|
|
signal: streamOptions.signal,
|
|
onEvent: streamOptions.onEvent,
|
|
executeTool: (body) => request<CompanionToolExecuteResult>(
|
|
`/api/v1/companion/turns/${encodeURIComponent(turnId)}/tools`,
|
|
{ method: "POST", body: JSON.stringify(body), signal: streamOptions.signal },
|
|
),
|
|
checkpoint: (body) => request(
|
|
`/api/v1/companion/turns/${encodeURIComponent(turnId)}/checkpoint`,
|
|
{ method: "POST", body: JSON.stringify(body), signal: streamOptions.signal },
|
|
),
|
|
complete: (body) => request<{ turn: CompanionTurn }>(
|
|
`/api/v1/companion/turns/${encodeURIComponent(turnId)}/complete`,
|
|
{ method: "POST", body: JSON.stringify(body), signal: streamOptions.signal },
|
|
),
|
|
});
|
|
};
|
|
|
|
const runCompanionDirect = async (
|
|
preparePath: string,
|
|
payload: unknown,
|
|
streamOptions: { signal?: AbortSignal; onEvent: (event: CompanionEvent) => void },
|
|
proxy: () => Promise<void>,
|
|
proxyExisting: (turnId: string) => Promise<void>,
|
|
) => {
|
|
const prepare = async () => {
|
|
const { context, response } = await send(preparePath, {
|
|
method: "POST", body: JSON.stringify(payload), signal: streamOptions.signal,
|
|
});
|
|
if (response.ok) {
|
|
const { parsePreparedCompanion } = await import("./companion-direct-stream");
|
|
const prepared = parsePreparedCompanion(await response.json());
|
|
if (!prepared) throw new ApiRequestError("The companion prepare response is invalid", 502, "companion_failed");
|
|
return prepared;
|
|
}
|
|
if (response.status !== 404) await throwRequestError(context, response);
|
|
return null;
|
|
};
|
|
|
|
if (options.directAiGeneration) {
|
|
const prepared = await prepare();
|
|
if (prepared) {
|
|
await runPreparedCompanion(prepared, streamOptions);
|
|
return;
|
|
}
|
|
await proxy();
|
|
return;
|
|
}
|
|
|
|
if (options.tryDirectAiGeneration) {
|
|
const { context, response } = await send("/api/v1/ai/direct-target", { signal: streamOptions.signal });
|
|
if (response.ok) {
|
|
const target = parseAiDirectTarget(await response.json());
|
|
const cacheKey = target ? aiDirectTargetKey(target) : "";
|
|
let allowed = cacheKey ? corsCapability.get(cacheKey) : false;
|
|
if (target && allowed === undefined) {
|
|
allowed = await probeAiProviderCors(target, providerFetch(), streamOptions.signal);
|
|
corsCapability.set(cacheKey, allowed);
|
|
}
|
|
if (target && allowed) {
|
|
try {
|
|
const prepared = await prepare();
|
|
if (prepared) {
|
|
try {
|
|
await runPreparedCompanion(prepared, streamOptions);
|
|
return;
|
|
} catch (error) {
|
|
if (!isAiCorsFailure(error)) throw error;
|
|
corsCapability.set(cacheKey, false);
|
|
await proxyExisting(prepared.turn.id);
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!isAiCorsFailure(error)) throw error;
|
|
if (cacheKey) corsCapability.set(cacheKey, false);
|
|
}
|
|
}
|
|
} else if (response.status !== 404 && response.status !== 409) {
|
|
await throwRequestError(context, response);
|
|
}
|
|
}
|
|
|
|
await proxy();
|
|
};
|
|
|
|
const runDirectOrProxy = async <T, P>(args: {
|
|
signal?: AbortSignal;
|
|
prepare: () => Promise<P | null>;
|
|
run: (prepared: P) => Promise<T>;
|
|
proxy: () => Promise<T>;
|
|
recover?: (prepared: P) => Promise<T>;
|
|
}): Promise<T> => {
|
|
const attemptPrepare = async () => {
|
|
if (options.directAiGeneration) return args.prepare();
|
|
if (!options.tryDirectAiGeneration) return null;
|
|
const { context, response } = await send("/api/v1/ai/direct-target", { signal: args.signal });
|
|
if (!response.ok) {
|
|
if (response.status !== 404 && response.status !== 409) await throwRequestError(context, response);
|
|
return null;
|
|
}
|
|
const target = parseAiDirectTarget(await response.json());
|
|
const cacheKey = target ? aiDirectTargetKey(target) : "";
|
|
let allowed = cacheKey ? corsCapability.get(cacheKey) : false;
|
|
if (target && allowed === undefined) {
|
|
allowed = await probeAiProviderCors(target, providerFetch(), args.signal);
|
|
corsCapability.set(cacheKey, allowed);
|
|
}
|
|
if (!target || !allowed) return null;
|
|
try {
|
|
return await args.prepare();
|
|
} catch (error) {
|
|
if (!isAiCorsFailure(error)) throw error;
|
|
if (cacheKey) corsCapability.set(cacheKey, false);
|
|
return null;
|
|
}
|
|
};
|
|
const prepared = await attemptPrepare();
|
|
if (prepared == null) return args.proxy();
|
|
try {
|
|
return await args.run(prepared);
|
|
} catch (error) {
|
|
if (!isAiCorsFailure(error)) throw error;
|
|
return args.recover ? args.recover(prepared) : args.proxy();
|
|
}
|
|
};
|
|
|
|
const pluginCapabilities = createPluginCapabilities(request, requestPluginPublic);
|
|
|
|
return {
|
|
getInstanceHealth: () => request<InstanceHealth>("/api/health"),
|
|
...pluginCapabilities,
|
|
pluginAi: {
|
|
status: pluginCapabilities.pluginAi.status,
|
|
generate: async (input: PluginAiGenerateRequest & { signal?: AbortSignal }) => {
|
|
const { signal, ...payload } = input;
|
|
return runDirectOrProxy({
|
|
signal,
|
|
prepare: async () => {
|
|
const { context, response } = await send("/api/v1/plugins/ai/generate/prepare", {
|
|
method: "POST", body: JSON.stringify(payload), signal,
|
|
});
|
|
if (response.ok) {
|
|
const { parsePreparedAiText } = await import("./ai-direct-generate");
|
|
const prepared = parsePreparedAiText(await response.json());
|
|
if (!prepared) throw new ApiRequestError("The AI prepare response is invalid", 502, "plugin_ai_failed");
|
|
return prepared;
|
|
}
|
|
if (response.status !== 404) await throwRequestError(context, response);
|
|
return null;
|
|
},
|
|
run: async (prepared) => {
|
|
const { generateDirectAiText } = await import("./ai-direct-generate");
|
|
return { text: await generateDirectAiText(prepared, { fetch: providerFetch(), signal }) };
|
|
},
|
|
proxy: () => pluginCapabilities.pluginAi.generate(input),
|
|
});
|
|
},
|
|
},
|
|
|
|
getInstanceRelease: () => request<InstanceRelease>("/api/release"),
|
|
|
|
getSession: () => request<AuthSession>("/api/v1/auth/session"),
|
|
|
|
getPublicMemoShare: (token: string) =>
|
|
request<PublicMemoShareResponse>(`/api/public/shares/${encodeURIComponent(token)}`),
|
|
|
|
updatePublishedNoteBodyFont: (bodyFont: PublicMemoShare["bodyFont"]) =>
|
|
request<{ bodyFont: PublicMemoShare["bodyFont"] }>("/api/v1/me/note-body-font", {
|
|
method: "PUT",
|
|
body: JSON.stringify({ bodyFont }),
|
|
}),
|
|
|
|
unlockPublicMemoShare: (token: string, password: string) =>
|
|
request<{ ok: true }>(`/api/public/shares/${encodeURIComponent(token)}/unlock`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
|
|
listLoginDeviceSessions: () =>
|
|
request<ListLoginDeviceSessionsResponse>("/api/v1/auth/sessions"),
|
|
|
|
revokeLoginDeviceSession: (sessionId: string) =>
|
|
request<{ ok: true }>(`/api/v1/auth/sessions/${sessionId}`, { method: "DELETE" }),
|
|
|
|
updateLoginDeviceSession: (sessionId: string, payload: { label: string | null }) =>
|
|
request<{ ok: true }>(`/api/v1/auth/sessions/${sessionId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
revokeOtherLoginDeviceSessions: () =>
|
|
request<{ ok: true }>("/api/v1/auth/sessions", { method: "DELETE" }),
|
|
|
|
login: (payload: LoginInput) =>
|
|
request<AuthSession>("/api/v1/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
changePassword: (payload: { currentPassword: string; newPassword: string; confirmPassword: string }) =>
|
|
request<{ ok: true }>("/api/v1/auth/change-password", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
getObjectStorageSettings: () =>
|
|
request<ObjectStorageSettingsResponse>("/api/v1/instance/object-storage"),
|
|
|
|
testObjectStorageConnection: (payload: {
|
|
endpoint: string;
|
|
region: string;
|
|
bucket: string;
|
|
accessKeyId: string;
|
|
secretAccessKey?: string;
|
|
forcePathStyle: boolean;
|
|
objectPrefix: string;
|
|
}) => request<{ ok: true }>("/api/v1/instance/object-storage/test", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateObjectStorageSettings: (payload:
|
|
| { provider: "builtin" }
|
|
| {
|
|
provider: "s3";
|
|
displayName: string;
|
|
endpoint: string;
|
|
region: string;
|
|
bucket: string;
|
|
accessKeyId: string;
|
|
secretAccessKey?: string;
|
|
forcePathStyle: boolean;
|
|
objectPrefix: string;
|
|
}) => request<ObjectStorageSettingsResponse>("/api/v1/instance/object-storage", {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
getAiSettings: (locale?: string) => {
|
|
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
|
|
return request<AiSettings>(`/api/v1/ai/settings${search}`);
|
|
},
|
|
|
|
createAiProvider: (payload: AiProviderCreatePayload) =>
|
|
request<AiSettings>("/api/v1/ai/providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateAiProvider: (providerConfigId: string, payload: AiProviderUpdatePayload) =>
|
|
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteAiProvider: (providerConfigId: string) =>
|
|
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
testAiProvider: (providerConfigId: string, payload: {
|
|
modelId: string;
|
|
provider?: AiProvider;
|
|
baseUrl?: string;
|
|
apiKey?: string;
|
|
}) =>
|
|
request<{ ok: true; response: string }>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/test`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
discoverAiProviderModels: (providerConfigId: string) =>
|
|
request<{ models: AiDiscoveredModel[] }>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/discover-models`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
}),
|
|
|
|
addAiModel: (providerConfigId: string, payload: { modelId: string; displayName?: string }) =>
|
|
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/models`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteAiModel: (providerConfigId: string, modelConfigId: string) =>
|
|
request<AiSettings>(`/api/v1/ai/providers/${encodeURIComponent(providerConfigId)}/models/${encodeURIComponent(modelConfigId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
updateDefaultAiModel: (modelConfigId: string | null) =>
|
|
request<AiSettings>("/api/v1/ai/default-model", {
|
|
method: "PUT",
|
|
body: JSON.stringify({ modelConfigId }),
|
|
}),
|
|
|
|
listAiPrompts: (locale?: string) => {
|
|
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
|
|
return request<{ prompts: AiPromptTemplate[] }>(`/api/v1/ai/prompts${search}`);
|
|
},
|
|
|
|
createAiPrompt: (payload: AiPromptTemplateCreateInput) =>
|
|
request<{ prompt: AiPromptTemplate }>("/api/v1/ai/prompts", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateAiPrompt: (
|
|
promptId: string,
|
|
payload: AiPromptTemplateUpdateInput,
|
|
) =>
|
|
request<{ prompt: AiPromptTemplate }>(`/api/v1/ai/prompts/${encodeURIComponent(promptId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteAiPrompt: (promptId: string) =>
|
|
request<{ ok: true }>(`/api/v1/ai/prompts/${encodeURIComponent(promptId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
restoreDefaultAiPrompts: (locale?: string) => {
|
|
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
|
|
return request<{ prompts: AiPromptTemplate[]; restoredCount: number }>(`/api/v1/ai/prompts/restore-defaults${search}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
},
|
|
|
|
suggestAiTags: (payload: AiTagSuggestionsRequestInput, signal?: AbortSignal) =>
|
|
runDirectOrProxy({
|
|
signal,
|
|
prepare: async () => {
|
|
const { context, response } = await send("/api/v1/ai/tag-suggestions/prepare", {
|
|
method: "POST", body: JSON.stringify(payload), signal,
|
|
});
|
|
if (response.ok) {
|
|
const { parsePreparedTagSuggestions } = await import("./ai-direct-generate");
|
|
const prepared = parsePreparedTagSuggestions(await response.json());
|
|
if (!prepared) throw new ApiRequestError("The AI prepare response is invalid", 502, "ai_tag_suggestions_failed");
|
|
return prepared;
|
|
}
|
|
if (response.status !== 404) await throwRequestError(context, response);
|
|
return null;
|
|
},
|
|
run: async (prepared) => {
|
|
const { generateDirectAiText, suggestionsFromDirectText } = await import("./ai-direct-generate");
|
|
const text = await generateDirectAiText(prepared, { fetch: providerFetch(), signal });
|
|
return { suggestions: suggestionsFromDirectText(text, prepared) };
|
|
},
|
|
proxy: () => request<AiTagSuggestionsResponse>("/api/v1/ai/tag-suggestions", {
|
|
method: "POST", body: JSON.stringify(payload), signal,
|
|
}),
|
|
}),
|
|
|
|
listCompanionMemories: () => request<{ memories: CompanionMemory[] }>("/api/v1/companion/memories"),
|
|
getCompanionDiscoverySettings: () => request<{ settings: CompanionDiscoverySettings }>("/api/v1/companion/discovery/settings"),
|
|
saveCompanionDiscoverySettings: (input: CompanionDiscoverySettingsInput) => request<{ settings: CompanionDiscoverySettings }>("/api/v1/companion/discovery/settings", { method: "PUT", body: JSON.stringify(input) }),
|
|
listCompanionDiscoveries: () => request<{ items: CompanionDiscoveryItem[] }>("/api/v1/companion/discovery"),
|
|
checkCompanionDiscoveries: (locale: string, signal?: AbortSignal) =>
|
|
runDirectOrProxy({
|
|
signal,
|
|
prepare: async () => {
|
|
const { context, response } = await send(
|
|
`/api/v1/companion/discovery/check/prepare?locale=${encodeURIComponent(locale)}`,
|
|
{ method: "POST", body: "{}", signal },
|
|
);
|
|
if (response.ok) {
|
|
const { parsePreparedDiscovery } = await import("./ai-direct-generate");
|
|
const prepared = parsePreparedDiscovery(await response.json());
|
|
if (!prepared) throw new ApiRequestError("The companion prepare response is invalid", 502, "companion_failed");
|
|
return prepared;
|
|
}
|
|
if (response.status !== 404) await throwRequestError(context, response);
|
|
return null;
|
|
},
|
|
run: async (prepared) => {
|
|
if (prepared.quiet) return { items: prepared.items };
|
|
const { generateDirectDiscoveryOutput } = await import("./ai-direct-generate");
|
|
const output = await generateDirectDiscoveryOutput(prepared, { fetch: providerFetch(), signal });
|
|
return request<{ items: CompanionDiscoveryItem[] }>("/api/v1/companion/discovery/check/complete", {
|
|
method: "POST", body: JSON.stringify({ turnId: prepared.turnId, output }), signal,
|
|
});
|
|
},
|
|
proxy: () => request<{ items: CompanionDiscoveryItem[] }>(
|
|
`/api/v1/companion/discovery/check?locale=${encodeURIComponent(locale)}`,
|
|
{ method: "POST", body: "{}", signal },
|
|
),
|
|
recover: (prepared) => prepared.quiet
|
|
? Promise.resolve({ items: prepared.items })
|
|
: request<{ items: CompanionDiscoveryItem[] }>("/api/v1/companion/discovery/check/proxy", {
|
|
method: "POST", body: JSON.stringify({ turnId: prepared.turnId }), signal,
|
|
}),
|
|
}),
|
|
acknowledgeCompanionDiscovery: (id: string, dismiss = false) => request<{ ok: true }>(`/api/v1/companion/discovery/${encodeURIComponent(id)}/${dismiss ? "dismiss" : "seen"}`, { method: "POST", body: "{}" }),
|
|
rememberCompanionDiscoveryFeedback: (id: string) => request<{ ok: true }>(`/api/v1/companion/discovery/${encodeURIComponent(id)}/feedback`, { method: "POST", body: "{}" }),
|
|
listCompanionActions: () => request<{ actions: CompanionAction[] }>("/api/v1/companion/actions"),
|
|
applyCompanionAction: (id: string) => request<{ action: CompanionAction }>(`/api/v1/companion/actions/${encodeURIComponent(id)}/apply`, { method: "POST", body: "{}" }),
|
|
dismissCompanionAction: (id: string) => request<{ action: CompanionAction }>(`/api/v1/companion/actions/${encodeURIComponent(id)}/dismiss`, { method: "POST", body: "{}" }),
|
|
saveCompanionMemory: (content: string, sourceTurnId?: string) => request<{ memory: CompanionMemory }>("/api/v1/companion/memories", {
|
|
method: "POST", body: JSON.stringify({ content, sourceTurnId }),
|
|
}),
|
|
updateCompanionMemory: (memory: CompanionMemory, content: string) => request<{ memory: CompanionMemory }>(`/api/v1/companion/memories/${encodeURIComponent(memory.id)}`, {
|
|
method: "PATCH", body: JSON.stringify({ content, version: memory.version }),
|
|
}),
|
|
forgetCompanionMemory: (memory: CompanionMemory) => request<{ ok: true }>(`/api/v1/companion/memories/${encodeURIComponent(memory.id)}?version=${memory.version}`, { method: "DELETE" }),
|
|
listCompanionTurns: () => request<{ turns: CompanionTurn[] }>("/api/v1/companion/turns"),
|
|
getCompanionTurn: (id: string) => request<{ turn: CompanionTurn }>(`/api/v1/companion/turns/${encodeURIComponent(id)}`),
|
|
cancelCompanionTurn: (id: string) => request<{ ok: true }>(`/api/v1/companion/turns/${encodeURIComponent(id)}/cancel`, { method: "POST", body: "{}" }),
|
|
resumeCompanionTurn: async (id: string, payload: CompanionTurnResume = {}, options: { signal?: AbortSignal; onEvent: (event: CompanionEvent) => void }) => {
|
|
await runCompanionDirect(
|
|
`/api/v1/companion/turns/${encodeURIComponent(id)}/resume/prepare`,
|
|
payload,
|
|
options,
|
|
() => consumeCompanionStream(
|
|
`/api/v1/companion/turns/${encodeURIComponent(id)}/resume`,
|
|
JSON.stringify(payload),
|
|
options.signal,
|
|
options.onEvent,
|
|
),
|
|
() => consumeCompanionStream(
|
|
`/api/v1/companion/turns/${encodeURIComponent(id)}/proxy`,
|
|
"{}",
|
|
options.signal,
|
|
options.onEvent,
|
|
),
|
|
);
|
|
},
|
|
clearCompanionHistory: () => request<{ ok: true }>("/api/v1/companion/history", { method: "DELETE" }),
|
|
exportCompanion: () => request<{ version: 2; controls: { useMemory: boolean; learningEnabled: boolean }; exportedAt: string; memories: CompanionMemory[]; turns: CompanionTurn[]; actions: CompanionAction[] }>("/api/v1/companion/export"),
|
|
importCompanionMemories: (memories: { content: string; kind?: "explicit" | "inferred" }[], controls?: { useMemory: boolean; learningEnabled: boolean }) => request<{ memories: CompanionMemory[] }>("/api/v1/companion/import-memories", {
|
|
method: "POST", body: JSON.stringify({ version: 2, memories, controls }),
|
|
}),
|
|
streamCompanion: async (payload: CompanionTurnInput, options: { signal?: AbortSignal; onEvent: (event: CompanionEvent) => void }) => {
|
|
await runCompanionDirect(
|
|
"/api/v1/companion/turns/prepare",
|
|
payload,
|
|
options,
|
|
() => consumeCompanionStream("/api/v1/companion/turns", JSON.stringify(payload), options.signal, options.onEvent),
|
|
(turnId) => consumeCompanionStream(
|
|
`/api/v1/companion/turns/${encodeURIComponent(turnId)}/proxy`,
|
|
"{}",
|
|
options.signal,
|
|
options.onEvent,
|
|
),
|
|
);
|
|
},
|
|
|
|
streamInfographicAgent: async (
|
|
payload: InfographicAgentRequest,
|
|
streamOptions: { signal?: AbortSignal; onEvent: (event: InfographicAgentEvent) => void },
|
|
) => {
|
|
const { context, response } = await send("/api/v1/ai/infographic-agent", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
signal: streamOptions.signal,
|
|
});
|
|
if (!response.ok) await throwRequestError(context, response);
|
|
if (!response.body) throw new ApiRequestError("Streaming response is unavailable", 502, "ai_stream_unavailable");
|
|
await consumeEventStream(response.body, streamOptions.onEvent);
|
|
},
|
|
|
|
streamAiGeneration: async (
|
|
payload: AiGenerateInput,
|
|
streamOptions: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void },
|
|
) => {
|
|
const proxyGenerate = async () => {
|
|
const path = "/api/v1/ai/generate";
|
|
const { context, response } = await send(path, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
signal: streamOptions.signal,
|
|
});
|
|
if (!response.ok) {
|
|
await throwRequestError(context, response);
|
|
}
|
|
if (!response.body) throw new ApiRequestError("Streaming response is unavailable", 502, "ai_stream_unavailable");
|
|
await consumeEventStream(response.body, streamOptions.onEvent);
|
|
};
|
|
|
|
const prepareAndDirect = async () => {
|
|
const { context, response } = await send("/api/v1/ai/generate/prepare", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
signal: streamOptions.signal,
|
|
});
|
|
if (response.ok) {
|
|
const prepared = parsePreparedAiGeneration(await response.json());
|
|
if (!prepared) {
|
|
throw new ApiRequestError("The AI prepare response is invalid", 502, "ai_generation_failed");
|
|
}
|
|
await streamDirectAiGeneration(prepared, payload.attachments, {
|
|
fetch: providerFetch(),
|
|
signal: streamOptions.signal,
|
|
onEvent: streamOptions.onEvent,
|
|
});
|
|
return true;
|
|
}
|
|
if (response.status !== 404) await throwRequestError(context, response);
|
|
return false;
|
|
};
|
|
|
|
if (options.directAiGeneration) {
|
|
if (await prepareAndDirect()) return;
|
|
await proxyGenerate();
|
|
return;
|
|
}
|
|
|
|
if (options.tryDirectAiGeneration) {
|
|
const { context, response } = await send("/api/v1/ai/direct-target", {
|
|
signal: streamOptions.signal,
|
|
});
|
|
if (response.ok) {
|
|
const target = parseAiDirectTarget(await response.json());
|
|
const cacheKey = target ? aiDirectTargetKey(target) : "";
|
|
let allowed = cacheKey ? corsCapability.get(cacheKey) : false;
|
|
if (target && allowed === undefined) {
|
|
allowed = await probeAiProviderCors(target, providerFetch(), streamOptions.signal);
|
|
corsCapability.set(cacheKey, allowed);
|
|
}
|
|
if (target && allowed) {
|
|
try {
|
|
if (await prepareAndDirect()) return;
|
|
} catch (error) {
|
|
if (!isAiCorsFailure(error)) throw error;
|
|
corsCapability.set(cacheKey, false);
|
|
}
|
|
}
|
|
} else if (response.status !== 404 && response.status !== 409) {
|
|
await throwRequestError(context, response);
|
|
}
|
|
}
|
|
|
|
await proxyGenerate();
|
|
},
|
|
|
|
listUsers: () => request<ListUsersResponse>("/api/v1/users"),
|
|
|
|
createUser: (payload: { username: string; displayName?: string | null; password: string }) =>
|
|
request<UserResponse>("/api/v1/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateUser: (userId: string, payload: { displayName?: string | null; password?: string; isDisabled?: boolean }) =>
|
|
request<UserResponse>(`/api/v1/users/${userId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
logout: () =>
|
|
request<{ ok: true }>("/api/v1/auth/logout", {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
}),
|
|
|
|
listNotebooks: () => request<ListNotebooksResponse>("/api/v1/notebooks"),
|
|
|
|
syncBootstrap: (params?: { afterId?: string | null; limit?: number }) => {
|
|
const search = new URLSearchParams();
|
|
if (params?.afterId) search.set("afterId", params.afterId);
|
|
if (params?.limit) search.set("limit", String(params.limit));
|
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
|
return request<SyncBootstrapResponse>(`/api/v1/sync/bootstrap${suffix}`);
|
|
},
|
|
|
|
syncChanges: (params: { cursor: number; limit?: number }) => {
|
|
const search = new URLSearchParams({ cursor: String(params.cursor) });
|
|
if (params.limit) search.set("limit", String(params.limit));
|
|
return request<SyncChangesResponse>(`/api/v1/sync/changes?${search.toString()}`);
|
|
},
|
|
|
|
getMobileSyncBootstrapPage: (afterId: string | null = null, limit = 100) => {
|
|
const search = new URLSearchParams({ limit: String(limit) });
|
|
if (afterId) {
|
|
search.set("afterId", afterId);
|
|
}
|
|
return request<MobileSyncBootstrapPage>(`/api/v1/sync/bootstrap?${search.toString()}`);
|
|
},
|
|
|
|
getMobileSyncChanges: (cursor: number, limit = 100) =>
|
|
request<MobileSyncChangesPage>(`/api/v1/sync/changes?cursor=${cursor}&limit=${limit}`),
|
|
|
|
createNotebook: (payload: { name: string; parentId?: string | null }) =>
|
|
request<NotebookResponse>("/api/v1/notebooks", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateNotebook: (notebookId: string, payload: { name?: string; parentId?: string | null; sortOrder?: number }) =>
|
|
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteNotebook: (notebookId: string) =>
|
|
request<{ ok: true }>(`/api/v1/notebooks/${notebookId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
restoreNotebook: (notebookId: string) =>
|
|
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}/restore`, {
|
|
method: "POST",
|
|
}),
|
|
|
|
listTags: () => request<ListTagsResponse>("/api/v1/tags"),
|
|
|
|
renameTag: (tag: string, name: string) =>
|
|
request<{ ok: true; updated: number }>(`/api/v1/tags/${encodeURIComponent(tag)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
|
|
deleteTag: (tag: string) =>
|
|
request<{ ok: true; updated: number }>(`/api/v1/tags/${encodeURIComponent(tag)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
listApiTokens: () => request<ListApiTokensResponse>("/api/v1/api-tokens"),
|
|
|
|
createApiToken: (payload: { name: string; scopes: string[]; expiresAt?: string | null }) =>
|
|
request<CreatedApiToken>("/api/v1/api-tokens", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
revokeApiToken: (tokenId: string) =>
|
|
request<{ ok: true }>(`/api/v1/api-tokens/${tokenId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
listMemos: (params: {
|
|
notebookId?: string | null;
|
|
includeDescendants?: boolean;
|
|
q?: string;
|
|
tag?: string;
|
|
trash?: boolean;
|
|
sort?: MemoSortMode;
|
|
filter?: MemoFilterMode;
|
|
cursor?: string | null;
|
|
limit?: number;
|
|
}) => {
|
|
const search = new URLSearchParams();
|
|
|
|
if (params.notebookId) {
|
|
search.set("notebookId", params.notebookId);
|
|
}
|
|
|
|
if (params.includeDescendants) {
|
|
search.set("includeDescendants", "1");
|
|
}
|
|
|
|
if (params.q?.trim()) {
|
|
search.set("q", params.q.trim());
|
|
}
|
|
|
|
if (params.tag?.trim()) {
|
|
search.set("tag", params.tag.trim());
|
|
}
|
|
|
|
if (params.trash) {
|
|
search.set("trash", "1");
|
|
}
|
|
|
|
if (params.sort) {
|
|
search.set("sort", params.sort);
|
|
}
|
|
|
|
if (params.filter && params.filter !== "all") {
|
|
search.set("filter", params.filter);
|
|
}
|
|
|
|
if (params.cursor) {
|
|
search.set("cursor", params.cursor);
|
|
}
|
|
|
|
if (params.limit) {
|
|
search.set("limit", String(params.limit));
|
|
}
|
|
|
|
return request<ListMemosResponse>(`/api/v1/memos?${search.toString()}`);
|
|
},
|
|
|
|
createMemo: (payload: {
|
|
notebookId: string;
|
|
title?: string;
|
|
contentJson?: TiptapDoc;
|
|
contentMarkdown?: string;
|
|
tags?: string[];
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}) =>
|
|
request<MemoResponse>("/api/v1/memos", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
listTemplates: () => request<ListTemplatesResponse>("/api/v1/templates"),
|
|
|
|
createTemplate: (payload: {
|
|
name: string;
|
|
description?: string | null;
|
|
memoId?: string;
|
|
title?: string | null;
|
|
contentMarkdown?: string;
|
|
tags?: string[];
|
|
}) => request<TemplateResponse>("/api/v1/templates", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateTemplate: (templateId: string, payload: {
|
|
name?: string;
|
|
description?: string | null;
|
|
title?: string | null;
|
|
contentMarkdown?: string;
|
|
tags?: string[];
|
|
}) => request<TemplateResponse>(`/api/v1/templates/${templateId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
useTemplate: (templateId: string, notebookId: string) =>
|
|
request<MemoResponse>(`/api/v1/templates/${templateId}/use`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ notebookId }),
|
|
}),
|
|
|
|
deleteTemplate: (templateId: string) =>
|
|
request<{ ok: true }>(`/api/v1/templates/${templateId}`, { method: "DELETE" }),
|
|
|
|
listScheduledTasks: (executorDeviceId?: string) => {
|
|
const search = new URLSearchParams();
|
|
if (executorDeviceId) search.set("executorDeviceId", executorDeviceId);
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return request<ListScheduledTasksResponse>(`/api/v1/scheduled-tasks${suffix}`);
|
|
},
|
|
|
|
listWorkspaceExtensions: () => request<ListWorkspaceExtensionsResponse>("/api/v1/workspace-extensions"),
|
|
|
|
upsertWorkspaceExtension: (extensionId: string, payload: WorkspaceExtensionUpsertInput) =>
|
|
request<WorkspaceExtensionResponse>(
|
|
`/api/v1/workspace-extensions/${encodeURIComponent(extensionId)}`,
|
|
{ method: "PUT", body: JSON.stringify(payload) },
|
|
),
|
|
|
|
deleteWorkspaceExtension: (extensionId: string) =>
|
|
request<WorkspaceExtensionResponse>(
|
|
`/api/v1/workspace-extensions/${encodeURIComponent(extensionId)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
|
|
listScheduledTaskRunHistory: (offset = 0, limit = 50) => {
|
|
const search = new URLSearchParams({ offset: String(offset), limit: String(limit) });
|
|
return request<ScheduledTaskRunHistoryResponse>(`/api/v1/scheduled-task-runs?${search.toString()}`);
|
|
},
|
|
|
|
listPluginScheduledTasks: (pluginId: string) => request<ListScheduledTasksResponse>(
|
|
`/api/v1/scheduled-tasks/plugin/${encodeURIComponent(pluginId)}`,
|
|
),
|
|
|
|
upsertPluginScheduledTask: (payload: {
|
|
pluginId: string;
|
|
scheduleKey: string;
|
|
name: string;
|
|
commandId: string;
|
|
cronExpression: string;
|
|
timezone: string;
|
|
executorDeviceId: string;
|
|
missedRunPolicy?: "run-once" | "skip";
|
|
isEnabled?: boolean;
|
|
}) => request<ScheduledTaskResponse>("/api/v1/scheduled-tasks/plugin-upsert", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deletePluginScheduledTask: (pluginId: string, scheduleKey: string) => request<{ ok: true }>(
|
|
`/api/v1/scheduled-tasks/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(scheduleKey)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
|
|
createScheduledTask: (payload: {
|
|
name: string;
|
|
taskType: "plugin-command";
|
|
taskPayload: { pluginId: string; commandId: string };
|
|
cronExpression: string;
|
|
timezone: string;
|
|
executorDeviceId: string;
|
|
missedRunPolicy?: "run-once" | "skip";
|
|
isEnabled?: boolean;
|
|
}) => request<ScheduledTaskResponse>("/api/v1/scheduled-tasks", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
updateScheduledTask: (taskId: string, payload: {
|
|
name?: string;
|
|
taskPayload?: { pluginId: string; commandId: string };
|
|
cronExpression?: string;
|
|
timezone?: string;
|
|
executorDeviceId?: string;
|
|
missedRunPolicy?: "run-once" | "skip";
|
|
isEnabled?: boolean;
|
|
}) => request<ScheduledTaskResponse>(`/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteScheduledTask: (taskId: string) => request<{ ok: true }>(
|
|
`/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
|
|
listScheduledTaskRuns: (taskId: string, offset = 0, limit = 50) => {
|
|
const search = new URLSearchParams({ offset: String(offset), limit: String(limit) });
|
|
return request<ScheduledTaskRunsResponse>(
|
|
`/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}/runs?${search.toString()}`,
|
|
);
|
|
},
|
|
|
|
claimScheduledTaskRun: (taskId: string, payload: {
|
|
scheduledFor: string;
|
|
executorDeviceId: string;
|
|
}) => request<ScheduledTaskRunResponse>(
|
|
`/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}/runs/claim`,
|
|
{ method: "POST", body: JSON.stringify(payload) },
|
|
),
|
|
|
|
finishScheduledTaskRun: (taskId: string, runId: string, payload: {
|
|
executorDeviceId: string;
|
|
status: "succeeded" | "failed";
|
|
errorMessage?: string | null;
|
|
}) => request<ScheduledTaskRunResponse>(
|
|
`/api/v1/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/finish`,
|
|
{ method: "POST", body: JSON.stringify(payload) },
|
|
),
|
|
|
|
moveMemos: (payload: { memoIds: string[]; notebookId: string }) =>
|
|
request<{ ok: true; moved: number }>("/api/v1/memos/batch/move", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteMemos: (payload: { memoIds: string[]; permanent?: boolean }) =>
|
|
request<{ ok: true; deleted: number }>("/api/v1/memos/batch/delete", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
emptyTrash: () =>
|
|
request<{ ok: true; deleted: number }>("/api/v1/memos/trash/empty", {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
getMemo: (memoId: string, options?: { includeDeleted?: boolean }) => {
|
|
const search = new URLSearchParams();
|
|
|
|
if (options?.includeDeleted) {
|
|
search.set("includeDeleted", "1");
|
|
}
|
|
|
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
|
return request<MemoResponse>(`/api/v1/memos/${memoId}${suffix}`);
|
|
},
|
|
|
|
getMemoShare: (memoId: string) =>
|
|
request<MemoShareResponse>(`/api/v1/memos/${memoId}/share`),
|
|
|
|
createMemoShare: (memoId: string) =>
|
|
request<{ share: MemoShare }>(`/api/v1/memos/${memoId}/share`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
}),
|
|
|
|
updateMemoShare: (memoId: string, payload: { passwordProtected: boolean }) =>
|
|
request<{ share: MemoShare }>(`/api/v1/memos/${memoId}/share`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
getTableForm: (memoId: string) =>
|
|
request<{ form: TableFormSettings | null }>(`/api/v1/memos/${encodeURIComponent(memoId)}/form`),
|
|
|
|
updateTableForm: (memoId: string, payload: TableFormUpdateInput) =>
|
|
request<{ form: TableFormSettings }>(`/api/v1/memos/${encodeURIComponent(memoId)}/form`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
getPublicTableForm: (token: string) =>
|
|
request<{ form: PublicTableForm }>(`/api/public/forms/${encodeURIComponent(token)}`),
|
|
|
|
unlockPublicTableForm: (token: string, password: string) =>
|
|
request<{ ok: true }>(`/api/public/forms/${encodeURIComponent(token)}/unlock`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
|
|
submitPublicTableForm: (token: string, cells: Record<string, unknown>) =>
|
|
request<{ ok: true }>(`/api/public/forms/${encodeURIComponent(token)}/submissions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ cells }),
|
|
}),
|
|
|
|
uploadPublicTableFormFile: (token: string, file: File) => {
|
|
const form = new FormData();
|
|
form.append("file", file, file.name || "attachment");
|
|
return request<{ resource: { id: string; filename: string; mimeType: string; byteSize: number } }>(
|
|
`/api/public/forms/${encodeURIComponent(token)}/resources`,
|
|
{ method: "POST", body: form },
|
|
);
|
|
},
|
|
|
|
revokeMemoShare: (memoId: string) =>
|
|
request<{ ok: true }>(`/api/v1/memos/${memoId}/share`, { method: "DELETE" }),
|
|
|
|
createMemoEditSession: (memoId: string) =>
|
|
request<{ editSession: MemoEditSession }>(`/api/v1/memos/${memoId}/edit-sessions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
}),
|
|
|
|
listMemoRevisions: (memoId: string) => request<ListMemoRevisionsResponse>(`/api/v1/memos/${memoId}/revisions`),
|
|
|
|
restoreMemoRevision: (memoId: string, revisionId: string) =>
|
|
request<MemoResponse>(`/api/v1/memos/${memoId}/revisions/${revisionId}/restore`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
}),
|
|
|
|
listResources: () => request<ListResourcesResponse>("/api/v1/resources"),
|
|
|
|
renameResource: (resourceId: string, filename: string) =>
|
|
request<ResourceResponse>(`/api/v1/resources/${encodeURIComponent(resourceId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ filename }),
|
|
}),
|
|
|
|
updateResourceContent: (resourceId: string, file: Blob, expectedContentHash: string) => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("expectedContentHash", expectedContentHash);
|
|
form.append("mimeType", file.type || "application/octet-stream");
|
|
if (file instanceof File) form.append("filename", file.name);
|
|
return request<ResourceResponse>(`/api/v1/resources/${encodeURIComponent(resourceId)}/blob`, {
|
|
method: "PUT",
|
|
body: form,
|
|
});
|
|
},
|
|
|
|
deleteResource: (resourceId: string) =>
|
|
request<{ ok: true }>(`/api/v1/resources/${encodeURIComponent(resourceId)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
|
|
getMarkdownExportPage: (offset = 0, limit = 50, memoIds?: string[]) => {
|
|
const search = new URLSearchParams({
|
|
offset: String(offset),
|
|
limit: String(limit),
|
|
});
|
|
if (memoIds && memoIds.length > 0) {
|
|
search.set("ids", memoIds.join(","));
|
|
}
|
|
return request<MarkdownExportPage>(`/api/v1/exports/markdown?${search.toString()}`);
|
|
},
|
|
|
|
getJsonBackupPage: (offset = 0, limit = 25) =>
|
|
request<JsonBackupPage>(`/api/v1/backups/json?offset=${offset}&limit=${limit}`),
|
|
|
|
restoreJsonNotebooks: (notebooks: JsonBackupNotebook[]) =>
|
|
request<{ ok: true }>("/api/v1/restores/json/notebooks", {
|
|
method: "POST",
|
|
body: JSON.stringify({ notebooks }),
|
|
}),
|
|
|
|
restoreJsonMemos: (memos: JsonBackupMemo[]) =>
|
|
request<{ ok: true }>("/api/v1/restores/json/memos", {
|
|
method: "POST",
|
|
body: JSON.stringify({ memos }),
|
|
}),
|
|
|
|
restoreJsonAiPrompts: (prompts: JsonBackupAiPrompt[]) =>
|
|
request<{ ok: true }>("/api/v1/restores/json/ai-prompts", {
|
|
method: "POST",
|
|
body: JSON.stringify({ prompts }),
|
|
}),
|
|
|
|
createJsonResourceRestoreSink,
|
|
|
|
restoreJsonResource: async (resourceId: string, metadata: JsonBackupMemo["resources"][number], file: Blob) => {
|
|
const sink = await createJsonResourceRestoreSink(resourceId, metadata);
|
|
const reader = file.stream().getReader();
|
|
try {
|
|
while (true) {
|
|
const next = await reader.read();
|
|
if (next.done) break;
|
|
await sink.write(next.value);
|
|
}
|
|
await sink.close();
|
|
return { ok: true as const };
|
|
} catch (error) {
|
|
await sink.abort();
|
|
throw error;
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
},
|
|
|
|
getResourceResponse: (resourceUrl: string, init?: RequestInit) =>
|
|
requestResourceResponse(resourceUrl, init),
|
|
|
|
getResourceBlob: (resourceUrl: string) => requestBlob(resourceUrl),
|
|
|
|
downloadGithubPluginAsset: (
|
|
owner: string,
|
|
repository: string,
|
|
releaseTag: string,
|
|
assetName: "manifest.json" | "main.js" | "styles.css",
|
|
) => requestArrayBuffer(
|
|
`/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/${encodeURIComponent(releaseTag)}/assets/${encodeURIComponent(assetName)}`,
|
|
),
|
|
|
|
downloadGithubPluginAssetById: (
|
|
owner: string,
|
|
repository: string,
|
|
assetId: string,
|
|
assetName: "manifest.json" | "main.js" | "styles.css",
|
|
) => requestArrayBuffer(
|
|
`/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/assets/${encodeURIComponent(assetId)}/${encodeURIComponent(assetName)}`,
|
|
),
|
|
|
|
getGithubPluginRepositoryManifest: async (owner: string, repository: string) => {
|
|
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/manifest`;
|
|
const { context, response } = await send(path, undefined, { setJsonContentType: false });
|
|
if (!response.ok) await throwRequestError(context, response, "GitHub plugin manifest request failed");
|
|
return response.text();
|
|
},
|
|
|
|
getGithubPluginLatestManifest: async (owner: string, repository: string) => {
|
|
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/latest-manifest`;
|
|
const { context, response } = await send(path, undefined, { setJsonContentType: false });
|
|
if (!response.ok) await throwRequestError(context, response, "GitHub plugin latest-release manifest request failed");
|
|
return response.text();
|
|
},
|
|
|
|
getGithubPluginRelease: async (owner: string, repository: string, releaseTag: string) => {
|
|
const path = `/api/v1/plugins/github/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/releases/tags/${encodeURIComponent(releaseTag)}`;
|
|
const { context, response } = await send(path, undefined, { setJsonContentType: false });
|
|
if (response.status === 404) return null;
|
|
if (!response.ok) await throwRequestError(context, response, "GitHub plugin release request failed");
|
|
return response.json() as Promise<{
|
|
tag_name: string;
|
|
draft: boolean;
|
|
assets: Array<{
|
|
id: number;
|
|
name: string;
|
|
size: number;
|
|
url: string;
|
|
browser_download_url: string;
|
|
digest?: string;
|
|
}>;
|
|
}>;
|
|
},
|
|
|
|
uploadMemoResource: (memoId: string, file: Blob | FormData) => {
|
|
// Small files do not benefit from an upload session's three round trips.
|
|
// Keep large attachments on the bounded-memory, resumable path.
|
|
if (!(file instanceof FormData) && file.size > MAX_SINGLE_REQUEST_UPLOAD_BYTES) {
|
|
return uploadMemoResourceMultipart(memoId, file);
|
|
}
|
|
const form = file instanceof FormData ? file : new FormData();
|
|
if (!(file instanceof FormData)) {
|
|
const filename = "name" in file && typeof file.name === "string" && file.name.trim()
|
|
? file.name
|
|
: "attachment";
|
|
form.append("file", file, filename);
|
|
}
|
|
return request<ResourceResponse>(`/api/v1/memos/${encodeURIComponent(memoId)}/resources`, {
|
|
method: "POST",
|
|
body: form,
|
|
});
|
|
},
|
|
|
|
uploadMemoResourceParts,
|
|
|
|
updateMemo: (
|
|
memoId: string,
|
|
payload: {
|
|
expectedRevision?: number;
|
|
expectedContentHash?: string;
|
|
editSessionId?: string;
|
|
notebookId?: string;
|
|
title?: string;
|
|
isPinned?: boolean;
|
|
contentJson?: TiptapDoc;
|
|
contentMarkdown?: string;
|
|
tags?: string[];
|
|
allowDestructiveOverwrite?: boolean;
|
|
}
|
|
) =>
|
|
request<MemoResponse>(`/api/v1/memos/${memoId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
deleteMemo: (memoId: string, options?: { permanent?: boolean }) => {
|
|
const search = new URLSearchParams();
|
|
|
|
if (options?.permanent) {
|
|
search.set("permanent", "1");
|
|
}
|
|
|
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
|
return request<{ ok: true }>(`/api/v1/memos/${memoId}${suffix}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
|
|
restoreMemo: (memoId: string) =>
|
|
request<MemoResponse>(`/api/v1/memos/${memoId}/restore`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
}),
|
|
|
|
mergeMemos: (payload: { memoIds: string[]; notebookId?: string; title?: string }) =>
|
|
request<MemoResponse>("/api/v1/memos/merge", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
resetDemo: () =>
|
|
request<{ success: true }>("/api/v1/demo/reset", {
|
|
method: "POST",
|
|
}),
|
|
};
|
|
};
|
|
|
|
const normalizeBaseUrl = (value?: string) => {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
return value.replace(/\/+$/, "");
|
|
};
|
|
|
|
const isAbsoluteHttpUrl = (value: string) => /^https?:\/\//i.test(value);
|