- 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
214 lines
6.9 KiB
TypeScript
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}`;
|
|
},
|
|
};
|