diff --git a/pipes/search/src/app/globals.css b/pipes/search/src/app/globals.css index 459287407e..995184a01d 100644 --- a/pipes/search/src/app/globals.css +++ b/pipes/search/src/app/globals.css @@ -33,6 +33,14 @@ body { --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { --background: 0 0% 3.9%; @@ -59,6 +67,14 @@ body { --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } @@ -81,3 +97,12 @@ body { transform: translate(-50%, -50%) !important; } +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + diff --git a/pipes/search/src/app/layout.tsx b/pipes/search/src/app/layout.tsx index adb6ff7fa4..770f39f8d0 100644 --- a/pipes/search/src/app/layout.tsx +++ b/pipes/search/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/toaster"; +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" +import { HistorySidebar } from "@/components/history-sidebar" const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,11 +29,22 @@ export default function RootLayout({ return ( - {children} - - + +
+
+ +
+
+ +
+
+ {children} +
+
+
+ ); diff --git a/pipes/search/src/app/page.tsx b/pipes/search/src/app/page.tsx index a3c457cd01..ca053ed8a4 100644 --- a/pipes/search/src/app/page.tsx +++ b/pipes/search/src/app/page.tsx @@ -4,26 +4,27 @@ import { useSettings } from "@/lib/hooks/use-settings"; import { Terminal } from "lucide-react"; import { SearchChat } from "@/components/search-chat"; - export default function SearchPage() { const { settings } = useSettings(); const aiDisabled = settings.aiProviderType === "screenpipe-cloud" && !settings.user.token; return ( -
- {aiDisabled &&( - - - heads up! - - your ai provider is set to 'screenpipe-cloud' and you don't have logged in
- please login to use this pipe, go to app > settings > login -
-
- )} -

where pixels become magic

- -
+ <> +
+ {aiDisabled && ( + + + heads up! + + your ai provider is set to 'screenpipe-cloud' and you don't have logged in
+ please login to use this pipe, go to app > settings > login +
+
+ )} +

where pixels become magic

+ +
+ ); } diff --git a/pipes/search/src/components/history-sidebar.tsx b/pipes/search/src/components/history-sidebar.tsx new file mode 100644 index 0000000000..a7c512a75f --- /dev/null +++ b/pipes/search/src/components/history-sidebar.tsx @@ -0,0 +1,148 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarGroupAction, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarHeader, +} from "@/components/ui/sidebar"; +import { SearchForm } from "@/components/search-form"; +import { listHistory, HistoryItem, deleteHistoryItem } from "@/hooks/actions/history"; + + +export function HistorySidebar() { + const [todayItems, setTodayItems] = useState([]); + const [yesterdayItems, setYesterdayItems] = useState([]); + const [previous7DaysItems, setPrevious7DaysItems] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + useEffect(() => { + const handleHistoryUpdate = () => { + fetchHistory(); + }; + window.addEventListener("historyCreated", handleHistoryUpdate); + return () => { + window.removeEventListener("historyCreated", handleHistoryUpdate); + }; + }, []); + const fetchHistory = async () => { + const history: HistoryItem[] = await listHistory(); + history.sort((a: HistoryItem, b: HistoryItem) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(today.getDate() - 7); + + const todayItems: HistoryItem[] = []; + const yesterdayItems: HistoryItem[] = []; + const previous7DaysItems: HistoryItem[] = []; + + history.forEach((item: HistoryItem) => { + const itemDate = new Date(item.timestamp); + if (itemDate.toDateString() === today.toDateString()) { + todayItems.push(item); + } else if (itemDate.toDateString() === yesterday.toDateString()) { + yesterdayItems.push(item); + } else if (itemDate >= sevenDaysAgo && itemDate < today) { + previous7DaysItems.push(item); + } + }); + + setTodayItems(todayItems); + setYesterdayItems(yesterdayItems); + setPrevious7DaysItems(previous7DaysItems); + }; + + useEffect(() => { + fetchHistory(); + }, []); + + const handleDeleteHistory = async (id: string) => { + await deleteHistoryItem(id); + fetchHistory(); + }; + const handleHistoryClick = (id: string) => { + localStorage.setItem("historyId", id); + window.dispatchEvent(new Event("historyUpdated")); + }; + + const handleNewChat = () => { + localStorage.removeItem('historyId'); + location.reload(); + }; + const handleSearchChange = (event: React.FormEvent) => { + const target = event.target as HTMLInputElement; + setSearchQuery(target.value); + }; + const filterItems = (items: HistoryItem[]) => { + return items.filter(item => item.title.toLowerCase().includes(searchQuery.toLowerCase())); + }; + + const renderHistoryItems = (items: HistoryItem[]) => ( + filterItems(items).map(item => ( + + + + + + )) + ); + + return ( + + +
+ +
+
+ + + Today + + New Chat + + + + {renderHistoryItems(todayItems)} + + + {yesterdayItems.length > 0 && ( + <> + Yesterday + + + {renderHistoryItems(yesterdayItems)} + + + + )} + {previous7DaysItems.length > 0 && ( + <> + < SidebarGroupLabel > Previous 7 days + + + {renderHistoryItems(previous7DaysItems)} + + + + )} + + +
+ ); +} diff --git a/pipes/search/src/components/search-chat.tsx b/pipes/search/src/components/search-chat.tsx index 2c17c49d99..c16cb78f2c 100644 --- a/pipes/search/src/components/search-chat.tsx +++ b/pipes/search/src/components/search-chat.tsx @@ -75,7 +75,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { IconCode } from "@/components/ui/icons"; import { CodeBlock } from "@/components/ui/codeblock"; import { SqlAutocompleteInput } from "@/components/sql-autocomplete-input"; -import { cn, removeDuplicateSelections } from "@/lib/utils"; +import { cn, removeDuplicateSelections, generateTitle } from "@/lib/utils"; import { ExampleSearch, ExampleSearchCards, @@ -102,6 +102,8 @@ import { } from "@/components/ui/popover"; import { useSettings } from "@/lib/hooks/use-settings"; import { SearchFilterGenerator } from "./search-filter-generator"; +import { saveHistory, loadHistory, HistoryItem } from "@/hooks/actions/history"; +import { v4 as uuidv4 } from 'uuid'; interface Agent { id: string; @@ -327,10 +329,10 @@ export function SearchChat() { platform.includes("mac") ? "macos" : platform.includes("win") - ? "windows" - : platform.includes("linux") - ? "linux" - : "unknown" + ? "windows" + : platform.includes("linux") + ? "linux" + : "unknown" ); } else { // Fallback to platform for older browsers @@ -339,10 +341,10 @@ export function SearchChat() { platform.includes("mac") ? "macos" : platform.includes("win") - ? "windows" - : platform.includes("linux") - ? "linux" - : "unknown" + ? "windows" + : platform.includes("linux") + ? "linux" + : "unknown" ); } }, []); @@ -641,14 +643,66 @@ export function SearchChat() { item.type === "OCR" ? item.content.text.length : item.type === "Audio" - ? item.content.transcription.length - : item.type === "UI" - ? item.content.text.length - : 0; + ? item.content.transcription.length + : item.type === "UI" + ? item.content.text.length + : 0; return total + contentLength; }, 0); }; + // Function to load chat history from local storage + const loadChatHistory = async () => { + const historyId = localStorage.getItem("historyId"); + if (historyId) { + const history = await loadHistory(historyId); + const historyItem = history[0]; + if (historyItem) { + // Restore search parameters + setQuery(historyItem.searchParams.q || ""); + setContentType(historyItem.searchParams.content_type); + setLimit(historyItem.searchParams.limit); + setStartDate(new Date(historyItem.searchParams.start_time)); + setEndDate(new Date(historyItem.searchParams.end_time)); + setAppName(historyItem.searchParams.app_name || ""); + setWindowName(historyItem.searchParams.window_name || ""); + setIncludeFrames(historyItem.searchParams.include_frames); + setMinLength(historyItem.searchParams.min_length); + setMaxLength(historyItem.searchParams.max_length); + + // Restore results + setResults(historyItem.results); + setTotalResults(historyItem.results.length); + setHasSearched(true); + setShowExamples(false); + + // Restore messages if any + if (historyItem.messages) { + setChatMessages( + historyItem.messages.map((msg) => ({ + id: msg.id, + role: msg.type === "ai" ? "assistant" : "user", + content: msg.content, + })) + ); + } + } + scrollToBottom(); + } + }; + + useEffect(() => { + const handleChatUpdate = () => { + loadChatHistory(); + }; + window.addEventListener("historyUpdated", handleChatUpdate); + // Load chat history when the component mounts + loadChatHistory(); + return () => { + window.removeEventListener("historyUpdated", handleChatUpdate); + }; + }, []); + const handleFloatingInputSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!floatingInput.trim() && !isStreaming) return; @@ -700,9 +754,8 @@ export function SearchChat() { const messages = [ { role: "user" as const, // claude does not support system messages? - content: `You are a helpful assistant specialized as a "${ - selectedAgent.name - }". ${selectedAgent.systemPrompt} + content: `You are a helpful assistant specialized as a "${selectedAgent.name + }". ${selectedAgent.systemPrompt} Rules: - Current time (JavaScript Date.prototype.toString): ${new Date().toString()} - User timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone} @@ -764,6 +817,78 @@ export function SearchChat() { ]); scrollToBottom(); } + // Save history after the response is fully received + const historyId = localStorage.getItem("historyId"); + let historyItem: HistoryItem; + + if (historyId) { + const history = await loadHistory(historyId); + historyItem = history[0] || { + id: historyId, + title: await generateTitle(floatingInput, settings), + query: floatingInput, + timestamp: new Date().toISOString(), + searchParams: { + q: query, + content_type: contentType, + limit: limit, + offset: offset, + start_time: startDate.toISOString(), + end_time: endDate.toISOString(), + app_name: appName, + window_name: windowName, + include_frames: includeFrames, + min_length: minLength, + max_length: maxLength, + }, + results: results, + messages: [], + }; + } else { + historyItem = { + id: uuidv4(), + title: floatingInput, + query: floatingInput, + timestamp: new Date().toISOString(), + searchParams: { + q: query, + content_type: contentType, + limit: limit, + offset: offset, + start_time: startDate.toISOString(), + end_time: endDate.toISOString(), + app_name: appName, + window_name: windowName, + include_frames: includeFrames, + min_length: minLength, + max_length: maxLength, + }, + results: results, + messages: [], + }; + localStorage.setItem("historyId", historyItem.id); + + } + + // Add human message to history + historyItem.messages.push({ + id: generateId(), + type: "user", + content: floatingInput, + timestamp: new Date().toISOString(), + }); + + // Add AI message to history + historyItem.messages.push({ + id: generateId(), + type: "ai", + content: fullResponse, + timestamp: new Date().toISOString(), + }); + + await saveHistory([historyItem]); + window.dispatchEvent(new Event("historyCreated")); + } catch (error: any) { if (error.toString().includes("unauthorized")) { toast({ @@ -1071,7 +1196,7 @@ export function SearchChat() {

{item.content.filePath && - item.content.filePath.trim() !== "" ? ( + item.content.filePath.trim() !== "" ? (
-
- {/* */} - -
{/* Add the new SearchFilterGenerator component */} @@ -1675,8 +1789,8 @@ export function SearchChat() { > {Object.values(selectedSpeakers).length > 0 ? `${Object.values(selectedSpeakers) - .map((s) => s.name) - .join(", ")}` + .map((s) => s.name) + .join(", ")}` : "select speakers"} @@ -1933,7 +2047,7 @@ export function SearchChat() { value={floatingInput} disabled={ calculateSelectedContentLength() > - MAX_CONTENT_LENGTH || + MAX_CONTENT_LENGTH || isAiDisabled || !isAvailable } diff --git a/pipes/search/src/components/search-form.tsx b/pipes/search/src/components/search-form.tsx new file mode 100644 index 0000000000..2ea3920f06 --- /dev/null +++ b/pipes/search/src/components/search-form.tsx @@ -0,0 +1,29 @@ +import { Search } from "lucide-react" + +import { Label } from "@/components/ui/label" +import { + SidebarGroup, + SidebarGroupContent, + SidebarInput, +} from "@/components/ui/sidebar" + +export function SearchForm({ ...props }: React.ComponentProps<"form">) { + return ( +
+ + + + + + + +
+ ) +} + diff --git a/pipes/search/src/components/ui/sidebar.tsx b/pipes/search/src/components/ui/sidebar.tsx new file mode 100644 index 0000000000..2a24ae6c26 --- /dev/null +++ b/pipes/search/src/components/ui/sidebar.tsx @@ -0,0 +1,763 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Sheet, SheetContent } from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContext = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +