nexus/ui/src/api/chat.ts
Nexus Dev 1b80631b66 feat(24-02): API client methods and React Query hooks for search, bookmarks, branches
- Add searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation to chatApi
- Create useChatSearch hook with debounced FTS, placeholderData, 30s staleTime
- Create useChatBookmarks and useToggleBookmark with cache invalidation for bookmarks and search queries
2026-04-02 15:08:51 +00:00

214 lines
6.9 KiB
TypeScript

import { api } from "./client";
import type {
ChatConversation,
ChatConversationListResponse,
ChatMessage,
ChatMessageListResponse,
ChatMessageSearchResponse,
ChatBookmarkToggleResponse,
ChatBookmarkListResponse,
} from "@paperclipai/shared";
export const chatApi = {
listConversations(companyId: string, opts?: { cursor?: string; limit?: number; search?: string; agentId?: string }) {
const params = new URLSearchParams();
if (opts?.cursor) params.set("cursor", opts.cursor);
if (opts?.limit) params.set("limit", String(opts.limit));
if (opts?.search) params.set("search", opts.search);
if (opts?.agentId) params.set("agentId", opts.agentId);
const qs = params.toString();
return api.get<ChatConversationListResponse>(
`/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`,
);
},
createConversation(companyId: string, data?: { title?: string; agentId?: string }) {
return api.post<ChatConversation>(`/companies/${companyId}/conversations`, data ?? {});
},
getConversation(id: string) {
return api.get<ChatConversation>(`/conversations/${id}`);
},
updateConversation(
id: string,
data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null; agentId?: string | null },
) {
return api.patch<ChatConversation>(`/conversations/${id}`, data);
},
deleteConversation(id: string) {
return api.delete<void>(`/conversations/${id}`);
},
listMessages(conversationId: string, opts?: { cursor?: string; limit?: number }) {
const params = new URLSearchParams();
if (opts?.cursor) params.set("cursor", opts.cursor);
if (opts?.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
return api.get<ChatMessageListResponse>(
`/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`,
);
},
postMessage(
conversationId: string,
data: { role: string; content: string; agentId?: string },
) {
return api.post<ChatMessage>(`/conversations/${conversationId}/messages`, data);
},
async postMessageAndStream(
conversationId: string,
data: { content: string; agentId?: string },
callbacks: {
onToken: (token: string) => void;
onDone: (messageId: string, content: string) => void;
onError: (error: string) => void;
},
signal?: AbortSignal,
): Promise<void> {
const response = await fetch(`/api/conversations/${conversationId}/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data),
signal,
});
if (!response.ok) {
callbacks.onError(`HTTP ${response.status}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError("No response body");
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw === "[DONE]") continue;
try {
const parsed = JSON.parse(raw) as { type: string; token?: string; messageId?: string; content?: string; error?: string };
if (parsed.type === "token" && parsed.token !== undefined) {
callbacks.onToken(parsed.token);
} else if (parsed.type === "done" && parsed.messageId !== undefined) {
callbacks.onDone(parsed.messageId, parsed.content ?? "");
} else if (parsed.type === "error") {
callbacks.onError(parsed.error ?? "Unknown error");
}
} catch {
// ignore malformed SSE lines
}
}
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
callbacks.onError((err as Error).message);
}
} finally {
reader.releaseLock();
}
},
savePartialMessage(
conversationId: string,
data: { role: string; content: string },
) {
return api.post<ChatMessage>(`/conversations/${conversationId}/messages`, data);
},
async editMessage(conversationId: string, messageId: string, content: string) {
return api.patch<ChatMessage>(`/conversations/${conversationId}/messages/${messageId}`, { content });
},
async truncateMessagesAfter(conversationId: string, messageId: string) {
await fetch(`/api/conversations/${conversationId}/messages/after/${messageId}`, {
method: "DELETE",
credentials: "include",
});
},
async deleteMessage(conversationId: string, messageId: string) {
await fetch(`/api/conversations/${conversationId}/messages/${messageId}`, {
method: "DELETE",
credentials: "include",
});
},
handoffSpec(
conversationId: string,
spec: { what: string; why: string; constraints: string; success: string },
targetRole: string = "pm",
) {
return api.post<{ handoffMessageId: string; issues: Array<{ id: string; identifier: string; title: string }> }>(
`/conversations/${conversationId}/handoff`,
{ spec, targetRole },
);
},
postStatusUpdate(
conversationId: string,
data: { agentName: string; taskId: string; taskTitle?: string; taskUrl?: string },
) {
return api.post<{ id: string }>(`/conversations/${conversationId}/status-update`, data);
},
searchMessages(companyId: string, q: string, limit?: number) {
const params = new URLSearchParams({ q });
if (limit) params.set("limit", String(limit));
return api.get<ChatMessageSearchResponse>(
`/companies/${companyId}/messages/search?${params}`,
);
},
toggleBookmark(conversationId: string, messageId: string) {
return api.post<ChatBookmarkToggleResponse>(
`/conversations/${conversationId}/bookmarks`,
{ messageId },
);
},
getBookmarks(companyId: string, conversationId?: string) {
const params = new URLSearchParams();
if (conversationId) params.set("conversationId", conversationId);
const qs = params.toString();
return api.get<ChatBookmarkListResponse>(
`/companies/${companyId}/bookmarks${qs ? `?${qs}` : ""}`,
);
},
branchConversation(conversationId: string, branchFromMessageId: string) {
return api.post<ChatConversation>(
`/conversations/${conversationId}/branch`,
{ branchFromMessageId },
);
},
listBranches(conversationId: string) {
return api.get<{ items: ChatConversation[] }>(
`/conversations/${conversationId}/branches`,
);
},
exportConversation(conversationId: string, format: "markdown" | "json") {
// Returns a download URL — use window.location.href to trigger
return `/api/conversations/${conversationId}/export?format=${format}`;
},
};