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 { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
||||||
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||||
import { PanelProvider } from "./context/PanelContext";
|
import { PanelProvider } from "./context/PanelContext";
|
||||||
|
import { ChatPanelProvider } from "./context/ChatPanelContext";
|
||||||
import { SidebarProvider } from "./context/SidebarContext";
|
import { SidebarProvider } from "./context/SidebarContext";
|
||||||
import { DialogProvider } from "./context/DialogContext";
|
import { DialogProvider } from "./context/DialogContext";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
|
@ -48,11 +49,13 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<PanelProvider>
|
<PanelProvider>
|
||||||
<PluginLauncherProvider>
|
<ChatPanelProvider>
|
||||||
<DialogProvider>
|
<PluginLauncherProvider>
|
||||||
<App />
|
<DialogProvider>
|
||||||
</DialogProvider>
|
<App />
|
||||||
</PluginLauncherProvider>
|
</DialogProvider>
|
||||||
|
</PluginLauncherProvider>
|
||||||
|
</ChatPanelProvider>
|
||||||
</PanelProvider>
|
</PanelProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</BreadcrumbProvider>
|
</BreadcrumbProvider>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue