diff --git a/apps/webapp/app/components/command-bar/add-memory-command.tsx b/apps/webapp/app/components/command-bar/add-memory-command.tsx new file mode 100644 index 0000000..b8fa9c2 --- /dev/null +++ b/apps/webapp/app/components/command-bar/add-memory-command.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { FileText, Plus } from "lucide-react"; +import { + CommandDialog, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "../ui/command"; +import { AddMemoryDialog } from "./memory-dialog.client"; +import { AddDocumentDialog } from "./document-dialog"; + +interface AddMemoryCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddMemoryCommand({ + open, + onOpenChange, +}: AddMemoryCommandProps) { + const [showAddMemory, setShowAddMemory] = useState(false); + const [showAddDocument, setShowAddDocument] = useState(false); + + const handleAddMemory = () => { + onOpenChange(false); + setShowAddMemory(true); + }; + + const handleAddDocument = () => { + onOpenChange(false); + setShowAddDocument(true); + }; + + return ( + <> + {/* Main Command Dialog */} + + + + + + + Add Memory + + + + Add Document + + + + + + {showAddMemory && ( + + )} + + {/* Add Document Dialog */} + + + ); +} diff --git a/apps/webapp/app/components/command-bar/document-dialog.tsx b/apps/webapp/app/components/command-bar/document-dialog.tsx new file mode 100644 index 0000000..b766f07 --- /dev/null +++ b/apps/webapp/app/components/command-bar/document-dialog.tsx @@ -0,0 +1,27 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; + +interface AddDocumentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddDocumentDialog({ + open, + onOpenChange, +}: AddDocumentDialogProps) { + return ( + + + + Add Document + + {/* TODO: Add document content here */} +
+

+ Document upload content goes here... +

+
+
+
+ ); +} diff --git a/apps/webapp/app/components/command-bar/memory-dialog.client.tsx b/apps/webapp/app/components/command-bar/memory-dialog.client.tsx new file mode 100644 index 0000000..91ec4bd --- /dev/null +++ b/apps/webapp/app/components/command-bar/memory-dialog.client.tsx @@ -0,0 +1,95 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { + extensionsForConversation, + getPlaceholder, +} from "../conversation/editor-extensions"; +import { Button } from "../ui/button"; +import { SpaceDropdown } from "../spaces/space-dropdown"; +import React from "react"; +import { useFetcher } from "@remix-run/react"; + +interface AddMemoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultSpaceId?: string; +} + +export function AddMemoryDialog({ + open, + onOpenChange, + defaultSpaceId, +}: AddMemoryDialogProps) { + const [spaceIds, setSpaceIds] = React.useState( + defaultSpaceId ? [defaultSpaceId] : [], + ); + const fetcher = useFetcher(); + const editor = useEditor({ + extensions: [ + ...extensionsForConversation, + getPlaceholder("Write your memory here..."), + ], + editorProps: { + attributes: { + class: + "prose prose-sm focus:outline-none max-w-full min-h-[200px] p-4 py-0", + }, + }, + }); + + const handleAdd = async () => { + const content = editor?.getText(); + if (!content?.trim()) return; + + const payload = { + episodeBody: content, + referenceTime: new Date().toISOString(), + spaceIds: spaceIds, + source: "core", + }; + + fetcher.submit(payload, { + method: "POST", + action: "/api/v1/add", + encType: "application/json", + }); + + // Clear editor and close dialog + editor?.commands.clearContent(); + setSpaceIds([]); + onOpenChange(false); + }; + + return ( + + +
+ +
+
+
+ { + setSpaceIds(spaceIds); + }} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/conversation/editor-extensions.tsx b/apps/webapp/app/components/conversation/editor-extensions.tsx index 2041de6..19493b8 100644 --- a/apps/webapp/app/components/conversation/editor-extensions.tsx +++ b/apps/webapp/app/components/conversation/editor-extensions.tsx @@ -9,6 +9,7 @@ import TableHeader from "@tiptap/extension-table-header"; import TableRow from "@tiptap/extension-table-row"; import { all, createLowlight } from "lowlight"; import { mergeAttributes, type Extension } from "@tiptap/react"; +import { Markdown } from "tiptap-markdown"; // create a lowlight instance with all languages loaded export const lowlight = createLowlight(all); @@ -136,4 +137,5 @@ export const extensionsForConversation = [ CodeBlockLowlight.configure({ lowlight, }), + Markdown, ]; diff --git a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx index 5ef586a..8fb52df 100644 --- a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx +++ b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx @@ -83,10 +83,14 @@ export const GraphClusteringVisualization = forwardRef< filtered = filtered.filter((triplet) => { const sourceMatches = isEpisodeNode(triplet.sourceNode) && - triplet.sourceNode.attributes?.content?.toLowerCase().includes(query); + triplet.sourceNode.attributes?.content + ?.toLowerCase() + .includes(query); const targetMatches = isEpisodeNode(triplet.targetNode) && - triplet.targetNode.attributes?.content?.toLowerCase().includes(query); + triplet.targetNode.attributes?.content + ?.toLowerCase() + .includes(query); return sourceMatches || targetMatches; }); diff --git a/apps/webapp/app/components/logs/log-details.tsx b/apps/webapp/app/components/logs/log-details.tsx index ff50b57..bc78f40 100644 --- a/apps/webapp/app/components/logs/log-details.tsx +++ b/apps/webapp/app/components/logs/log-details.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, type ReactNode } from "react"; import { useFetcher } from "@remix-run/react"; -import { AlertCircle, Loader2 } from "lucide-react"; +import { AlertCircle, File, Loader2, MessageSquare } from "lucide-react"; import { Badge, BadgeColor } from "../ui/badge"; import { type LogItem } from "~/hooks/use-logs"; import Markdown from "react-markdown"; @@ -8,6 +8,7 @@ import { getIconForAuthorise } from "../icon-utils"; import { cn, formatString } from "~/lib/utils"; import { getStatusColor } from "./utils"; import { format } from "date-fns"; +import { SpaceDropdown } from "../spaces/space-dropdown"; interface LogDetailsProps { log: LogItem; @@ -33,13 +34,13 @@ function PropertyItem({ if (!value) return null; return ( -
- {label} +
+ {label} {variant === "status" ? ( @@ -49,7 +50,13 @@ function PropertyItem({ {value} ) : ( - + {icon} {value} @@ -73,10 +80,10 @@ interface EpisodeFactsResponse { function getStatusValue(status: string) { if (status === "PENDING") { - return "In Queue"; + return formatString("IN QUEUE"); } - return status; + return formatString(status); } export function LogDetails({ log }: LogDetailsProps) { @@ -113,6 +120,9 @@ export function LogDetails({ log }: LogDetailsProps) { } else if (log.episodeUUID) { setFactsLoading(true); fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`); + } else { + setFacts([]); + setInvalidFacts([]); } }, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]); @@ -129,41 +139,8 @@ export function LogDetails({ log }: LogDetailsProps) { return (
-
-
- Episode Details -
-
- -
+
- {log.data?.type === "DOCUMENT" && log.data?.episodes ? ( - - {log.data.episodes.map( - (episodeId: string, index: number) => ( - - {episodeId} - - ), - )} -
- } - variant="secondary" - /> - ) : ( - - )} + ) : ( + + ) + } variant="secondary" /> + + {/* Space Assignment for CONVERSATION type */} + {log.data.type.toLowerCase() === "conversation" && + log?.episodeUUID && ( +
+ + Spaces + + + +
+ )}
{/* Error Details */} {log.error && (
-
- Error Details -
@@ -212,21 +209,63 @@ export function LogDetails({ log }: LogDetailsProps) {
)} -
-
- Content -
- {/* Log Content */} -
-
- {log.ingestText} + {log.data?.type === "CONVERSATION" && ( +
+ {/* Log Content */} +
+
+ {log.ingestText} +
-
+ )} + + {/* Episodes List for DOCUMENT type */} + {log.data?.type === "DOCUMENT" && log.episodeDetails?.length > 0 && ( +
+
+ Episodes ({log.episodeDetails.length}) +
+
+ {log.episodeDetails.map((episode: any, index: number) => ( +
+
+
+ + Episode {index + 1} + + + {episode.uuid} + +
+
+ +
+
+ {/* Episode Content */} +
+
+ Content +
+
+ {episode.content} +
+
+
+ ))} +
+
+ )} {/* Episode Facts */}
-
+
Facts
diff --git a/apps/webapp/app/components/logs/log-options.tsx b/apps/webapp/app/components/logs/log-options.tsx index f5d9b2f..8a3b132 100644 --- a/apps/webapp/app/components/logs/log-options.tsx +++ b/apps/webapp/app/components/logs/log-options.tsx @@ -41,7 +41,6 @@ export const LogOptions = ({ id }: LogOptionsProps) => { }; useEffect(() => { - console.log(deleteFetcher.state, deleteFetcher.data); if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) { navigate(`/home/inbox`); } diff --git a/apps/webapp/app/components/logs/log-text-collapse.tsx b/apps/webapp/app/components/logs/log-text-collapse.tsx index 0bc0139..6c7c61f 100644 --- a/apps/webapp/app/components/logs/log-text-collapse.tsx +++ b/apps/webapp/app/components/logs/log-text-collapse.tsx @@ -4,6 +4,7 @@ import { type LogItem } from "~/hooks/use-logs"; import { getIconForAuthorise } from "../icon-utils"; import { useNavigate, useParams } from "@remix-run/react"; import { getStatusColor, getStatusValue } from "./utils"; +import { File, MessageSquare } from "lucide-react"; interface LogTextCollapseProps { text?: string; @@ -49,9 +50,13 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) { }; const getIngestType = (log: LogItem) => { - const type = log.type ?? log.data.type ?? "Conversation"; + const type = log.type ?? log.data.type ?? "CONVERSATION"; - return type[0].toUpperCase(); + return type === "CONVERSATION" ? ( + + ) : ( + + ); }; return ( @@ -100,7 +105,7 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
{getIngestType(log)} diff --git a/apps/webapp/app/components/logs/utils.ts b/apps/webapp/app/components/logs/utils.ts index 71b6b8d..40df970 100644 --- a/apps/webapp/app/components/logs/utils.ts +++ b/apps/webapp/app/components/logs/utils.ts @@ -22,5 +22,5 @@ export function getStatusValue(status: string) { return formatString("In Queue"); } - return status; + return formatString(status); } diff --git a/apps/webapp/app/components/logs/virtual-logs-list.tsx b/apps/webapp/app/components/logs/virtual-logs-list.tsx index 4fde4eb..70544cc 100644 --- a/apps/webapp/app/components/logs/virtual-logs-list.tsx +++ b/apps/webapp/app/components/logs/virtual-logs-list.tsx @@ -10,6 +10,7 @@ import { import { type LogItem } from "~/hooks/use-logs"; import { ScrollManagedList } from "../virtualized-list"; import { LogTextCollapse } from "./log-text-collapse"; +import { LoaderCircle } from "lucide-react"; interface VirtualLogsListProps { logs: LogItem[]; @@ -139,7 +140,7 @@ export function VirtualLogsList({ {isLoading && (
- Loading more logs... +
)}
diff --git a/apps/webapp/app/components/onboarding/onboarding-question.tsx b/apps/webapp/app/components/onboarding/onboarding-question.tsx index 85c35a6..ccfac4e 100644 --- a/apps/webapp/app/components/onboarding/onboarding-question.tsx +++ b/apps/webapp/app/components/onboarding/onboarding-question.tsx @@ -139,6 +139,7 @@ export default function OnboardingQuestionComponent({ variant="ghost" size="xl" onClick={onPrevious} + disabled={loading} className="rounded-lg px-4 py-2" > Previous @@ -151,7 +152,7 @@ export default function OnboardingQuestionComponent({ size="xl" onClick={onNext} isLoading={!!loading} - disabled={!isValid()} + disabled={!isValid() || loading} className="rounded-lg px-4 py-2" > {isLast ? "Complete Profile" : "Continue"} diff --git a/apps/webapp/app/components/sidebar/app-sidebar.tsx b/apps/webapp/app/components/sidebar/app-sidebar.tsx index 8226609..c1b6ae3 100644 --- a/apps/webapp/app/components/sidebar/app-sidebar.tsx +++ b/apps/webapp/app/components/sidebar/app-sidebar.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { Sidebar, @@ -12,14 +13,20 @@ import { Columns3, Inbox, LayoutGrid, + LoaderCircle, MessageSquare, Network, + Plus, } from "lucide-react"; import { NavMain } from "./nav-main"; import { useUser } from "~/hooks/useUser"; import { NavUser } from "./nav-user"; import Logo from "../logo/logo"; import { ConversationList } from "../conversation"; +import { Button } from "../ui"; +import { Project } from "../icons/project"; +import { AddMemoryCommand } from "../command-bar/add-memory-command"; +import { AddMemoryDialog } from "../command-bar/memory-dialog.client"; const data = { navMain: [ @@ -41,7 +48,7 @@ const data = { { title: "Spaces", url: "/home/space", - icon: Columns3, + icon: Project, }, { title: "Integrations", @@ -54,33 +61,57 @@ const data = { export function AppSidebar({ ...props }: React.ComponentProps) { const user = useUser(); - return ( - - - - -
- - C.O.R.E. -
-
-
-
- - -
-

History

- -
-
+ const [showAddMemory, setShowAddMemory] = React.useState(false); - - - -
+ // Open command bar with Meta+K (Cmd+K on Mac, Ctrl+K on Windows/Linux) + useHotkeys("meta+k", (e) => { + e.preventDefault(); + setShowAddMemory(true); + }); + + return ( + <> + + + + +
+ + C.O.R.E. +
+ + +
+
+
+ + +
+

History

+ +
+
+ + + + +
+ + {showAddMemory && ( + + )} + ); } diff --git a/apps/webapp/app/components/sidebar/nav-user.tsx b/apps/webapp/app/components/sidebar/nav-user.tsx index cb162f9..a346003 100644 --- a/apps/webapp/app/components/sidebar/nav-user.tsx +++ b/apps/webapp/app/components/sidebar/nav-user.tsx @@ -67,6 +67,15 @@ export function NavUser({ user }: { user: ExtendedUser }) { + + ); diff --git a/apps/webapp/app/components/spaces/space-card.tsx b/apps/webapp/app/components/spaces/space-card.tsx index 3753bb8..4882e32 100644 --- a/apps/webapp/app/components/spaces/space-card.tsx +++ b/apps/webapp/app/components/spaces/space-card.tsx @@ -17,8 +17,8 @@ interface SpaceCardProps { createdAt: string; updatedAt: string; autoMode: boolean; - statementCount: number | null; summary: string | null; + contextCount?: number | null; themes?: string[]; }; } @@ -46,13 +46,17 @@ export function SpaceCard({ space }: SpaceCardProps) {
{space.name} - {space.description || space.summary || "Knowledge space"} +

- {space.statementCount && space.statementCount > 0 && ( + {space.contextCount && space.contextCount > 0 && (
- {space.statementCount} fact - {space.statementCount !== 1 ? "s" : ""} + {space.contextCount} episode + {space.contextCount !== 1 ? "s" : ""}
)}
diff --git a/apps/webapp/app/components/spaces/space-dropdown.tsx b/apps/webapp/app/components/spaces/space-dropdown.tsx new file mode 100644 index 0000000..99f06f8 --- /dev/null +++ b/apps/webapp/app/components/spaces/space-dropdown.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect } from "react"; +import { Check, Plus, X } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverPortal, + PopoverTrigger, +} from "~/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "~/components/ui/command"; +import { Badge } from "~/components/ui/badge"; +import { cn } from "~/lib/utils"; +import { useFetcher } from "@remix-run/react"; +import { Project } from "../icons/project"; + +interface Space { + id: string; + name: string; + description?: string; +} + +interface SpaceDropdownProps { + episodeIds: string[]; + selectedSpaceIds?: string[]; + onSpaceChange?: (spaceIds: string[]) => void; + className?: string; +} + +export function SpaceDropdown({ + episodeIds, + selectedSpaceIds = [], + onSpaceChange, + className, +}: SpaceDropdownProps) { + const [open, setOpen] = useState(false); + const [selectedSpaces, setSelectedSpaces] = + useState(selectedSpaceIds); + const [spaces, setSpaces] = useState([]); + const spacesFetcher = useFetcher<{ spaces: Space[] }>(); + const assignFetcher = useFetcher(); + + // Fetch all spaces + useEffect(() => { + spacesFetcher.load("/api/v1/spaces"); + }, []); + + // Update spaces when data is fetched + useEffect(() => { + if (spacesFetcher.data?.spaces) { + setSpaces(spacesFetcher.data.spaces); + } + }, [spacesFetcher.data]); + + const handleSpaceToggle = (spaceId: string) => { + const newSelectedSpaces = selectedSpaces.includes(spaceId) + ? selectedSpaces.filter((id) => id !== spaceId) + : [...selectedSpaces, spaceId]; + + setSelectedSpaces(newSelectedSpaces); + if (episodeIds) { + assignFetcher.submit( + { + episodeIds: JSON.stringify(episodeIds), + spaceId, + action: selectedSpaces.includes(spaceId) ? "remove" : "assign", + }, + { + method: "post", + action: "/api/v1/episodes/assign-space", + encType: "application/json", + }, + ); + } + + // Call the callback if provided + if (onSpaceChange) { + onSpaceChange(newSelectedSpaces); + } + }; + + const selectedSpaceObjects = spaces.filter((space) => + selectedSpaces.includes(space.id), + ); + + const getTrigger = () => { + if (selectedSpaceObjects?.length === 1) { + return ( + <> + {selectedSpaceObjects[0].name} + + ); + } + + if (selectedSpaceObjects?.length > 1) { + return ( + <> + {selectedSpaceObjects.length} Spaces + + ); + } + + return ( + <> + {" "} + + Spaces + + ); + }; + + return ( +
+ {/* + button to add more spaces */} + + + + + + + + + + No spaces found. + + {spaces.map((space) => ( + handleSpaceToggle(space.id)} + > + +
+ {space.name} +
+
+ ))} +
+
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/spaces/space-fact-card.tsx b/apps/webapp/app/components/spaces/space-episode-card.tsx similarity index 61% rename from apps/webapp/app/components/spaces/space-fact-card.tsx rename to apps/webapp/app/components/spaces/space-episode-card.tsx index bb003df..5a0bfbf 100644 --- a/apps/webapp/app/components/spaces/space-fact-card.tsx +++ b/apps/webapp/app/components/spaces/space-episode-card.tsx @@ -2,12 +2,27 @@ import { Calendar } from "lucide-react"; import { Badge } from "~/components/ui/badge"; import type { StatementNode } from "@core/types"; import { cn } from "~/lib/utils"; +import { useNavigate } from "@remix-run/react"; +import Markdown from "react-markdown"; -interface SpaceFactCardProps { - fact: StatementNode; +export interface Episode { + uuid: string; + content: string; + originalContent: string; + source: any; + createdAt: Date; + validAt: Date; + metadata: any; + sessionId: any; + logId?: any; } -export function SpaceFactCard({ fact }: SpaceFactCardProps) { +interface SpaceFactCardProps { + episode: Episode; +} + +export function SpaceEpisodeCard({ episode }: SpaceFactCardProps) { + const navigate = useNavigate(); const formatDate = (date: Date | string) => { const d = new Date(date); return d.toLocaleDateString("en-US", { @@ -17,18 +32,20 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) { }); }; - const displayText = fact.fact; + const displayText = episode.originalContent; - const recallCount = - (fact.recallCount?.high ?? 0) + (fact.recallCount?.low ?? 0); + const onClick = () => { + navigate(`/home/inbox/${episode.logId}`); + }; return ( <> -
+
-
{displayText}
+ {displayText}
- {!!recallCount && Recalled: {recallCount} times} - {formatDate(fact.validAt)} + {formatDate(episode.validAt)} - {fact.invalidAt && ( - - Invalid since {formatDate(fact.invalidAt)} - - )}
diff --git a/apps/webapp/app/components/spaces/space-facts-filters.tsx b/apps/webapp/app/components/spaces/space-episode-filters.tsx similarity index 61% rename from apps/webapp/app/components/spaces/space-facts-filters.tsx rename to apps/webapp/app/components/spaces/space-episode-filters.tsx index 584e244..325d8d8 100644 --- a/apps/webapp/app/components/spaces/space-facts-filters.tsx +++ b/apps/webapp/app/components/spaces/space-episode-filters.tsx @@ -9,7 +9,7 @@ import { } from "~/components/ui/popover"; import { Badge } from "~/components/ui/badge"; -interface SpaceFactsFiltersProps { +interface SpaceEpisodesFiltersProps { selectedValidDate?: string; selectedSpaceFilter?: string; onValidDateChange: (date?: string) => void; @@ -22,34 +22,24 @@ const validDateOptions = [ { value: "last_6_months", label: "Last 6 Months" }, ]; -const spaceFilterOptions = [ - { value: "active", label: "Active Facts" }, - { value: "archived", label: "Archived Facts" }, - { value: "all", label: "All Facts" }, -]; +type FilterStep = "main" | "validDate"; -type FilterStep = "main" | "validDate" | "spaceFilter"; - -export function SpaceFactsFilters({ +export function SpaceEpisodesFilters({ selectedValidDate, selectedSpaceFilter, onValidDateChange, - onSpaceFilterChange, -}: SpaceFactsFiltersProps) { +}: SpaceEpisodesFiltersProps) { const [popoverOpen, setPopoverOpen] = useState(false); const [step, setStep] = useState("main"); const selectedValidDateLabel = validDateOptions.find( (d) => d.value === selectedValidDate, )?.label; - const selectedSpaceFilterLabel = spaceFilterOptions.find( - (f) => f.value === selectedSpaceFilter, - )?.label; const hasFilters = selectedValidDate || selectedSpaceFilter; return ( -
+ <> { @@ -79,13 +69,6 @@ export function SpaceFactsFilters({ > Valid Date -
)} @@ -122,40 +105,6 @@ export function SpaceFactsFilters({ ))}
)} - - {step === "spaceFilter" && ( -
- - {spaceFilterOptions.map((option) => ( - - ))} -
- )} @@ -172,17 +121,8 @@ export function SpaceFactsFilters({ /> )} - {selectedSpaceFilter && ( - - {selectedSpaceFilterLabel} - onSpaceFilterChange(undefined)} - /> - - )}
)} -
+ ); } diff --git a/apps/webapp/app/components/spaces/space-facts-list.tsx b/apps/webapp/app/components/spaces/space-episodes-list.tsx similarity index 78% rename from apps/webapp/app/components/spaces/space-facts-list.tsx rename to apps/webapp/app/components/spaces/space-episodes-list.tsx index a3476cc..c26265f 100644 --- a/apps/webapp/app/components/spaces/space-facts-list.tsx +++ b/apps/webapp/app/components/spaces/space-episodes-list.tsx @@ -9,25 +9,24 @@ import { } from "react-virtualized"; import { Database } from "lucide-react"; import { Card, CardContent } from "~/components/ui/card"; -import type { StatementNode } from "@core/types"; import { ScrollManagedList } from "../virtualized-list"; -import { SpaceFactCard } from "./space-fact-card"; +import { type Episode, SpaceEpisodeCard } from "./space-episode-card"; -interface SpaceFactsListProps { - facts: any[]; +interface SpaceEpisodesListProps { + episodes: any[]; hasMore: boolean; loadMore: () => void; isLoading: boolean; height?: number; } -function FactItemRenderer( +function EpisodeItemRenderer( props: ListRowProps, - facts: StatementNode[], + episodes: Episode[], cache: CellMeasurerCache, ) { const { index, key, style, parent } = props; - const fact = facts[index]; + const episode = episodes[index]; return (
- +
); } -export function SpaceFactsList({ - facts, +export function SpaceEpisodesList({ + episodes, hasMore, loadMore, isLoading, -}: SpaceFactsListProps) { +}: SpaceEpisodesListProps) { // Create a CellMeasurerCache instance using useRef to prevent recreation const cacheRef = useRef(null); if (!cacheRef.current) { cacheRef.current = new CellMeasurerCache({ - defaultHeight: 200, // Default row height for fact cards + defaultHeight: 200, // Default row height for episode cards fixedWidth: true, // Rows have fixed width but dynamic height }); } @@ -62,17 +61,17 @@ export function SpaceFactsList({ useEffect(() => { cache.clearAll(); - }, [facts, cache]); + }, [episodes, cache]); - if (facts.length === 0 && !isLoading) { + if (episodes.length === 0 && !isLoading) { return (
-

No facts found

+

No Episodes found

- This space doesn't contain any facts yet. + This space doesn't contain any episodes yet.

@@ -81,7 +80,7 @@ export function SpaceFactsList({ } const isRowLoaded = ({ index }: { index: number }) => { - return !!facts[index]; + return !!episodes[index]; }; const loadMoreRows = async () => { @@ -92,14 +91,14 @@ export function SpaceFactsList({ }; const rowRenderer = (props: ListRowProps) => { - return FactItemRenderer(props, facts, cache); + return EpisodeItemRenderer(props, episodes, cache); }; const rowHeight = ({ index }: Index) => { return cache.getHeight(index, 0); }; - const itemCount = hasMore ? facts.length + 1 : facts.length; + const itemCount = hasMore ? episodes.length + 1 : episodes.length; return (
@@ -131,7 +130,7 @@ export function SpaceFactsList({ {isLoading && (
- Loading more facts... + Loading more episodes...
)}
diff --git a/apps/webapp/app/components/spaces/spaces-grid.tsx b/apps/webapp/app/components/spaces/spaces-grid.tsx index d615138..e2073d1 100644 --- a/apps/webapp/app/components/spaces/spaces-grid.tsx +++ b/apps/webapp/app/components/spaces/spaces-grid.tsx @@ -9,8 +9,8 @@ interface SpacesGridProps { createdAt: string; updatedAt: string; autoMode: boolean; - statementCount: number | null; summary: string | null; + contextCount?: number | null; themes?: string[]; }>; } diff --git a/apps/webapp/app/components/ui/command.tsx b/apps/webapp/app/components/ui/command.tsx index 307f965..c7da972 100644 --- a/apps/webapp/app/components/ui/command.tsx +++ b/apps/webapp/app/components/ui/command.tsx @@ -40,7 +40,7 @@ const CommandDialog = ({ {children} @@ -141,7 +141,7 @@ const CommandItem = React.forwardRef< { rel.predicate as predicateLabel, e.uuid as episodeUuid, e.content as episodeContent, + e.spaceIds as spaceIds, s.uuid as statementUuid, - s.spaceIds as spaceIds, - s.fact as fact, - s.invalidAt as invalidAt, s.validAt as validAt, s.createdAt as createdAt`, { userId }, @@ -169,13 +167,8 @@ export const getClusteredGraphData = async (userId: string) => { const predicateLabel = record.get("predicateLabel"); const episodeUuid = record.get("episodeUuid"); - const episodeContent = record.get("episodeContent"); - const statementUuid = record.get("statementUuid"); const clusterIds = record.get("spaceIds"); const clusterId = clusterIds ? clusterIds[0] : undefined; - const fact = record.get("fact"); - const invalidAt = record.get("invalidAt"); - const validAt = record.get("validAt"); const createdAt = record.get("createdAt"); // Create unique edge identifier to avoid duplicates diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index 962a1b0..f8073c2 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -2,7 +2,6 @@ import type { Prisma, User } from "@core/database"; import type { GoogleProfile } from "@coji/remix-auth-google"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { ensureBillingInitialized } from "~/services/billing.server"; export type { User } from "@core/database"; type FindOrCreateMagicLink = { @@ -167,7 +166,12 @@ export async function findOrCreateGoogleUser({ } export async function getUserById(id: User["id"]) { - const user = await prisma.user.findUnique({ where: { id } }); + const user = await prisma.user.findUnique({ + where: { id }, + include: { + Workspace: true, + }, + }); if (!user) { return null; diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 6abd6e1..f95384f 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -25,7 +25,7 @@ import { type ToastMessage, } from "./models/message.server"; import { env } from "./env.server"; -import { getUser, getUserRemainingCount } from "./services/session.server"; +import { getUser } from "./services/session.server"; import { usePostHog } from "./hooks/usePostHog"; import { AppContainer, @@ -40,6 +40,7 @@ import { useTheme, } from "remix-themes"; import clsx from "clsx"; +import { getUsageSummary } from "./services/billing.server"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; @@ -50,12 +51,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const posthogProjectKey = env.POSTHOG_PROJECT_KEY; const user = await getUser(request); - const usage = await getUserRemainingCount(request); + const usageSummary = await getUsageSummary(user?.Workspace?.id as string); return typedjson( { user: user, - availableCredits: usage?.availableCredits ?? 0, + availableCredits: usageSummary?.credits.available ?? 0, + totalCredits: usageSummary?.credits.monthly ?? 0, toastMessage, theme: getTheme(), posthogProjectKey, diff --git a/apps/webapp/app/routes/api.v1.episodes.assign-space.ts b/apps/webapp/app/routes/api.v1.episodes.assign-space.ts new file mode 100644 index 0000000..9d5e14b --- /dev/null +++ b/apps/webapp/app/routes/api.v1.episodes.assign-space.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { SpaceService } from "~/services/space.server"; +import { json } from "@remix-run/node"; + +const spaceService = new SpaceService(); + +// Schema for assigning episodes to space +const AssignEpisodesSchema = z.object({ + episodeIds: z.string().transform((val) => JSON.parse(val) as string[]), + spaceId: z.string(), + action: z.enum(["assign", "remove"]), +}); + +const { action } = createHybridActionApiRoute( + { + body: AssignEpisodesSchema, + allowJWT: true, + authorization: { + action: "manage", + }, + corsStrategy: "all", + }, + async ({ authentication, body }) => { + const userId = authentication.userId; + const { episodeIds, spaceId, action: actionType } = body; + + try { + if (actionType === "assign") { + await spaceService.assignEpisodesToSpace(episodeIds, spaceId, userId); + return json({ + success: true, + message: `Successfully assigned ${episodeIds.length} episode(s) to space`, + }); + } else if (actionType === "remove") { + await spaceService.removeEpisodesFromSpace(episodeIds, spaceId, userId); + return json({ + success: true, + message: `Successfully removed ${episodeIds.length} episode(s) from space`, + }); + } + + return json( + { + error: "Invalid action type", + success: false, + }, + { status: 400 }, + ); + } catch (error) { + console.error("Error managing episode space assignment:", error); + return json( + { + error: + error instanceof Error + ? error.message + : "Failed to manage episode space assignment", + success: false, + }, + { status: 500 }, + ); + } + }, +); + +export { action }; diff --git a/apps/webapp/app/routes/api.v1.logs.$logId.tsx b/apps/webapp/app/routes/api.v1.logs.$logId.tsx index 21bda8b..76ae432 100644 --- a/apps/webapp/app/routes/api.v1.logs.$logId.tsx +++ b/apps/webapp/app/routes/api.v1.logs.$logId.tsx @@ -24,8 +24,11 @@ const loader = createHybridLoaderApiRoute( corsStrategy: "all", allowJWT: true, }, - async ({ params }) => { - const formattedLog = await getIngestionQueueForFrontend(params.logId); + async ({ params, authentication }) => { + const formattedLog = await getIngestionQueueForFrontend( + params.logId, + authentication.userId, + ); return json({ log: formattedLog }); }, diff --git a/apps/webapp/app/routes/api.v1.spaces.$spaceId.statements.ts b/apps/webapp/app/routes/api.v1.spaces.$spaceId.statements.ts deleted file mode 100644 index 7f6caa9..0000000 --- a/apps/webapp/app/routes/api.v1.spaces.$spaceId.statements.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from "zod"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; -import { SpaceService } from "~/services/space.server"; -import { json } from "@remix-run/node"; - -const spaceService = new SpaceService(); - -// Schema for space ID parameter -const SpaceParamsSchema = z.object({ - spaceId: z.string(), -}); - -const { loader } = createActionApiRoute( - { - params: SpaceParamsSchema, - allowJWT: true, - authorization: { - action: "search", - }, - corsStrategy: "all", - }, - async ({ authentication, params }) => { - const userId = authentication.userId; - const { spaceId } = params; - - // Verify space exists and belongs to user - const space = await spaceService.getSpace(spaceId, userId); - if (!space) { - return json({ error: "Space not found" }, { status: 404 }); - } - - // Get statements in the space - const statements = await spaceService.getSpaceStatements(spaceId, userId); - - return json({ - deprecated: true, - deprecationMessage: "This endpoint is deprecated. Use /api/v1/spaces/{spaceId}/episodes instead. Spaces now work with episodes directly.", - newEndpoint: `/api/v1/spaces/${spaceId}/episodes`, - statements, - space: { - uuid: space.uuid, - name: space.name, - statementCount: statements.length - } - }); - } -); - -export { loader }; \ No newline at end of file diff --git a/apps/webapp/app/routes/api.v1.spaces.ts b/apps/webapp/app/routes/api.v1.spaces.ts index 7cac1a5..04ce23b 100644 --- a/apps/webapp/app/routes/api.v1.spaces.ts +++ b/apps/webapp/app/routes/api.v1.spaces.ts @@ -16,19 +16,6 @@ const CreateSpaceSchema = z.object({ description: z.string().optional(), }); -// Schema for bulk operations -const BulkOperationSchema = z.object({ - intent: z.enum([ - "assign_statements", - "remove_statements", - "bulk_assign", - "initialize_space_ids", - ]), - spaceId: z.string().optional(), - statementIds: z.array(z.string()).optional(), - spaceIds: z.array(z.string()).optional(), -}); - // Search query schema const SearchParamsSchema = z.object({ q: z.string().optional(), @@ -36,7 +23,7 @@ const SearchParamsSchema = z.object({ const { action } = createHybridActionApiRoute( { - body: z.union([CreateSpaceSchema, BulkOperationSchema]), + body: CreateSpaceSchema, allowJWT: true, authorization: { action: "manage", @@ -82,96 +69,6 @@ const { action } = createHybridActionApiRoute( return json({ space, success: true }); } - if (request.method === "PUT") { - // Bulk operations - if (!body || !("intent" in body)) { - return json({ error: "Intent is required" }, { status: 400 }); - } - - switch (body.intent) { - case "assign_statements": { - if (!body.spaceId || !body.statementIds) { - return json( - { error: "Space ID and statement IDs are required" }, - { status: 400 }, - ); - } - - const result = await spaceService.assignStatementsToSpace( - body.statementIds, - body.spaceId, - authentication.userId, - ); - - if (result.success) { - return json({ - success: true, - message: `Assigned ${result.statementsUpdated} statements to space`, - statementsUpdated: result.statementsUpdated, - }); - } else { - return json({ error: result.error }, { status: 400 }); - } - } - - case "remove_statements": { - if (!body.spaceId || !body.statementIds) { - return json( - { error: "Space ID and statement IDs are required" }, - { status: 400 }, - ); - } - - const result = await spaceService.removeStatementsFromSpace( - body.statementIds, - body.spaceId, - authentication.userId, - ); - - if (result.success) { - return json({ - success: true, - message: `Removed ${result.statementsUpdated} statements from space`, - statementsUpdated: result.statementsUpdated, - }); - } else { - return json({ error: result.error }, { status: 400 }); - } - } - - case "bulk_assign": { - if (!body.statementIds || !body.spaceIds) { - return json( - { error: "Statement IDs and space IDs are required" }, - { status: 400 }, - ); - } - - const results = await spaceService.bulkAssignStatements( - body.statementIds, - body.spaceIds, - authentication.userId, - ); - - return json({ results, success: true }); - } - - case "initialize_space_ids": { - const updatedCount = await spaceService.initializeSpaceIds( - authentication.userId, - ); - return json({ - success: true, - message: `Initialized spaceIds for ${updatedCount} statements`, - updatedCount, - }); - } - - default: - return json({ error: "Invalid intent" }, { status: 400 }); - } - } - return json({ error: "Method not allowed" }, { status: 405 }); }, ); diff --git a/apps/webapp/app/routes/home.inbox.$logId.tsx b/apps/webapp/app/routes/home.inbox.$logId.tsx index 4f9b13f..8e77bf5 100644 --- a/apps/webapp/app/routes/home.inbox.$logId.tsx +++ b/apps/webapp/app/routes/home.inbox.$logId.tsx @@ -9,11 +9,11 @@ import { getIngestionQueueForFrontend } from "~/services/ingestionLogs.server"; import { requireUserId } from "~/services/session.server"; export async function loader({ request, params }: LoaderFunctionArgs) { - await requireUserId(request); + const userId = await requireUserId(request); const logId = params.logId; try { - const log = await getIngestionQueueForFrontend(logId as string); + const log = await getIngestionQueueForFrontend(logId as string, userId); return json({ log: log }); } catch (e) { return json({ log: null }); diff --git a/apps/webapp/app/routes/home.space.$spaceId.facts.tsx b/apps/webapp/app/routes/home.space.$spaceId.episodes.tsx similarity index 57% rename from apps/webapp/app/routes/home.space.$spaceId.facts.tsx rename to apps/webapp/app/routes/home.space.$spaceId.episodes.tsx index 8d894f7..d5a8894 100644 --- a/apps/webapp/app/routes/home.space.$spaceId.facts.tsx +++ b/apps/webapp/app/routes/home.space.$spaceId.episodes.tsx @@ -3,11 +3,13 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useLoaderData } from "@remix-run/react"; import { requireUserId } from "~/services/session.server"; import { SpaceService } from "~/services/space.server"; -import { SpaceFactsFilters } from "~/components/spaces/space-facts-filters"; -import { SpaceFactsList } from "~/components/spaces/space-facts-list"; +import { SpaceEpisodesFilters } from "~/components/spaces/space-episode-filters"; +import { SpaceEpisodesList } from "~/components/spaces/space-episodes-list"; import { ClientOnly } from "remix-utils/client-only"; import { LoaderCircle } from "lucide-react"; +import { getLogByEpisode } from "~/services/ingestionLogs.server"; +import { Button } from "~/components/ui"; export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -15,16 +17,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const spaceId = params.spaceId as string; const space = await spaceService.getSpace(spaceId, userId); - const statements = await spaceService.getSpaceStatements(spaceId, userId); + const episodes = await spaceService.getSpaceEpisodes(spaceId, userId); + + const episodesWithLogData = await Promise.all( + episodes.map(async (ep) => { + const log = await getLogByEpisode(ep.uuid); + + return { + ...ep, + logId: log?.id, + }; + }), + ); return { space, - statements: statements || [], + episodes: episodesWithLogData || [], }; } -export default function Facts() { - const { statements } = useLoaderData(); +export default function Episodes() { + const { episodes } = useLoaderData(); const [selectedValidDate, setSelectedValidDate] = useState< string | undefined >(); @@ -32,42 +45,27 @@ export default function Facts() { string | undefined >(); - // Filter statements based on selected filters - const filteredStatements = statements.filter((statement) => { + // Filter episodes based on selected filters + const filteredEpisodes = episodes.filter((episode) => { // Date filter if (selectedValidDate) { const now = new Date(); - const statementDate = new Date(statement.validAt); + const episodeDate = new Date(episode.createdAt); switch (selectedValidDate) { case "last_week": const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - if (statementDate < weekAgo) return false; + if (episodeDate < weekAgo) return false; break; case "last_month": const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - if (statementDate < monthAgo) return false; + if (episodeDate < monthAgo) return false; break; case "last_6_months": const sixMonthsAgo = new Date( now.getTime() - 180 * 24 * 60 * 60 * 1000, ); - if (statementDate < sixMonthsAgo) return false; - break; - } - } - - // Status filter - if (selectedSpaceFilter) { - switch (selectedSpaceFilter) { - case "active": - if (statement.invalidAt) return false; - break; - case "archived": - if (!statement.invalidAt) return false; - break; - case "all": - default: + if (episodeDate < sixMonthsAgo) return false; break; } } @@ -81,20 +79,22 @@ export default function Facts() { return (
- +
+ +
} > {() => ( - - Summary + Context (); const location = useLocation(); const navigate = useNavigate(); + const [showAddMemory, setShowAddMemory] = React.useState(false); return ( <> @@ -46,16 +50,10 @@ export default function Space() { onClick: () => navigate(`/home/space/${space.id}/overview`), }, { - label: "Facts", - value: "facts", - isActive: location.pathname.includes("/facts"), - onClick: () => navigate(`/home/space/${space.id}/facts`), - }, - { - label: "Patterns", - value: "patterns", - isActive: location.pathname.includes("/patterns"), - onClick: () => navigate(`/home/space/${space.id}/patterns`), + label: "Episodes", + value: "edpisodes", + isActive: location.pathname.includes("/episodes"), + onClick: () => navigate(`/home/space/${space.id}/episodes`), }, ]} actionsNode={ @@ -67,17 +65,33 @@ export default function Space() { } > {() => ( - +
+ + +
)}
} />
+ + {showAddMemory && ( + + )}
); diff --git a/apps/webapp/app/routes/onboarding.tsx b/apps/webapp/app/routes/onboarding.tsx index 4219734..448cabe 100644 --- a/apps/webapp/app/routes/onboarding.tsx +++ b/apps/webapp/app/routes/onboarding.tsx @@ -153,6 +153,7 @@ export default function Onboarding() { setCurrentQuestion(currentQuestion + 1); } else { setLoading(true); + // Submit all answers submitAnswers(); } diff --git a/apps/webapp/app/routes/settings.billing.tsx b/apps/webapp/app/routes/settings.billing.tsx index 6c6ac2b..69c913b 100644 --- a/apps/webapp/app/routes/settings.billing.tsx +++ b/apps/webapp/app/routes/settings.billing.tsx @@ -262,6 +262,7 @@ export default function BillingSettings() {

{usageSummary.credits.percentageUsed}% used this period @@ -452,7 +453,7 @@ export default function BillingSettings() { -

+
{/* Free Plan */}
@@ -467,10 +468,10 @@ export default function BillingSettings() {
  • - Memory facts: 3k/mo + Credits: 3k/mo
  • - NO USAGE BASED + No usage based