feat(21-03): add chat API client, ChatPanelProvider, and TanStack Query hooks
- Create chatApi with full CRUD + archive/pin/message endpoints - Create ChatPanelContext with localStorage persistence (nexus:chat-panel-open) - Create useChatConversations with useInfiniteQuery and conversation mutations - Create useChatMessages with useInfiniteQuery and useSendMessage - Wrap app tree with ChatPanelProvider in main.tsx
This commit is contained in:
parent
94a7c723de
commit
2a0724839b
5 changed files with 187 additions and 5 deletions
37
ui/src/api/chat.ts
Normal file
37
ui/src/api/chat.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { ChatConversation, ChatConversationListResponse, ChatMessage } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: (companyId: 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<ChatConversationListResponse>(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
createConversation: (companyId: string, data?: { title?: string }) =>
|
||||
api.post<ChatConversation>(`/api/companies/${companyId}/conversations`, data ?? {}),
|
||||
getConversation: (id: string) =>
|
||||
api.get<ChatConversation>(`/api/conversations/${id}`),
|
||||
updateConversation: (id: string, data: { title?: string }) =>
|
||||
api.patch<ChatConversation>(`/api/conversations/${id}`, data),
|
||||
deleteConversation: (id: string) =>
|
||||
api.delete(`/api/conversations/${id}`),
|
||||
archiveConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/archive`, {}),
|
||||
unarchiveConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/unarchive`, {}),
|
||||
pinConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/pin`, {}),
|
||||
unpinConversation: (id: string) =>
|
||||
api.post<ChatConversation>(`/api/conversations/${id}/unpin`, {}),
|
||||
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<{ items: ChatMessage[]; hasMore: boolean }>(`/api/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
sendMessage: (conversationId: string, data: { role: string; content: string; agentId?: string | null }) =>
|
||||
api.post<ChatMessage>(`/api/conversations/${conversationId}/messages`, data),
|
||||
};
|
||||
60
ui/src/context/ChatPanelContext.tsx
Normal file
60
ui/src/context/ChatPanelContext.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
const STORAGE_KEY = "nexus:chat-panel-open";
|
||||
|
||||
interface ChatPanelContextValue {
|
||||
chatOpen: boolean;
|
||||
setChatOpen: (open: boolean) => void;
|
||||
toggleChat: () => void;
|
||||
activeConversationId: string | null;
|
||||
setActiveConversationId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
|
||||
|
||||
function readPreference(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePreference(open: boolean) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(open));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function ChatPanelProvider({ children }: { children: ReactNode }) {
|
||||
const [chatOpen, setChatOpenState] = useState(readPreference);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
|
||||
const setChatOpen = useCallback((open: boolean) => {
|
||||
setChatOpenState(open);
|
||||
writePreference(open);
|
||||
}, []);
|
||||
|
||||
const toggleChat = useCallback(() => {
|
||||
setChatOpenState((prev) => {
|
||||
const next = !prev;
|
||||
writePreference(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatPanelContext.Provider
|
||||
value={{ chatOpen, setChatOpen, toggleChat, activeConversationId, setActiveConversationId }}
|
||||
>
|
||||
{children}
|
||||
</ChatPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatPanel() {
|
||||
const ctx = useContext(ChatPanelContext);
|
||||
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
|
||||
return ctx;
|
||||
}
|
||||
56
ui/src/hooks/useChatConversations.ts
Normal file
56
ui/src/hooks/useChatConversations.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { chatApi } from "../api/chat";
|
||||
|
||||
export function useChatConversations(companyId: string | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["chat", "conversations", companyId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
|
||||
enabled: !!companyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConversation(companyId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data?: { title?: string }) =>
|
||||
chatApi.createConversation(companyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConversationActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
pin: useMutation({
|
||||
mutationFn: chatApi.pinConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
unpin: useMutation({
|
||||
mutationFn: chatApi.unpinConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
archive: useMutation({
|
||||
mutationFn: chatApi.archiveConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
unarchive: useMutation({
|
||||
mutationFn: chatApi.unarchiveConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: chatApi.deleteConversation,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
rename: useMutation({
|
||||
mutationFn: ({ id, title }: { id: string; title: string }) =>
|
||||
chatApi.updateConversation(id, { title }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
26
ui/src/hooks/useChatMessages.ts
Normal file
26
ui/src/hooks/useChatMessages.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { chatApi } from "../api/chat";
|
||||
|
||||
export function useChatMessages(conversationId: string | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["chat", "messages", conversationId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined,
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage(conversationId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
chatApi.sendMessage(conversationId!, { role: "user", content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { CompanyProvider } from "./context/CompanyContext";
|
|||
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
||||
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||
import { PanelProvider } from "./context/PanelContext";
|
||||
import { ChatPanelProvider } from "./context/ChatPanelContext";
|
||||
import { SidebarProvider } from "./context/SidebarContext";
|
||||
import { DialogProvider } from "./context/DialogContext";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
|
|
@ -48,11 +49,13 @@ createRoot(document.getElementById("root")!).render(
|
|||
<BreadcrumbProvider>
|
||||
<SidebarProvider>
|
||||
<PanelProvider>
|
||||
<PluginLauncherProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</PluginLauncherProvider>
|
||||
<ChatPanelProvider>
|
||||
<PluginLauncherProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</PluginLauncherProvider>
|
||||
</ChatPanelProvider>
|
||||
</PanelProvider>
|
||||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue