From 6856ceb1f3dd29adc1700b2db12e0903872dc18b Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Mon, 17 Aug 2026 09:56:28 -0300 Subject: [PATCH 1/2] feat(chat): buscar conversas por conteudo no backend (#175) GET /conversations?q= filtra por titulo E conteudo das mensagens (case-insensitive), mantendo take 100 e o mesmo shape de resposta. Hook useDebouncedValue adicionado para a UI de busca. --- hooks/use-debounced-value.ts | 18 ++++++++++++++++++ server/routes/conversations.ts | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 hooks/use-debounced-value.ts diff --git a/hooks/use-debounced-value.ts b/hooks/use-debounced-value.ts new file mode 100644 index 0000000..05fa8c9 --- /dev/null +++ b/hooks/use-debounced-value.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Returns a copy of `value` that only updates after `delayMs` of stability. + * Useful to avoid firing requests on every keystroke. + */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/server/routes/conversations.ts b/server/routes/conversations.ts index c0ea439..be36c68 100644 --- a/server/routes/conversations.ts +++ b/server/routes/conversations.ts @@ -209,14 +209,24 @@ async function persistMessages(conversationId: string, messages: CreateMessageIn } // GET /conversations — lista conversas do usuário +// `q` (opcional) busca por título da conversa E conteúdo das mensagens. app.get("/", async (c) => { const userId = requireAuth(c); if (typeof userId !== "string") return userId; const archived = c.req.query("archived") === "true"; + const q = c.req.query("q")?.trim() ?? ""; + + const where: Prisma.ConversationWhereInput = { userId, archived }; + if (q) { + where.OR = [ + { title: { contains: q, mode: "insensitive" } }, + { messages: { some: { content: { contains: q, mode: "insensitive" } } } }, + ]; + } const conversations = await prisma.conversation.findMany({ - where: { userId, archived }, + where, select: { id: true, title: true, From 91b03cb378361c88e1130713db0ae8f22ea9c4fa Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Mon, 17 Aug 2026 09:57:09 -0300 Subject: [PATCH 2/2] feat(chat): busca por conteudo na sidebar e exportacao JSON/Markdown (#175, #176) - Sidebar consulta GET /conversations?q= com debounce de 300ms quando a busca tem 2+ caracteres, mostrando contador de resultados (titulo + conteudo das mensagens); busca curta segue filtrando por titulo no client. - Novos helpers em lib/conversation-export (markdown/json/filename/download) com testes unitarios; chat-page refatorado para usa-los e ganha item Exportar (JSON) no menu da toolbar. - Sidebar ganha acao por conversa com exportacao em JSON e Markdown. --- components/chat/chat-history-sidebar.tsx | 89 +++++++++++++-- components/chat/chat-page.tsx | 67 ++++++++---- lib/conversation-export.test.ts | 131 +++++++++++++++++++++++ lib/conversation-export.ts | 120 +++++++++++++++++++++ 4 files changed, 380 insertions(+), 27 deletions(-) create mode 100644 lib/conversation-export.test.ts create mode 100644 lib/conversation-export.ts diff --git a/components/chat/chat-history-sidebar.tsx b/components/chat/chat-history-sidebar.tsx index 095570f..3d45e8d 100644 --- a/components/chat/chat-history-sidebar.tsx +++ b/components/chat/chat-history-sidebar.tsx @@ -5,6 +5,7 @@ import { ArchiveIcon, ArchiveRestoreIcon, CheckIcon, + DownloadIcon, HistoryIcon, Loader2Icon, MessageSquarePlusIcon, @@ -13,6 +14,7 @@ import { Trash2Icon, XIcon, } from "lucide-react"; +import { toast } from "sonner"; import { AlertDialog, @@ -25,6 +27,12 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -35,7 +43,14 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { apiJson, apiJsonRequest } from "@/lib/api"; +import { + buildExportFilename, + conversationToJson, + conversationToMarkdown, + downloadTextFile, +} from "@/lib/conversation-export"; import { cn } from "@/lib/utils"; type ConversationSummary = { @@ -78,11 +93,20 @@ export function ChatHistorySidebar({ const [bulkWorking, setBulkWorking] = useState(false); const renameInputRef = useRef(null); - const fetchConversations = useCallback(async () => { + // Busca server-side (título + conteúdo das mensagens) com debounce. + const debouncedSearch = useDebouncedValue(searchQuery.trim(), 300); + const isSearching = debouncedSearch.length >= 2; + + const fetchConversations = useCallback(async (search: string) => { setLoading(true); try { - const query = showArchived ? "?archived=true" : ""; - const data = await apiJson<{ conversations: ConversationSummary[] }>(`/conversations${query}`); + const params = new URLSearchParams(); + if (showArchived) params.set("archived", "true"); + if (search) params.set("q", search); + const qs = params.toString(); + const data = await apiJson<{ conversations: ConversationSummary[] }>( + `/conversations${qs ? `?${qs}` : ""}`, + ); setConversations(data.conversations); setSelectedIds(new Set()); } catch { @@ -93,8 +117,8 @@ export function ChatHistorySidebar({ }, [showArchived]); useEffect(() => { - void fetchConversations(); - }, [fetchConversations, refreshKey]); + void fetchConversations(isSearching ? debouncedSearch : ""); + }, [fetchConversations, refreshKey, debouncedSearch, isSearching]); // Focus rename input when editing useEffect(() => { @@ -216,12 +240,37 @@ export function ChatHistorySidebar({ return title.replace(/^t[ií]tulo:\s*/i, "").trim() || "Nova conversa"; } + async function handleExportConversation( + conv: ConversationSummary, + format: "json" | "md", + ) { + try { + const data = await apiJson<{ + conversation: ConversationSummary; + messages: Array<{ content: string; createdAt: string; id: string; role: string }>; + }>(`/conversations/${conv.id}/messages`); + + const filename = buildExportFilename(getConversationTitle(conv.title), format); + if (format === "json") { + downloadTextFile(filename, "application/json", conversationToJson(data.conversation, data.messages)); + toast.success("Conversa exportada como JSON."); + } else { + downloadTextFile(filename, "text/markdown", conversationToMarkdown(data.conversation, data.messages)); + toast.success("Conversa exportada como Markdown."); + } + } catch { + toast.error("Falha ao exportar a conversa."); + } + } + // Filter + group conversations const filteredConversations = useMemo(() => { + // Busca ativa: o servidor já filtrou por título E conteúdo das mensagens. + if (isSearching) return conversations; if (!searchQuery.trim()) return conversations; const q = searchQuery.toLowerCase(); return conversations.filter((c) => (c.title ?? "").toLowerCase().includes(q)); - }, [conversations, searchQuery]); + }, [conversations, searchQuery, isSearching]); const selectedCount = selectedIds.size; const allVisibleSelected = filteredConversations.length > 0 && filteredConversations.every((c) => selectedIds.has(c.id)); @@ -301,6 +350,13 @@ export function ChatHistorySidebar({ className="h-9 pl-7 text-base md:h-7 md:text-xs" /> + {isSearching && ( +

+ {loading + ? "Buscando..." + : `${filteredConversations.length} conversa${filteredConversations.length === 1 ? "" : "s"} encontrada${filteredConversations.length === 1 ? "" : "s"} — título e conteúdo das mensagens`} +

+ )} {selectedCount > 0 && filteredConversations.length > 0 && ( @@ -452,6 +508,27 @@ export function ChatHistorySidebar({ {renamingId !== conv.id && (
+ + + + + e.stopPropagation()}> + void handleExportConversation(conv, "json")}> + Exportar (JSON) + + void handleExportConversation(conv, "md")}> + Exportar (Markdown) + + +