nexus/ui/src/api/chat.ts
Nexus Dev 26894428a9 feat(22-05): virtualize ChatMessageList and add chat API edit/truncate methods
- Rewrite ChatMessageList with @tanstack/react-virtual useVirtualizer (estimateSize: 80, overscan: 5)
- Dynamic height measurement via measureElement ref
- Streaming message appended as synthetic __streaming__ entry
- virtualizer.measure() called on streamingContent change (Pitfall 3)
- Jump-to-bottom button when scrolled >200px from bottom
- 3 Skeleton loading placeholders
- Add editMessage, truncateMessagesAfter, deleteMessage to chatApi
- Add postMessageAndStream and savePartialMessage (were missing from prior plan)
- Fix duplicate ChatConversation type exports in packages/shared/src/index.ts (Rule 1 - Bug)
2026-04-02 15:08:50 +00:00

151 lines
4.8 KiB
TypeScript

import { api } from "./client";
import type {
ChatConversation,
ChatConversationListResponse,
ChatMessage,
ChatMessageListResponse,
} 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",
});
},
};