feat: change spaces to episode based

This commit is contained in:
Harshith Mullapudi 2025-10-09 11:11:05 +05:30
parent fdc52ffc47
commit 0a75a68d1d
44 changed files with 972 additions and 781 deletions

View File

@ -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 */}
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search" className="py-1" />
<CommandList>
<CommandGroup heading="Add to Memory">
<CommandItem
onSelect={handleAddMemory}
className="flex items-center gap-2 py-1"
>
<Plus className="mr-2 h-4 w-4" />
<span>Add Memory</span>
</CommandItem>
<CommandItem
onSelect={handleAddDocument}
className="flex items-center gap-2 py-1"
>
<FileText className="mr-2 h-4 w-4" />
<span>Add Document</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
{showAddMemory && (
<AddMemoryDialog open={showAddMemory} onOpenChange={setShowAddMemory} />
)}
{/* Add Document Dialog */}
<AddDocumentDialog
open={showAddDocument}
onOpenChange={setShowAddDocument}
/>
</>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Document</DialogTitle>
</DialogHeader>
{/* TODO: Add document content here */}
<div className="border-border rounded-md border p-4">
<p className="text-muted-foreground text-sm">
Document upload content goes here...
</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<string[]>(
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="pt-0 sm:max-w-[600px]">
<div className="overflow-hidden rounded-md">
<EditorContent editor={editor} />
</div>
<div className="flex justify-between gap-2 px-4 pb-4">
<div>
<SpaceDropdown
episodeIds={[]}
selectedSpaceIds={spaceIds}
onSpaceChange={(spaceIds) => {
setSpaceIds(spaceIds);
}}
/>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="secondary"
onClick={handleAdd}
isLoading={fetcher.state !== "idle"}
>
Add
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -9,6 +9,7 @@ import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row"; import TableRow from "@tiptap/extension-table-row";
import { all, createLowlight } from "lowlight"; import { all, createLowlight } from "lowlight";
import { mergeAttributes, type Extension } from "@tiptap/react"; import { mergeAttributes, type Extension } from "@tiptap/react";
import { Markdown } from "tiptap-markdown";
// create a lowlight instance with all languages loaded // create a lowlight instance with all languages loaded
export const lowlight = createLowlight(all); export const lowlight = createLowlight(all);
@ -136,4 +137,5 @@ export const extensionsForConversation = [
CodeBlockLowlight.configure({ CodeBlockLowlight.configure({
lowlight, lowlight,
}), }),
Markdown,
]; ];

View File

@ -83,10 +83,14 @@ export const GraphClusteringVisualization = forwardRef<
filtered = filtered.filter((triplet) => { filtered = filtered.filter((triplet) => {
const sourceMatches = const sourceMatches =
isEpisodeNode(triplet.sourceNode) && isEpisodeNode(triplet.sourceNode) &&
triplet.sourceNode.attributes?.content?.toLowerCase().includes(query); triplet.sourceNode.attributes?.content
?.toLowerCase()
.includes(query);
const targetMatches = const targetMatches =
isEpisodeNode(triplet.targetNode) && isEpisodeNode(triplet.targetNode) &&
triplet.targetNode.attributes?.content?.toLowerCase().includes(query); triplet.targetNode.attributes?.content
?.toLowerCase()
.includes(query);
return sourceMatches || targetMatches; return sourceMatches || targetMatches;
}); });

View File

@ -1,6 +1,6 @@
import { useState, useEffect, type ReactNode } from "react"; import { useState, useEffect, type ReactNode } from "react";
import { useFetcher } from "@remix-run/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 { Badge, BadgeColor } from "../ui/badge";
import { type LogItem } from "~/hooks/use-logs"; import { type LogItem } from "~/hooks/use-logs";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
@ -8,6 +8,7 @@ import { getIconForAuthorise } from "../icon-utils";
import { cn, formatString } from "~/lib/utils"; import { cn, formatString } from "~/lib/utils";
import { getStatusColor } from "./utils"; import { getStatusColor } from "./utils";
import { format } from "date-fns"; import { format } from "date-fns";
import { SpaceDropdown } from "../spaces/space-dropdown";
interface LogDetailsProps { interface LogDetailsProps {
log: LogItem; log: LogItem;
@ -33,13 +34,13 @@ function PropertyItem({
if (!value) return null; if (!value) return null;
return ( return (
<div className="flex items-center py-1"> <div className="flex items-center py-1 !text-base">
<span className="text-muted-foreground min-w-[160px]">{label}</span> <span className="text-muted-foreground min-w-[120px]">{label}</span>
{variant === "status" ? ( {variant === "status" ? (
<Badge <Badge
className={cn( className={cn(
"!bg-grayAlpha-100 text-muted-foreground h-7 rounded px-4 text-xs", "text-foreground h-7 items-center gap-2 rounded !bg-transparent px-4.5 !text-base",
className, className,
)} )}
> >
@ -49,7 +50,13 @@ function PropertyItem({
{value} {value}
</Badge> </Badge>
) : ( ) : (
<Badge variant={variant} className={cn("h-7 rounded px-4", className)}> <Badge
variant={variant}
className={cn(
"h-7 items-center gap-2 rounded bg-transparent px-4 !text-base",
className,
)}
>
{icon} {icon}
{value} {value}
</Badge> </Badge>
@ -73,10 +80,10 @@ interface EpisodeFactsResponse {
function getStatusValue(status: string) { function getStatusValue(status: string) {
if (status === "PENDING") { if (status === "PENDING") {
return "In Queue"; return formatString("IN QUEUE");
} }
return status; return formatString(status);
} }
export function LogDetails({ log }: LogDetailsProps) { export function LogDetails({ log }: LogDetailsProps) {
@ -113,6 +120,9 @@ export function LogDetails({ log }: LogDetailsProps) {
} else if (log.episodeUUID) { } else if (log.episodeUUID) {
setFactsLoading(true); setFactsLoading(true);
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`); fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
} else {
setFacts([]);
setInvalidFacts([]);
} }
}, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]); }, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]);
@ -129,41 +139,8 @@ export function LogDetails({ log }: LogDetailsProps) {
return ( return (
<div className="flex h-full w-full flex-col items-center overflow-auto"> <div className="flex h-full w-full flex-col items-center overflow-auto">
<div className="max-w-4xl"> <div className="max-w-4xl">
<div className="px-4 pt-4"> <div className="mt-5 mb-5 px-4">
<div className="mb-4 flex w-full items-center justify-between">
<span>Episode Details</span>
</div>
</div>
<div className="mb-10 px-4">
<div className="space-y-1"> <div className="space-y-1">
{log.data?.type === "DOCUMENT" && log.data?.episodes ? (
<PropertyItem
label="Episodes"
value={
<div className="flex flex-wrap gap-1">
{log.data.episodes.map(
(episodeId: string, index: number) => (
<Badge
key={index}
variant="outline"
className="text-xs"
>
{episodeId}
</Badge>
),
)}
</div>
}
variant="secondary"
/>
) : (
<PropertyItem
label="Episode Id"
value={log.episodeUUID}
variant="secondary"
/>
)}
<PropertyItem <PropertyItem
label="Session Id" label="Session Id"
value={log.data?.sessionId?.toLowerCase()} value={log.data?.sessionId?.toLowerCase()}
@ -174,6 +151,13 @@ export function LogDetails({ log }: LogDetailsProps) {
value={formatString( value={formatString(
log.data?.type ? log.data.type.toLowerCase() : "conversation", log.data?.type ? log.data.type.toLowerCase() : "conversation",
)} )}
icon={
log.data?.type === "CONVERSATION" ? (
<MessageSquare size={16} />
) : (
<File size={16} />
)
}
variant="secondary" variant="secondary"
/> />
<PropertyItem <PropertyItem
@ -192,15 +176,28 @@ export function LogDetails({ log }: LogDetailsProps) {
variant="status" variant="status"
statusColor={log.status && getStatusColor(log.status)} statusColor={log.status && getStatusColor(log.status)}
/> />
{/* Space Assignment for CONVERSATION type */}
{log.data.type.toLowerCase() === "conversation" &&
log?.episodeUUID && (
<div className="mt-2 flex items-start py-1">
<span className="text-muted-foreground min-w-[120px]">
Spaces
</span>
<SpaceDropdown
className="px-3"
episodeIds={[log.episodeUUID]}
selectedSpaceIds={log.spaceIds || []}
/>
</div>
)}
</div> </div>
</div> </div>
{/* Error Details */} {/* Error Details */}
{log.error && ( {log.error && (
<div className="mb-6 px-4"> <div className="mb-6 px-4">
<div className="mb-2 flex w-full items-center justify-between">
<span>Error Details</span>
</div>
<div className="bg-destructive/10 rounded-md p-3"> <div className="bg-destructive/10 rounded-md p-3">
<div className="flex items-start gap-2 text-red-600"> <div className="flex items-start gap-2 text-red-600">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" /> <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
@ -212,21 +209,63 @@ export function LogDetails({ log }: LogDetailsProps) {
</div> </div>
)} )}
<div className="flex flex-col items-center p-4 pt-0"> {log.data?.type === "CONVERSATION" && (
<div className="mb-2 flex w-full items-center justify-between"> <div className="flex flex-col items-center p-4 pt-0">
<span>Content</span> {/* Log Content */}
</div> <div className="mb-4 w-full break-words whitespace-pre-wrap">
{/* Log Content */} <div className="rounded-md">
<div className="mb-4 w-full text-sm break-words whitespace-pre-wrap"> <Markdown>{log.ingestText}</Markdown>
<div className="rounded-md"> </div>
<Markdown>{log.ingestText}</Markdown>
</div> </div>
</div> </div>
</div> )}
{/* Episodes List for DOCUMENT type */}
{log.data?.type === "DOCUMENT" && log.episodeDetails?.length > 0 && (
<div className="mb-6 px-4">
<div className="mb-2 flex w-full items-center justify-between font-medium">
<span>Episodes ({log.episodeDetails.length})</span>
</div>
<div className="flex flex-col gap-3">
{log.episodeDetails.map((episode: any, index: number) => (
<div
key={episode.uuid}
className="bg-grayAlpha-100 flex flex-col gap-3 rounded-md p-3"
>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-muted-foreground text-xs">
Episode {index + 1}
</span>
<span className="truncate font-mono text-xs">
{episode.uuid}
</span>
</div>
<div className="flex-shrink-0">
<SpaceDropdown
episodeIds={[episode.uuid]}
selectedSpaceIds={episode.spaceIds || []}
/>
</div>
</div>
{/* Episode Content */}
<div className="border-grayAlpha-200 border-t pt-3">
<div className="text-muted-foreground mb-1 text-xs">
Content
</div>
<div className="text-sm break-words whitespace-pre-wrap">
<Markdown>{episode.content}</Markdown>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Episode Facts */} {/* Episode Facts */}
<div className="mb-6 px-4"> <div className="mb-6 px-4">
<div className="mb-2 flex w-full items-center justify-between"> <div className="mb-2 flex w-full items-center justify-between font-medium">
<span>Facts</span> <span>Facts</span>
</div> </div>
<div className="rounded-md"> <div className="rounded-md">

View File

@ -41,7 +41,6 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
}; };
useEffect(() => { useEffect(() => {
console.log(deleteFetcher.state, deleteFetcher.data);
if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) { if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) {
navigate(`/home/inbox`); navigate(`/home/inbox`);
} }

View File

@ -4,6 +4,7 @@ import { type LogItem } from "~/hooks/use-logs";
import { getIconForAuthorise } from "../icon-utils"; import { getIconForAuthorise } from "../icon-utils";
import { useNavigate, useParams } from "@remix-run/react"; import { useNavigate, useParams } from "@remix-run/react";
import { getStatusColor, getStatusValue } from "./utils"; import { getStatusColor, getStatusValue } from "./utils";
import { File, MessageSquare } from "lucide-react";
interface LogTextCollapseProps { interface LogTextCollapseProps {
text?: string; text?: string;
@ -49,9 +50,13 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
}; };
const getIngestType = (log: LogItem) => { 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" ? (
<MessageSquare size={14} />
) : (
<File size={14} />
);
}; };
return ( return (
@ -100,7 +105,7 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Badge <Badge
className={cn( className={cn(
"!bg-grayAlpha-100 text-muted-foreground rounded text-xs", "text-muted-foreground rounded !bg-transparent text-xs",
)} )}
> >
{getIngestType(log)} {getIngestType(log)}

View File

@ -22,5 +22,5 @@ export function getStatusValue(status: string) {
return formatString("In Queue"); return formatString("In Queue");
} }
return status; return formatString(status);
} }

View File

@ -10,6 +10,7 @@ import {
import { type LogItem } from "~/hooks/use-logs"; import { type LogItem } from "~/hooks/use-logs";
import { ScrollManagedList } from "../virtualized-list"; import { ScrollManagedList } from "../virtualized-list";
import { LogTextCollapse } from "./log-text-collapse"; import { LogTextCollapse } from "./log-text-collapse";
import { LoaderCircle } from "lucide-react";
interface VirtualLogsListProps { interface VirtualLogsListProps {
logs: LogItem[]; logs: LogItem[];
@ -139,7 +140,7 @@ export function VirtualLogsList({
{isLoading && ( {isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm"> <div className="text-muted-foreground p-4 text-center text-sm">
Loading more logs... <LoaderCircle size={18} className="mr-1 animate-spin" />
</div> </div>
)} )}
</div> </div>

View File

@ -139,6 +139,7 @@ export default function OnboardingQuestionComponent({
variant="ghost" variant="ghost"
size="xl" size="xl"
onClick={onPrevious} onClick={onPrevious}
disabled={loading}
className="rounded-lg px-4 py-2" className="rounded-lg px-4 py-2"
> >
Previous Previous
@ -151,7 +152,7 @@ export default function OnboardingQuestionComponent({
size="xl" size="xl"
onClick={onNext} onClick={onNext}
isLoading={!!loading} isLoading={!!loading}
disabled={!isValid()} disabled={!isValid() || loading}
className="rounded-lg px-4 py-2" className="rounded-lg px-4 py-2"
> >
{isLast ? "Complete Profile" : "Continue"} {isLast ? "Complete Profile" : "Continue"}

View File

@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { import {
Sidebar, Sidebar,
@ -12,14 +13,20 @@ import {
Columns3, Columns3,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
LoaderCircle,
MessageSquare, MessageSquare,
Network, Network,
Plus,
} from "lucide-react"; } from "lucide-react";
import { NavMain } from "./nav-main"; import { NavMain } from "./nav-main";
import { useUser } from "~/hooks/useUser"; import { useUser } from "~/hooks/useUser";
import { NavUser } from "./nav-user"; import { NavUser } from "./nav-user";
import Logo from "../logo/logo"; import Logo from "../logo/logo";
import { ConversationList } from "../conversation"; 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 = { const data = {
navMain: [ navMain: [
@ -41,7 +48,7 @@ const data = {
{ {
title: "Spaces", title: "Spaces",
url: "/home/space", url: "/home/space",
icon: Columns3, icon: Project,
}, },
{ {
title: "Integrations", title: "Integrations",
@ -54,33 +61,57 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const user = useUser(); const user = useUser();
return ( const [showAddMemory, setShowAddMemory] = React.useState(false);
<Sidebar
variant="inset"
{...props}
className="bg-background h-[100vh] py-2"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
<Logo size={20} />
C.O.R.E.
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<div className="mt-4 flex h-full flex-col">
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
<ConversationList />
</div>
</SidebarContent>
<SidebarFooter className="px-2"> // Open command bar with Meta+K (Cmd+K on Mac, Ctrl+K on Windows/Linux)
<NavUser user={user} /> useHotkeys("meta+k", (e) => {
</SidebarFooter> e.preventDefault();
</Sidebar> setShowAddMemory(true);
});
return (
<>
<Sidebar
variant="inset"
{...props}
className="bg-background h-[100vh] py-2"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex justify-center">
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
<Logo size={20} />
C.O.R.E.
</div>
<Button
variant="secondary"
isActive
size="sm"
className="rounded"
onClick={() => setShowAddMemory(true)}
>
<Plus size={16} />
</Button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<div className="mt-4 flex h-full flex-col">
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
<ConversationList />
</div>
</SidebarContent>
<SidebarFooter className="flex flex-col px-2">
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
{showAddMemory && (
<AddMemoryDialog open={showAddMemory} onOpenChange={setShowAddMemory} />
)}
</>
); );
} }

View File

@ -67,6 +67,15 @@ export function NavUser({ user }: { user: ExtendedUser }) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button
variant="ghost"
onClick={() => {
navigate("/settings/billing");
}}
>
<div>{user.availableCredits} credits</div>
</Button>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
); );

View File

@ -17,8 +17,8 @@ interface SpaceCardProps {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
autoMode: boolean; autoMode: boolean;
statementCount: number | null;
summary: string | null; summary: string | null;
contextCount?: number | null;
themes?: string[]; themes?: string[];
}; };
} }
@ -46,13 +46,17 @@ export function SpaceCard({ space }: SpaceCardProps) {
</div> </div>
<CardTitle className="text-base">{space.name}</CardTitle> <CardTitle className="text-base">{space.name}</CardTitle>
<CardDescription className="line-clamp-2 text-xs"> <CardDescription className="line-clamp-2 text-xs">
{space.description || space.summary || "Knowledge space"} <p
dangerouslySetInnerHTML={{
__html: space.description || space.summary || "Knowledge space",
}}
></p>
</CardDescription> </CardDescription>
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs"> <div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
{space.statementCount && space.statementCount > 0 && ( {space.contextCount && space.contextCount > 0 && (
<div> <div>
{space.statementCount} fact {space.contextCount} episode
{space.statementCount !== 1 ? "s" : ""} {space.contextCount !== 1 ? "s" : ""}
</div> </div>
)} )}
</div> </div>

View File

@ -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<string[]>(selectedSpaceIds);
const [spaces, setSpaces] = useState<Space[]>([]);
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 (
<>
<Project size={14} /> {selectedSpaceObjects[0].name}
</>
);
}
if (selectedSpaceObjects?.length > 1) {
return (
<>
<Project size={14} /> {selectedSpaceObjects.length} Spaces
</>
);
}
return (
<>
{" "}
<Project size={14} />
Spaces
</>
);
};
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{/* + button to add more spaces */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
role="combobox"
aria-expanded={open}
className="h-7 gap-1 rounded"
>
{getTrigger()}
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-[250px] p-0" align="end">
<Command>
<CommandInput placeholder="Search spaces..." />
<CommandList>
<CommandEmpty>No spaces found.</CommandEmpty>
<CommandGroup>
{spaces.map((space) => (
<CommandItem
key={space.id}
value={space.name}
onSelect={() => handleSpaceToggle(space.id)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedSpaces.includes(space.id)
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-sm">{space.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
</div>
);
}

View File

@ -2,12 +2,27 @@ import { Calendar } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import type { StatementNode } from "@core/types"; import type { StatementNode } from "@core/types";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useNavigate } from "@remix-run/react";
import Markdown from "react-markdown";
interface SpaceFactCardProps { export interface Episode {
fact: StatementNode; 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 formatDate = (date: Date | string) => {
const d = new Date(date); const d = new Date(date);
return d.toLocaleDateString("en-US", { return d.toLocaleDateString("en-US", {
@ -17,18 +32,20 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
}); });
}; };
const displayText = fact.fact; const displayText = episode.originalContent;
const recallCount = const onClick = () => {
(fact.recallCount?.high ?? 0) + (fact.recallCount?.low ?? 0); navigate(`/home/inbox/${episode.logId}`);
};
return ( return (
<> <>
<div className="flex w-full items-center px-5 pr-2"> <div className="group flex w-full items-center px-5 pr-2">
<div <div
className={cn( className={cn(
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-3", "group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow cursor-pointer items-start gap-2 rounded-md px-3",
)} )}
onClick={onClick}
> >
<div <div
className={cn( className={cn(
@ -37,19 +54,13 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
> >
<div className="flex w-full items-center justify-between gap-4"> <div className="flex w-full items-center justify-between gap-4">
<div className="inline-flex min-h-[24px] min-w-[0px] shrink items-center justify-start"> <div className="inline-flex min-h-[24px] min-w-[0px] shrink items-center justify-start">
<div className={cn("truncate text-left")}>{displayText}</div> <Markdown>{displayText}</Markdown>
</div> </div>
<div className="text-muted-foreground flex shrink-0 items-center justify-end gap-2 text-xs"> <div className="text-muted-foreground flex shrink-0 items-center justify-end gap-2 text-xs">
{!!recallCount && <span>Recalled: {recallCount} times</span>}
<Badge variant="secondary" className="rounded text-xs"> <Badge variant="secondary" className="rounded text-xs">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{formatDate(fact.validAt)} {formatDate(episode.validAt)}
</Badge> </Badge>
{fact.invalidAt && (
<Badge variant="destructive" className="rounded text-xs">
Invalid since {formatDate(fact.invalidAt)}
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,7 +9,7 @@ import {
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
interface SpaceFactsFiltersProps { interface SpaceEpisodesFiltersProps {
selectedValidDate?: string; selectedValidDate?: string;
selectedSpaceFilter?: string; selectedSpaceFilter?: string;
onValidDateChange: (date?: string) => void; onValidDateChange: (date?: string) => void;
@ -22,34 +22,24 @@ const validDateOptions = [
{ value: "last_6_months", label: "Last 6 Months" }, { value: "last_6_months", label: "Last 6 Months" },
]; ];
const spaceFilterOptions = [ type FilterStep = "main" | "validDate";
{ value: "active", label: "Active Facts" },
{ value: "archived", label: "Archived Facts" },
{ value: "all", label: "All Facts" },
];
type FilterStep = "main" | "validDate" | "spaceFilter"; export function SpaceEpisodesFilters({
export function SpaceFactsFilters({
selectedValidDate, selectedValidDate,
selectedSpaceFilter, selectedSpaceFilter,
onValidDateChange, onValidDateChange,
onSpaceFilterChange, }: SpaceEpisodesFiltersProps) {
}: SpaceFactsFiltersProps) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [step, setStep] = useState<FilterStep>("main"); const [step, setStep] = useState<FilterStep>("main");
const selectedValidDateLabel = validDateOptions.find( const selectedValidDateLabel = validDateOptions.find(
(d) => d.value === selectedValidDate, (d) => d.value === selectedValidDate,
)?.label; )?.label;
const selectedSpaceFilterLabel = spaceFilterOptions.find(
(f) => f.value === selectedSpaceFilter,
)?.label;
const hasFilters = selectedValidDate || selectedSpaceFilter; const hasFilters = selectedValidDate || selectedSpaceFilter;
return ( return (
<div className="mb-2 flex w-full items-center justify-start gap-2 px-5"> <>
<Popover <Popover
open={popoverOpen} open={popoverOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -79,13 +69,6 @@ export function SpaceFactsFilters({
> >
Valid Date Valid Date
</Button> </Button>
<Button
variant="ghost"
className="justify-start"
onClick={() => setStep("spaceFilter")}
>
Status
</Button>
</div> </div>
)} )}
@ -122,40 +105,6 @@ export function SpaceFactsFilters({
))} ))}
</div> </div>
)} )}
{step === "spaceFilter" && (
<div className="flex flex-col gap-1 p-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSpaceFilterChange(undefined);
setPopoverOpen(false);
setStep("main");
}}
>
All Facts
</Button>
{spaceFilterOptions.map((option) => (
<Button
key={option.value}
variant="ghost"
className="w-full justify-start"
onClick={() => {
onSpaceFilterChange(
option.value === selectedSpaceFilter
? undefined
: option.value,
);
setPopoverOpen(false);
setStep("main");
}}
>
{option.label}
</Button>
))}
</div>
)}
</PopoverContent> </PopoverContent>
</PopoverPortal> </PopoverPortal>
</Popover> </Popover>
@ -172,17 +121,8 @@ export function SpaceFactsFilters({
/> />
</Badge> </Badge>
)} )}
{selectedSpaceFilter && (
<Badge variant="secondary" className="h-7 gap-1 rounded px-2">
{selectedSpaceFilterLabel}
<X
className="hover:text-destructive h-3.5 w-3.5 cursor-pointer"
onClick={() => onSpaceFilterChange(undefined)}
/>
</Badge>
)}
</div> </div>
)} )}
</div> </>
); );
} }

View File

@ -9,25 +9,24 @@ import {
} from "react-virtualized"; } from "react-virtualized";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import type { StatementNode } from "@core/types";
import { ScrollManagedList } from "../virtualized-list"; import { ScrollManagedList } from "../virtualized-list";
import { SpaceFactCard } from "./space-fact-card"; import { type Episode, SpaceEpisodeCard } from "./space-episode-card";
interface SpaceFactsListProps { interface SpaceEpisodesListProps {
facts: any[]; episodes: any[];
hasMore: boolean; hasMore: boolean;
loadMore: () => void; loadMore: () => void;
isLoading: boolean; isLoading: boolean;
height?: number; height?: number;
} }
function FactItemRenderer( function EpisodeItemRenderer(
props: ListRowProps, props: ListRowProps,
facts: StatementNode[], episodes: Episode[],
cache: CellMeasurerCache, cache: CellMeasurerCache,
) { ) {
const { index, key, style, parent } = props; const { index, key, style, parent } = props;
const fact = facts[index]; const episode = episodes[index];
return ( return (
<CellMeasurer <CellMeasurer
@ -38,23 +37,23 @@ function FactItemRenderer(
rowIndex={index} rowIndex={index}
> >
<div key={key} style={style} className="pb-2"> <div key={key} style={style} className="pb-2">
<SpaceFactCard fact={fact} /> <SpaceEpisodeCard episode={episode} />
</div> </div>
</CellMeasurer> </CellMeasurer>
); );
} }
export function SpaceFactsList({ export function SpaceEpisodesList({
facts, episodes,
hasMore, hasMore,
loadMore, loadMore,
isLoading, isLoading,
}: SpaceFactsListProps) { }: SpaceEpisodesListProps) {
// Create a CellMeasurerCache instance using useRef to prevent recreation // Create a CellMeasurerCache instance using useRef to prevent recreation
const cacheRef = useRef<CellMeasurerCache | null>(null); const cacheRef = useRef<CellMeasurerCache | null>(null);
if (!cacheRef.current) { if (!cacheRef.current) {
cacheRef.current = new CellMeasurerCache({ 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 fixedWidth: true, // Rows have fixed width but dynamic height
}); });
} }
@ -62,17 +61,17 @@ export function SpaceFactsList({
useEffect(() => { useEffect(() => {
cache.clearAll(); cache.clearAll();
}, [facts, cache]); }, [episodes, cache]);
if (facts.length === 0 && !isLoading) { if (episodes.length === 0 && !isLoading) {
return ( return (
<Card className="bg-background-2 w-full"> <Card className="bg-background-2 w-full">
<CardContent className="bg-background-2 flex w-full items-center justify-center py-16"> <CardContent className="bg-background-2 flex w-full items-center justify-center py-16">
<div className="text-center"> <div className="text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No facts found</h3> <h3 className="mb-2 text-lg font-semibold">No Episodes found</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
This space doesn't contain any facts yet. This space doesn't contain any episodes yet.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -81,7 +80,7 @@ export function SpaceFactsList({
} }
const isRowLoaded = ({ index }: { index: number }) => { const isRowLoaded = ({ index }: { index: number }) => {
return !!facts[index]; return !!episodes[index];
}; };
const loadMoreRows = async () => { const loadMoreRows = async () => {
@ -92,14 +91,14 @@ export function SpaceFactsList({
}; };
const rowRenderer = (props: ListRowProps) => { const rowRenderer = (props: ListRowProps) => {
return FactItemRenderer(props, facts, cache); return EpisodeItemRenderer(props, episodes, cache);
}; };
const rowHeight = ({ index }: Index) => { const rowHeight = ({ index }: Index) => {
return cache.getHeight(index, 0); return cache.getHeight(index, 0);
}; };
const itemCount = hasMore ? facts.length + 1 : facts.length; const itemCount = hasMore ? episodes.length + 1 : episodes.length;
return ( return (
<div className="h-full grow overflow-hidden rounded-lg"> <div className="h-full grow overflow-hidden rounded-lg">
@ -131,7 +130,7 @@ export function SpaceFactsList({
{isLoading && ( {isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm"> <div className="text-muted-foreground p-4 text-center text-sm">
Loading more facts... Loading more episodes...
</div> </div>
)} )}
</div> </div>

View File

@ -9,8 +9,8 @@ interface SpacesGridProps {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
autoMode: boolean; autoMode: boolean;
statementCount: number | null;
summary: string | null; summary: string | null;
contextCount?: number | null;
themes?: string[]; themes?: string[];
}>; }>;
} }

View File

@ -40,7 +40,7 @@ const CommandDialog = ({
<Dialog {...props}> <Dialog {...props}>
<DialogContent className={cn("overflow-hidden p-0 font-sans")}> <DialogContent className={cn("overflow-hidden p-0 font-sans")}>
<Command <Command
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5" className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-10 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
{...commandProps} {...commandProps}
> >
{children} {children}
@ -141,7 +141,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"command-item aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "command-item aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded px-2 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
{...props} {...props}

View File

@ -15,6 +15,8 @@ export interface LogItem {
activityId?: string; activityId?: string;
episodeUUID?: string; episodeUUID?: string;
data?: any; data?: any;
spaceIds?: string[];
episodeDetails?: any;
} }
export interface LogsResponse { export interface LogsResponse {

View File

@ -5,7 +5,8 @@ import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData"; import { useTypedMatchesData } from "./useTypedMatchData";
export interface ExtendedUser extends User { export interface ExtendedUser extends User {
availableCredits?: number; availableCredits: number;
totalCredits: number;
} }
export function useIsImpersonating(matches?: UIMatch[]) { export function useIsImpersonating(matches?: UIMatch[]) {
@ -23,7 +24,11 @@ export function useOptionalUser(matches?: UIMatch[]): ExtendedUser | undefined {
}); });
return routeMatch?.user return routeMatch?.user
? { ...routeMatch?.user, availableCredits: routeMatch?.availableCredits } ? {
...routeMatch?.user,
availableCredits: routeMatch?.availableCredits,
totalCredits: routeMatch?.totalCredits,
}
: undefined; : undefined;
} }

View File

@ -145,10 +145,8 @@ export const getClusteredGraphData = async (userId: string) => {
rel.predicate as predicateLabel, rel.predicate as predicateLabel,
e.uuid as episodeUuid, e.uuid as episodeUuid,
e.content as episodeContent, e.content as episodeContent,
e.spaceIds as spaceIds,
s.uuid as statementUuid, s.uuid as statementUuid,
s.spaceIds as spaceIds,
s.fact as fact,
s.invalidAt as invalidAt,
s.validAt as validAt, s.validAt as validAt,
s.createdAt as createdAt`, s.createdAt as createdAt`,
{ userId }, { userId },
@ -169,13 +167,8 @@ export const getClusteredGraphData = async (userId: string) => {
const predicateLabel = record.get("predicateLabel"); const predicateLabel = record.get("predicateLabel");
const episodeUuid = record.get("episodeUuid"); const episodeUuid = record.get("episodeUuid");
const episodeContent = record.get("episodeContent");
const statementUuid = record.get("statementUuid");
const clusterIds = record.get("spaceIds"); const clusterIds = record.get("spaceIds");
const clusterId = clusterIds ? clusterIds[0] : undefined; 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"); const createdAt = record.get("createdAt");
// Create unique edge identifier to avoid duplicates // Create unique edge identifier to avoid duplicates

View File

@ -2,7 +2,6 @@ import type { Prisma, User } from "@core/database";
import type { GoogleProfile } from "@coji/remix-auth-google"; import type { GoogleProfile } from "@coji/remix-auth-google";
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { env } from "~/env.server"; import { env } from "~/env.server";
import { ensureBillingInitialized } from "~/services/billing.server";
export type { User } from "@core/database"; export type { User } from "@core/database";
type FindOrCreateMagicLink = { type FindOrCreateMagicLink = {
@ -167,7 +166,12 @@ export async function findOrCreateGoogleUser({
} }
export async function getUserById(id: User["id"]) { 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) { if (!user) {
return null; return null;

View File

@ -25,7 +25,7 @@ import {
type ToastMessage, type ToastMessage,
} from "./models/message.server"; } from "./models/message.server";
import { env } from "./env.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 { usePostHog } from "./hooks/usePostHog";
import { import {
AppContainer, AppContainer,
@ -40,6 +40,7 @@ import {
useTheme, useTheme,
} from "remix-themes"; } from "remix-themes";
import clsx from "clsx"; import clsx from "clsx";
import { getUsageSummary } from "./services/billing.server";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; 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 posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const user = await getUser(request); const user = await getUser(request);
const usage = await getUserRemainingCount(request); const usageSummary = await getUsageSummary(user?.Workspace?.id as string);
return typedjson( return typedjson(
{ {
user: user, user: user,
availableCredits: usage?.availableCredits ?? 0, availableCredits: usageSummary?.credits.available ?? 0,
totalCredits: usageSummary?.credits.monthly ?? 0,
toastMessage, toastMessage,
theme: getTheme(), theme: getTheme(),
posthogProjectKey, posthogProjectKey,

View File

@ -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 };

View File

@ -24,8 +24,11 @@ const loader = createHybridLoaderApiRoute(
corsStrategy: "all", corsStrategy: "all",
allowJWT: true, allowJWT: true,
}, },
async ({ params }) => { async ({ params, authentication }) => {
const formattedLog = await getIngestionQueueForFrontend(params.logId); const formattedLog = await getIngestionQueueForFrontend(
params.logId,
authentication.userId,
);
return json({ log: formattedLog }); return json({ log: formattedLog });
}, },

View File

@ -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 };

View File

@ -16,19 +16,6 @@ const CreateSpaceSchema = z.object({
description: z.string().optional(), 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 // Search query schema
const SearchParamsSchema = z.object({ const SearchParamsSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@ -36,7 +23,7 @@ const SearchParamsSchema = z.object({
const { action } = createHybridActionApiRoute( const { action } = createHybridActionApiRoute(
{ {
body: z.union([CreateSpaceSchema, BulkOperationSchema]), body: CreateSpaceSchema,
allowJWT: true, allowJWT: true,
authorization: { authorization: {
action: "manage", action: "manage",
@ -82,96 +69,6 @@ const { action } = createHybridActionApiRoute(
return json({ space, success: true }); 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 }); return json({ error: "Method not allowed" }, { status: 405 });
}, },
); );

View File

@ -9,11 +9,11 @@ import { getIngestionQueueForFrontend } from "~/services/ingestionLogs.server";
import { requireUserId } from "~/services/session.server"; import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
await requireUserId(request); const userId = await requireUserId(request);
const logId = params.logId; const logId = params.logId;
try { try {
const log = await getIngestionQueueForFrontend(logId as string); const log = await getIngestionQueueForFrontend(logId as string, userId);
return json({ log: log }); return json({ log: log });
} catch (e) { } catch (e) {
return json({ log: null }); return json({ log: null });

View File

@ -3,11 +3,13 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
import { requireUserId } from "~/services/session.server"; import { requireUserId } from "~/services/session.server";
import { SpaceService } from "~/services/space.server"; import { SpaceService } from "~/services/space.server";
import { SpaceFactsFilters } from "~/components/spaces/space-facts-filters"; import { SpaceEpisodesFilters } from "~/components/spaces/space-episode-filters";
import { SpaceFactsList } from "~/components/spaces/space-facts-list"; import { SpaceEpisodesList } from "~/components/spaces/space-episodes-list";
import { ClientOnly } from "remix-utils/client-only"; import { ClientOnly } from "remix-utils/client-only";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { getLogByEpisode } from "~/services/ingestionLogs.server";
import { Button } from "~/components/ui";
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request); const userId = await requireUserId(request);
@ -15,16 +17,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const spaceId = params.spaceId as string; const spaceId = params.spaceId as string;
const space = await spaceService.getSpace(spaceId, userId); 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 { return {
space, space,
statements: statements || [], episodes: episodesWithLogData || [],
}; };
} }
export default function Facts() { export default function Episodes() {
const { statements } = useLoaderData<typeof loader>(); const { episodes } = useLoaderData<typeof loader>();
const [selectedValidDate, setSelectedValidDate] = useState< const [selectedValidDate, setSelectedValidDate] = useState<
string | undefined string | undefined
>(); >();
@ -32,42 +45,27 @@ export default function Facts() {
string | undefined string | undefined
>(); >();
// Filter statements based on selected filters // Filter episodes based on selected filters
const filteredStatements = statements.filter((statement) => { const filteredEpisodes = episodes.filter((episode) => {
// Date filter // Date filter
if (selectedValidDate) { if (selectedValidDate) {
const now = new Date(); const now = new Date();
const statementDate = new Date(statement.validAt); const episodeDate = new Date(episode.createdAt);
switch (selectedValidDate) { switch (selectedValidDate) {
case "last_week": case "last_week":
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
if (statementDate < weekAgo) return false; if (episodeDate < weekAgo) return false;
break; break;
case "last_month": case "last_month":
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
if (statementDate < monthAgo) return false; if (episodeDate < monthAgo) return false;
break; break;
case "last_6_months": case "last_6_months":
const sixMonthsAgo = new Date( const sixMonthsAgo = new Date(
now.getTime() - 180 * 24 * 60 * 60 * 1000, now.getTime() - 180 * 24 * 60 * 60 * 1000,
); );
if (statementDate < sixMonthsAgo) return false; if (episodeDate < 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:
break; break;
} }
} }
@ -81,20 +79,22 @@ export default function Facts() {
return ( return (
<div className="flex h-full w-full flex-col pt-5"> <div className="flex h-full w-full flex-col pt-5">
<SpaceFactsFilters <div className="mb-2 flex w-full items-center justify-start gap-2 px-5">
selectedValidDate={selectedValidDate} <SpaceEpisodesFilters
selectedSpaceFilter={selectedSpaceFilter} selectedValidDate={selectedValidDate}
onValidDateChange={setSelectedValidDate} selectedSpaceFilter={selectedSpaceFilter}
onSpaceFilterChange={setSelectedSpaceFilter} onValidDateChange={setSelectedValidDate}
/> onSpaceFilterChange={setSelectedSpaceFilter}
/>
</div>
<div className="flex h-[calc(100vh_-_56px)] w-full"> <div className="flex h-[calc(100vh_-_56px)] w-full">
<ClientOnly <ClientOnly
fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
> >
{() => ( {() => (
<SpaceFactsList <SpaceEpisodesList
facts={filteredStatements} episodes={filteredEpisodes}
hasMore={false} // TODO: Implement real pagination hasMore={false} // TODO: Implement real pagination
loadMore={loadMore} loadMore={loadMore}
isLoading={false} isLoading={false}

View File

@ -148,7 +148,7 @@ export default function Overview() {
variant="ghost" variant="ghost"
className="text-muted-foreground mb-1 -ml-2 gap-1" className="text-muted-foreground mb-1 -ml-2 gap-1"
> >
Summary Context
<ChevronDown <ChevronDown
size={14} size={14}
className={`transition-transform duration-300 ${ className={`transition-transform duration-300 ${

View File

@ -7,6 +7,9 @@ import { useTypedLoaderData } from "remix-typedjson";
import { Outlet, useLocation, useNavigate } from "@remix-run/react"; import { Outlet, useLocation, useNavigate } from "@remix-run/react";
import { SpaceOptions } from "~/components/spaces/space-options"; import { SpaceOptions } from "~/components/spaces/space-options";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { Button } from "~/components/ui";
import React from "react";
import { AddMemoryDialog } from "~/components/command-bar/memory-dialog.client";
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request); const userId = await requireUserId(request);
@ -23,6 +26,7 @@ export default function Space() {
const space = useTypedLoaderData<typeof loader>(); const space = useTypedLoaderData<typeof loader>();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [showAddMemory, setShowAddMemory] = React.useState(false);
return ( return (
<> <>
@ -46,16 +50,10 @@ export default function Space() {
onClick: () => navigate(`/home/space/${space.id}/overview`), onClick: () => navigate(`/home/space/${space.id}/overview`),
}, },
{ {
label: "Facts", label: "Episodes",
value: "facts", value: "edpisodes",
isActive: location.pathname.includes("/facts"), isActive: location.pathname.includes("/episodes"),
onClick: () => navigate(`/home/space/${space.id}/facts`), onClick: () => navigate(`/home/space/${space.id}/episodes`),
},
{
label: "Patterns",
value: "patterns",
isActive: location.pathname.includes("/patterns"),
onClick: () => navigate(`/home/space/${space.id}/patterns`),
}, },
]} ]}
actionsNode={ actionsNode={
@ -67,17 +65,33 @@ export default function Space() {
} }
> >
{() => ( {() => (
<SpaceOptions <div className="flex items-center gap-2">
id={space.id as string} <Button
name={space.name} variant="secondary"
description={space.description} onClick={() => setShowAddMemory(true)}
/> >
Add episode
</Button>
<SpaceOptions
id={space.id as string}
name={space.name}
description={space.description}
/>
</div>
)} )}
</ClientOnly> </ClientOnly>
} }
/> />
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto"> <div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto">
<Outlet /> <Outlet />
{showAddMemory && (
<AddMemoryDialog
open={showAddMemory}
onOpenChange={setShowAddMemory}
defaultSpaceId={space.id}
/>
)}
</div> </div>
</> </>
); );

View File

@ -153,6 +153,7 @@ export default function Onboarding() {
setCurrentQuestion(currentQuestion + 1); setCurrentQuestion(currentQuestion + 1);
} else { } else {
setLoading(true); setLoading(true);
// Submit all answers // Submit all answers
submitAnswers(); submitAnswers();
} }

View File

@ -262,6 +262,7 @@ export default function BillingSettings() {
<Progress <Progress
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]} segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
className="mb-2" className="mb-2"
color="#c15e50"
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{usageSummary.credits.percentageUsed}% used this period {usageSummary.credits.percentageUsed}% used this period
@ -452,7 +453,7 @@ export default function BillingSettings() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-6 p-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{/* Free Plan */} {/* Free Plan */}
<Card className="p-6"> <Card className="p-6">
<div className="mb-4"> <div className="mb-4">
@ -467,10 +468,10 @@ export default function BillingSettings() {
</div> </div>
<ul className="mb-6 space-y-2 text-sm"> <ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>Memory facts: 3k/mo</span> <span>Credits: 3k/mo</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>NO USAGE BASED</span> <span>No usage based</span>
</li> </li>
</ul> </ul>
<Button <Button
@ -504,14 +505,15 @@ export default function BillingSettings() {
</div> </div>
<ul className="mb-6 space-y-2 text-sm"> <ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>Memory facts: 15k/mo</span> <span>Credits: 15k/mo</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>$0.299 /1K ADDITIONAL FACTS</span> <span>$0.299 /1K Additional Credits</span>
</li> </li>
</ul> </ul>
<Button <Button
className="w-full" className="w-full"
variant="secondary"
disabled={ disabled={
usageSummary.plan.type === "PRO" || usageSummary.plan.type === "PRO" ||
fetcher.state === "submitting" fetcher.state === "submitting"
@ -540,14 +542,15 @@ export default function BillingSettings() {
</div> </div>
<ul className="mb-6 space-y-2 text-sm"> <ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>Memory facts: 100k/mo</span> <span>Credits: 100k/mo</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span>$0.249 /1K ADDITIONAL FACTS</span> <span>$0.249 /1K Additional Credits</span>
</li> </li>
</ul> </ul>
<Button <Button
className="w-full" className="w-full"
variant="secondary"
disabled={ disabled={
usageSummary.plan.type === "MAX" || usageSummary.plan.type === "MAX" ||
fetcher.state === "submitting" fetcher.state === "submitting"

View File

@ -160,6 +160,10 @@ export async function ensureBillingInitialized(workspaceId: string) {
* Get workspace usage summary * Get workspace usage summary
*/ */
export async function getUsageSummary(workspaceId: string) { export async function getUsageSummary(workspaceId: string) {
if (!workspaceId) {
return null;
}
// Ensure billing records exist for existing accounts // Ensure billing records exist for existing accounts
await ensureBillingInitialized(workspaceId); await ensureBillingInitialized(workspaceId);

View File

@ -1,5 +1,9 @@
import { runQuery } from "~/lib/neo4j.server"; import { runQuery } from "~/lib/neo4j.server";
import { type StatementNode, type EntityNode, type EpisodicNode } from "@core/types"; import {
type StatementNode,
type EntityNode,
type EpisodicNode,
} from "@core/types";
export async function saveEpisode(episode: EpisodicNode): Promise<string> { export async function saveEpisode(episode: EpisodicNode): Promise<string> {
const query = ` const query = `
@ -72,6 +76,8 @@ export async function getEpisode(uuid: string): Promise<EpisodicNode | null> {
userId: episode.userId, userId: episode.userId,
space: episode.space, space: episode.space,
sessionId: episode.sessionId, sessionId: episode.sessionId,
recallCount: episode.recallCount,
spaceIds: episode.spaceIds,
}; };
} }
@ -140,7 +146,7 @@ export async function searchEpisodesByEmbedding(params: {
}) { }) {
const limit = params.limit || 100; const limit = params.limit || 100;
const query = ` const query = `
CALL db.index.vector.queryNodes('episode_embedding', ${limit*2}, $embedding) CALL db.index.vector.queryNodes('episode_embedding', ${limit * 2}, $embedding)
YIELD node AS episode YIELD node AS episode
WHERE episode.userId = $userId WHERE episode.userId = $userId
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score
@ -285,7 +291,7 @@ export async function getRelatedEpisodesEntities(params: {
}) { }) {
const limit = params.limit || 100; const limit = params.limit || 100;
const query = ` const query = `
CALL db.index.vector.queryNodes('episode_embedding', ${limit*2}, $embedding) CALL db.index.vector.queryNodes('episode_embedding', ${limit * 2}, $embedding)
YIELD node AS episode YIELD node AS episode
WHERE episode.userId = $userId WHERE episode.userId = $userId
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score

View File

@ -157,8 +157,12 @@ export async function deleteSpace(
RETURN count(s) as updatedStatements RETURN count(s) as updatedStatements
`; `;
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, { userId, spaceId }); const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, {
const updatedStatements = cleanupStatementsResult[0]?.get("updatedStatements") || 0; userId,
spaceId,
});
const updatedStatements =
cleanupStatementsResult[0]?.get("updatedStatements") || 0;
// 3. Clean up episode references (remove spaceId from spaceIds arrays) // 3. Clean up episode references (remove spaceId from spaceIds arrays)
const cleanupEpisodesQuery = ` const cleanupEpisodesQuery = `
@ -168,8 +172,12 @@ export async function deleteSpace(
RETURN count(e) as updatedEpisodes RETURN count(e) as updatedEpisodes
`; `;
const cleanupEpisodesResult = await runQuery(cleanupEpisodesQuery, { userId, spaceId }); const cleanupEpisodesResult = await runQuery(cleanupEpisodesQuery, {
const updatedEpisodes = cleanupEpisodesResult[0]?.get("updatedEpisodes") || 0; userId,
spaceId,
});
const updatedEpisodes =
cleanupEpisodesResult[0]?.get("updatedEpisodes") || 0;
// 4. Delete the space node and all its relationships // 4. Delete the space node and all its relationships
const deleteQuery = ` const deleteQuery = `
@ -199,166 +207,6 @@ export async function deleteSpace(
} }
} }
/**
* Assign statements to a space
*/
export async function assignStatementsToSpace(
statementIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
try {
// Verify space exists and belongs to user
const space = await getSpace(spaceId, userId);
if (!space) {
return {
success: false,
statementsUpdated: 0,
error: "Space not found or access denied",
};
}
const query = `
MATCH (s:Statement {userId: $userId})
WHERE s.uuid IN $statementIds
SET s.spaceIds = CASE
WHEN s.spaceIds IS NULL THEN [$spaceId]
WHEN $spaceId IN s.spaceIds THEN s.spaceIds
ELSE s.spaceIds + [$spaceId]
END,
s.lastSpaceAssignment = datetime(),
s.spaceAssignmentMethod = CASE
WHEN s.spaceAssignmentMethod IS NULL THEN 'manual'
ELSE s.spaceAssignmentMethod
END
RETURN count(s) as updated
`;
const result = await runQuery(query, { statementIds, spaceId, userId });
const updatedCount = result[0]?.get("updated") || 0;
return {
success: true,
statementsUpdated: Number(updatedCount),
};
} catch (error) {
return {
success: false,
statementsUpdated: 0,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Remove statements from a space
*/
export async function removeStatementsFromSpace(
statementIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
try {
const query = `
MATCH (s:Statement {userId: $userId})
WHERE s.uuid IN $statementIds AND s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
RETURN count(s) as updated
`;
const result = await runQuery(query, { statementIds, spaceId, userId });
const updatedCount = result[0]?.get("updated") || 0;
return {
success: true,
statementsUpdated: Number(updatedCount),
};
} catch (error) {
return {
success: false,
statementsUpdated: 0,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Get all statements in a space
*/
export async function getSpaceStatements(spaceId: string, userId: string) {
const query = `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity)
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
RETURN s, subj.name as subject, pred.name as predicate, obj.name as object
ORDER BY s.createdAt DESC
`;
const result = await runQuery(query, { spaceId, userId });
return result.map((record) => {
const statement = record.get("s").properties;
return {
uuid: statement.uuid,
fact: statement.fact,
subject: record.get("subject"),
predicate: record.get("predicate"),
object: record.get("object"),
createdAt: new Date(statement.createdAt),
validAt: new Date(statement.validAt),
invalidAt: statement.invalidAt
? new Date(statement.invalidAt)
: undefined,
spaceIds: statement.spaceIds || [],
recallCount: statement.recallCount,
};
});
}
/**
* Get real-time statement count for a space from Neo4j
*/
export async function getSpaceStatementCount(
spaceId: string,
userId: string,
): Promise<number> {
const query = `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NOT NULL
AND $spaceId IN s.spaceIds
RETURN count(s) as statementCount
`;
const result = await runQuery(query, { spaceId, userId });
return Number(result[0]?.get("statementCount") || 0);
}
/**
* Initialize spaceIds array for existing statements (migration helper)
*/
export async function initializeStatementSpaceIds(
userId?: string,
): Promise<number> {
const query = userId
? `
MATCH (s:Statement {userId: $userId})
WHERE s.spaceIds IS NULL
SET s.spaceIds = []
RETURN count(s) as updated
`
: `
MATCH (s:Statement)
WHERE s.spaceIds IS NULL
SET s.spaceIds = []
RETURN count(s) as updated
`;
const result = await runQuery(query, userId ? { userId } : {});
return Number(result[0]?.get("updated") || 0);
}
/** /**
* Assign episodes to a space using intent-based matching * Assign episodes to a space using intent-based matching
*/ */
@ -508,3 +356,36 @@ export async function getSpaceEpisodeCount(
const result = await runQuery(query, { spaceId, userId }); const result = await runQuery(query, { spaceId, userId });
return Number(result[0]?.get("episodeCount") || 0); return Number(result[0]?.get("episodeCount") || 0);
} }
/**
* Get spaces for specific episodes
*/
export async function getSpacesForEpisodes(
episodeIds: string[],
userId: string,
): Promise<Record<string, string[]>> {
const query = `
UNWIND $episodeIds as episodeId
MATCH (e:Episode {uuid: episodeId, userId: $userId})
WHERE e.spaceIds IS NOT NULL AND size(e.spaceIds) > 0
RETURN episodeId, e.spaceIds as spaceIds
`;
const result = await runQuery(query, { episodeIds, userId });
const spacesMap: Record<string, string[]> = {};
// Initialize all episodes with empty arrays
episodeIds.forEach((id) => {
spacesMap[id] = [];
});
// Fill in the spaceIds for episodes that have them
result.forEach((record) => {
const episodeId = record.get("episodeId");
const spaceIds = record.get("spaceIds");
spacesMap[episodeId] = spaceIds || [];
});
return spacesMap;
}

View File

@ -1,4 +1,6 @@
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { getEpisode } from "./graphModels/episode";
import { getSpacesForEpisodes } from "./graphModels/space";
export async function getIngestionLogs( export async function getIngestionLogs(
userId: string, userId: string,
@ -53,7 +55,10 @@ export const getIngestionQueue = async (id: string) => {
}); });
}; };
export const getIngestionQueueForFrontend = async (id: string) => { export const getIngestionQueueForFrontend = async (
id: string,
userId: string,
) => {
// Fetch the specific log by logId // Fetch the specific log by logId
const log = await prisma.ingestionQueue.findUnique({ const log = await prisma.ingestionQueue.findUnique({
where: { id: id }, where: { id: id },
@ -66,6 +71,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
type: true, type: true,
output: true, output: true,
data: true, data: true,
workspaceId: true,
activity: { activity: {
select: { select: {
text: true, text: true,
@ -94,7 +100,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
log.activity?.integrationAccount?.integrationDefinition; log.activity?.integrationAccount?.integrationDefinition;
const logData = log.data as any; const logData = log.data as any;
const formattedLog = { const formattedLog: any = {
id: log.id, id: log.id,
source: integrationDef?.name || logData?.source || "Unknown", source: integrationDef?.name || logData?.source || "Unknown",
ingestText: ingestText:
@ -112,9 +118,76 @@ export const getIngestionQueueForFrontend = async (id: string) => {
data: log.data, data: log.data,
}; };
// Fetch space data based on log type
if (logData?.type === "CONVERSATION" && formattedLog?.episodeUUID) {
// For CONVERSATION type: get spaceIds for the single episode
const spacesMap = await getSpacesForEpisodes(
[formattedLog.episodeUUID],
userId,
);
formattedLog.spaceIds = spacesMap[formattedLog.episodeUUID] || [];
} else if (
logData?.type === "DOCUMENT" &&
(log.output as any)?.episodes?.length > 0
) {
// For DOCUMENT type: get episode details and space information for all episodes
const episodeIds = (log.output as any)?.episodes;
// Fetch all episode details in parallel
const episodeDetailsPromises = episodeIds.map((episodeId: string) =>
getEpisode(episodeId).catch(() => null),
);
const episodeDetails = await Promise.all(episodeDetailsPromises);
// Get spaceIds for all episodes
const spacesMap = await getSpacesForEpisodes(episodeIds, userId);
// Combine episode details with space information
formattedLog.episodeDetails = episodeIds.map(
(episodeId: string, index: number) => {
const episode = episodeDetails[index];
return {
uuid: episodeId,
content: episode?.content || episode?.originalContent || "No content",
spaceIds: spacesMap[episodeId] || [],
};
},
);
}
return formattedLog; return formattedLog;
}; };
export const getLogByEpisode = async (episodeUuid: string) => {
// Find logs where the episode UUID matches either:
// 1. log.output.episodeUuid (single episode - CONVERSATION type)
// 2. log.output.episodes array (multiple episodes - DOCUMENT type)
const logs = await prisma.ingestionQueue.findMany({
where: {
OR: [
{
output: {
path: ["episodeUuid"],
equals: episodeUuid,
},
},
{
output: {
path: ["episodes"],
array_contains: episodeUuid,
},
},
],
},
orderBy: {
createdAt: "desc",
},
take: 1,
});
return logs[0] || null;
};
export const deleteIngestionQueue = async (id: string) => { export const deleteIngestionQueue = async (id: string) => {
return await prisma.ingestionQueue.delete({ return await prisma.ingestionQueue.delete({
where: { where: {

View File

@ -33,14 +33,6 @@ export async function getUser(request: Request) {
throw await logout(request); throw await logout(request);
} }
export async function getUserRemainingCount(request: Request) {
const userId = await getUserId(request);
if (userId === undefined) return null;
const userUsage = await getUserLeftCredits(userId);
if (userUsage) return userUsage;
}
export async function requireUserId(request: Request, redirectTo?: string) { export async function requireUserId(request: Request, redirectTo?: string) {
const userId = await getUserId(request); const userId = await getUserId(request);
if (!userId) { if (!userId) {
@ -71,6 +63,7 @@ export async function requireUser(request: Request) {
confirmedBasicDetails: user.confirmedBasicDetails, confirmedBasicDetails: user.confirmedBasicDetails,
onboardingComplete: user.onboardingComplete, onboardingComplete: user.onboardingComplete,
isImpersonating: !!impersonationId, isImpersonating: !!impersonationId,
workspaceId: user.Workspace?.id,
}; };
} }

View File

@ -3,20 +3,18 @@ import {
type SpaceNode, type SpaceNode,
type CreateSpaceParams, type CreateSpaceParams,
type UpdateSpaceParams, type UpdateSpaceParams,
type SpaceAssignmentResult,
} from "@core/types"; } from "@core/types";
import { type Space } from "@prisma/client"; import { type Space } from "@prisma/client";
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment"; import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
import { import {
assignEpisodesToSpace, assignEpisodesToSpace,
assignStatementsToSpace,
createSpace, createSpace,
deleteSpace, deleteSpace,
getSpace, getSpace,
getSpaceStatements, getSpaceEpisodeCount,
initializeStatementSpaceIds, getSpaceEpisodes,
removeStatementsFromSpace, removeEpisodesFromSpace,
updateSpace, updateSpace,
} from "./graphModels/space"; } from "./graphModels/space";
import { prisma } from "~/trigger/utils/prisma"; import { prisma } from "~/trigger/utils/prisma";
@ -278,97 +276,11 @@ export class SpaceService {
return resetSpace; return resetSpace;
} }
/**
* Assign statements to a space
*/
async assignStatementsToSpace(
statementIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
logger.info(
`Assigning ${statementIds.length} statements to space ${spaceId} for user ${userId}`,
);
// Validate input
if (statementIds.length === 0) {
throw new Error("No statement IDs provided");
}
if (statementIds.length > 1000) {
throw new Error("Too many statements (max 1000 per operation)");
}
const result = await assignStatementsToSpace(statementIds, spaceId, userId);
if (result.success) {
logger.info(
`Successfully assigned ${result.statementsUpdated} statements to space ${spaceId}`,
);
} else {
logger.warn(
`Failed to assign statements to space ${spaceId}: ${result.error}`,
);
}
return result;
}
/**
* Remove statements from a space
*/
async removeStatementsFromSpace(
statementIds: string[],
spaceId: string,
userId: string,
): Promise<SpaceAssignmentResult> {
logger.info(
`Removing ${statementIds.length} statements from space ${spaceId} for user ${userId}`,
);
// Validate input
if (statementIds.length === 0) {
throw new Error("No statement IDs provided");
}
if (statementIds.length > 1000) {
throw new Error("Too many statements (max 1000 per operation)");
}
const result = await removeStatementsFromSpace(
statementIds,
spaceId,
userId,
);
if (result.success) {
logger.info(
`Successfully removed ${result.statementsUpdated} statements from space ${spaceId}`,
);
} else {
logger.warn(
`Failed to remove statements from space ${spaceId}: ${result.error}`,
);
}
return result;
}
/**
* Get all statements in a space
* @deprecated Use getSpaceEpisodes instead - spaces now work with episodes
*/
async getSpaceStatements(spaceId: string, userId: string) {
logger.info(`Fetching statements for space ${spaceId} for user ${userId}`);
return await getSpaceStatements(spaceId, userId);
}
/** /**
* Get all episodes in a space * Get all episodes in a space
*/ */
async getSpaceEpisodes(spaceId: string, userId: string) { async getSpaceEpisodes(spaceId: string, userId: string) {
logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`); logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`);
const { getSpaceEpisodes } = await import("./graphModels/space");
return await getSpaceEpisodes(spaceId, userId); return await getSpaceEpisodes(spaceId, userId);
} }
@ -384,7 +296,7 @@ export class SpaceService {
`Assigning ${episodeIds.length} episodes to space ${spaceId} for user ${userId}`, `Assigning ${episodeIds.length} episodes to space ${spaceId} for user ${userId}`,
); );
await assignEpisodesToSpace(episodeIds,spaceId, userId); await assignEpisodesToSpace(episodeIds, spaceId, userId);
logger.info( logger.info(
`Successfully assigned ${episodeIds.length} episodes to space ${spaceId}`, `Successfully assigned ${episodeIds.length} episodes to space ${spaceId}`,
@ -403,7 +315,7 @@ export class SpaceService {
`Removing ${episodeIds.length} episodes from space ${spaceId} for user ${userId}`, `Removing ${episodeIds.length} episodes from space ${spaceId} for user ${userId}`,
); );
await this.removeEpisodesFromSpace(episodeIds, spaceId, userId); await removeEpisodesFromSpace(episodeIds, spaceId, userId);
logger.info( logger.info(
`Successfully removed ${episodeIds.length} episodes from space ${spaceId}`, `Successfully removed ${episodeIds.length} episodes from space ${spaceId}`,
@ -432,49 +344,6 @@ export class SpaceService {
}); });
} }
/**
* Get spaces that contain specific statements
*/
async getSpacesForStatements(
statementIds: string[],
userId: string,
): Promise<{ statementId: string; spaces: Space[] }[]> {
const userSpaces = await this.getUserSpaces(userId);
const result: { statementId: string; spaces: Space[] }[] = [];
for (const statementId of statementIds) {
const spacesContainingStatement = [];
for (const space of userSpaces) {
const statements = await this.getSpaceStatements(space.id, userId);
if (statements.some((stmt) => stmt.uuid === statementId)) {
spacesContainingStatement.push(space);
}
}
result.push({
statementId,
spaces: spacesContainingStatement,
});
}
return result;
}
/**
* Initialize spaceIds for existing statements (migration utility)
*/
async initializeSpaceIds(userId?: string): Promise<number> {
logger.info(
`Initializing spaceIds for ${userId ? `user ${userId}` : "all users"}`,
);
const updatedCount = await initializeStatementSpaceIds(userId);
logger.info(`Initialized spaceIds for ${updatedCount} statements`);
return updatedCount;
}
/** /**
* Validate space access * Validate space access
*/ */
@ -482,41 +351,4 @@ export class SpaceService {
const space = await this.getSpace(spaceId, userId); const space = await this.getSpace(spaceId, userId);
return space !== null && space.isActive; return space !== null && space.isActive;
} }
/**
* Bulk assign statements to multiple spaces
*/
async bulkAssignStatements(
statementIds: string[],
spaceIds: string[],
userId: string,
): Promise<{ spaceId: string; result: SpaceAssignmentResult }[]> {
logger.info(
`Bulk assigning ${statementIds.length} statements to ${spaceIds.length} spaces for user ${userId}`,
);
const results: { spaceId: string; result: SpaceAssignmentResult }[] = [];
for (const spaceId of spaceIds) {
try {
const result = await this.assignStatementsToSpace(
statementIds,
spaceId,
userId,
);
results.push({ spaceId, result });
} catch (error) {
results.push({
spaceId,
result: {
success: false,
statementsUpdated: 0,
error: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
return results;
}
} }

View File

@ -125,6 +125,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-resizable-panels": "^1.0.9", "react-resizable-panels": "^1.0.9",
"react-hotkeys-hook": "^4.5.0",
"react-virtualized": "^9.22.6", "react-virtualized": "^9.22.6",
"remix-auth": "^4.2.0", "remix-auth": "^4.2.0",
"remix-auth-oauth2": "^3.4.1", "remix-auth-oauth2": "^3.4.1",
@ -135,6 +136,7 @@
"stripe": "19.0.0", "stripe": "19.0.0",
"simple-oauth2": "^5.1.0", "simple-oauth2": "^5.1.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tiptap-markdown": "0.9.0",
"tailwind-scrollbar-hide": "^2.0.0", "tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tailwindcss-textshadow": "^2.1.3", "tailwindcss-textshadow": "^2.1.3",

View File

@ -39,6 +39,7 @@ export interface EpisodicNode {
sessionId?: string; sessionId?: string;
recallCount?: number; recallCount?: number;
chunkIndex?: number; // Index of this chunk within the document chunkIndex?: number; // Index of this chunk within the document
spaceIds?: string[];
} }
/** /**

53
pnpm-lock.yaml generated
View File

@ -607,6 +607,9 @@ importers:
react-dom: react-dom:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-hotkeys-hook:
specifier: ^4.5.0
version: 4.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-markdown: react-markdown:
specifier: 10.1.0 specifier: 10.1.0
version: 10.1.0(@types/react@18.2.69)(react@18.3.1) version: 10.1.0(@types/react@18.2.69)(react@18.3.1)
@ -655,6 +658,9 @@ importers:
tiny-invariant: tiny-invariant:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.3 version: 1.3.3
tiptap-markdown:
specifier: 0.9.0
version: 0.9.0(@tiptap/core@2.25.0(@tiptap/pm@2.25.0))
zod: zod:
specifier: 3.25.76 specifier: 3.25.76
version: 3.25.76 version: 3.25.76
@ -5741,9 +5747,15 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -5753,6 +5765,9 @@ packages:
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@1.0.5':
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
'@types/mdurl@2.0.0': '@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@ -9066,6 +9081,9 @@ packages:
resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
markdown-it@14.1.0: markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true hasBin: true
@ -10584,6 +10602,12 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
react-hotkeys-hook@4.6.2:
resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==}
peerDependencies:
react: '>=16.8.1'
react-dom: '>=16.8.1'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -11556,6 +11580,11 @@ packages:
tiptap-extension-global-drag-handle@0.1.18: tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tiptap-markdown@0.9.0:
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
peerDependencies:
'@tiptap/core': ^3.0.1
tmp@0.0.33: tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'} engines: {node: '>=0.6.0'}
@ -18728,8 +18757,15 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/markdown-it@13.0.9':
dependencies:
'@types/linkify-it': 3.0.5
'@types/mdurl': 1.0.5
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
dependencies: dependencies:
'@types/linkify-it': 5.0.0 '@types/linkify-it': 5.0.0
@ -18743,6 +18779,8 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/mdurl@1.0.5': {}
'@types/mdurl@2.0.0': {} '@types/mdurl@2.0.0': {}
'@types/mdx@2.0.13': {} '@types/mdx@2.0.13': {}
@ -22608,6 +22646,8 @@ snapshots:
markdown-extensions@1.1.1: {} markdown-extensions@1.1.1: {}
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0: markdown-it@14.1.0:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@ -24536,6 +24576,11 @@ snapshots:
- utf-8-validate - utf-8-validate
- webpack-cli - webpack-cli
react-hotkeys-hook@4.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@17.0.2: {} react-is@17.0.2: {}
@ -25790,6 +25835,14 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {} tiptap-extension-global-drag-handle@0.1.18: {}
tiptap-markdown@0.9.0(@tiptap/core@2.25.0(@tiptap/pm@2.25.0)):
dependencies:
'@tiptap/core': 2.25.0(@tiptap/pm@2.25.0)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1
prosemirror-markdown: 1.13.2
tmp@0.0.33: tmp@0.0.33:
dependencies: dependencies:
os-tmpdir: 1.0.2 os-tmpdir: 1.0.2