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 { 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,
];

View File

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

View File

@ -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 (
<div className="flex items-center py-1">
<span className="text-muted-foreground min-w-[160px]">{label}</span>
<div className="flex items-center py-1 !text-base">
<span className="text-muted-foreground min-w-[120px]">{label}</span>
{variant === "status" ? (
<Badge
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,
)}
>
@ -49,7 +50,13 @@ function PropertyItem({
{value}
</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}
{value}
</Badge>
@ -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 (
<div className="flex h-full w-full flex-col items-center overflow-auto">
<div className="max-w-4xl">
<div className="px-4 pt-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="mt-5 mb-5 px-4">
<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
label="Session Id"
value={log.data?.sessionId?.toLowerCase()}
@ -174,6 +151,13 @@ export function LogDetails({ log }: LogDetailsProps) {
value={formatString(
log.data?.type ? log.data.type.toLowerCase() : "conversation",
)}
icon={
log.data?.type === "CONVERSATION" ? (
<MessageSquare size={16} />
) : (
<File size={16} />
)
}
variant="secondary"
/>
<PropertyItem
@ -192,15 +176,28 @@ export function LogDetails({ log }: LogDetailsProps) {
variant="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>
{/* Error Details */}
{log.error && (
<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="flex items-start gap-2 text-red-600">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
@ -212,21 +209,63 @@ export function LogDetails({ log }: LogDetailsProps) {
</div>
)}
<div className="flex flex-col items-center p-4 pt-0">
<div className="mb-2 flex w-full items-center justify-between">
<span>Content</span>
</div>
{/* Log Content */}
<div className="mb-4 w-full text-sm break-words whitespace-pre-wrap">
<div className="rounded-md">
<Markdown>{log.ingestText}</Markdown>
{log.data?.type === "CONVERSATION" && (
<div className="flex flex-col items-center p-4 pt-0">
{/* Log Content */}
<div className="mb-4 w-full break-words whitespace-pre-wrap">
<div className="rounded-md">
<Markdown>{log.ingestText}</Markdown>
</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 */}
<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>
</div>
<div className="rounded-md">

View File

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

View File

@ -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" ? (
<MessageSquare size={14} />
) : (
<File size={14} />
);
};
return (
@ -100,7 +105,7 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
<div className="flex items-center gap-1">
<Badge
className={cn(
"!bg-grayAlpha-100 text-muted-foreground rounded text-xs",
"text-muted-foreground rounded !bg-transparent text-xs",
)}
>
{getIngestType(log)}

View File

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

View File

@ -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 && (
<div className="text-muted-foreground p-4 text-center text-sm">
Loading more logs...
<LoaderCircle size={18} className="mr-1 animate-spin" />
</div>
)}
</div>

View File

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

View File

@ -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<typeof Sidebar>) {
const user = useUser();
return (
<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>
const [showAddMemory, setShowAddMemory] = React.useState(false);
<SidebarFooter className="px-2">
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
// Open command bar with Meta+K (Cmd+K on Mac, Ctrl+K on Windows/Linux)
useHotkeys("meta+k", (e) => {
e.preventDefault();
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>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
onClick={() => {
navigate("/settings/billing");
}}
>
<div>{user.availableCredits} credits</div>
</Button>
</SidebarMenuItem>
</SidebarMenu>
);

View File

@ -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) {
</div>
<CardTitle className="text-base">{space.name}</CardTitle>
<CardDescription className="line-clamp-2 text-xs">
{space.description || space.summary || "Knowledge space"}
<p
dangerouslySetInnerHTML={{
__html: space.description || space.summary || "Knowledge space",
}}
></p>
</CardDescription>
<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>
{space.statementCount} fact
{space.statementCount !== 1 ? "s" : ""}
{space.contextCount} episode
{space.contextCount !== 1 ? "s" : ""}
</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 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 (
<>
<div className="flex w-full items-center px-5 pr-2">
<div className="group flex w-full items-center px-5 pr-2">
<div
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
className={cn(
@ -37,19 +54,13 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
>
<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={cn("truncate text-left")}>{displayText}</div>
<Markdown>{displayText}</Markdown>
</div>
<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">
<Calendar className="h-3 w-3" />
{formatDate(fact.validAt)}
{formatDate(episode.validAt)}
</Badge>
{fact.invalidAt && (
<Badge variant="destructive" className="rounded text-xs">
Invalid since {formatDate(fact.invalidAt)}
</Badge>
)}
</div>
</div>
</div>

View File

@ -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<FilterStep>("main");
const selectedValidDateLabel = validDateOptions.find(
(d) => d.value === selectedValidDate,
)?.label;
const selectedSpaceFilterLabel = spaceFilterOptions.find(
(f) => f.value === selectedSpaceFilter,
)?.label;
const hasFilters = selectedValidDate || selectedSpaceFilter;
return (
<div className="mb-2 flex w-full items-center justify-start gap-2 px-5">
<>
<Popover
open={popoverOpen}
onOpenChange={(open) => {
@ -79,13 +69,6 @@ export function SpaceFactsFilters({
>
Valid Date
</Button>
<Button
variant="ghost"
className="justify-start"
onClick={() => setStep("spaceFilter")}
>
Status
</Button>
</div>
)}
@ -122,40 +105,6 @@ export function SpaceFactsFilters({
))}
</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>
</PopoverPortal>
</Popover>
@ -172,17 +121,8 @@ export function SpaceFactsFilters({
/>
</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>
</>
);
}

View File

@ -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 (
<CellMeasurer
@ -38,23 +37,23 @@ function FactItemRenderer(
rowIndex={index}
>
<div key={key} style={style} className="pb-2">
<SpaceFactCard fact={fact} />
<SpaceEpisodeCard episode={episode} />
</div>
</CellMeasurer>
);
}
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<CellMeasurerCache | null>(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 (
<Card className="bg-background-2 w-full">
<CardContent className="bg-background-2 flex w-full items-center justify-center py-16">
<div className="text-center">
<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">
This space doesn't contain any facts yet.
This space doesn't contain any episodes yet.
</p>
</div>
</CardContent>
@ -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 (
<div className="h-full grow overflow-hidden rounded-lg">
@ -131,7 +130,7 @@ export function SpaceFactsList({
{isLoading && (
<div className="text-muted-foreground p-4 text-center text-sm">
Loading more facts...
Loading more episodes...
</div>
)}
</div>

View File

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

View File

@ -40,7 +40,7 @@ const CommandDialog = ({
<Dialog {...props}>
<DialogContent className={cn("overflow-hidden p-0 font-sans")}>
<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}
>
{children}
@ -141,7 +141,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
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,
)}
{...props}

View File

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

View File

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

View File

@ -145,10 +145,8 @@ export const getClusteredGraphData = async (userId: string) => {
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

View File

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

View File

@ -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,

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",
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 });
},

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(),
});
// 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 });
},
);

View File

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

View File

@ -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<typeof loader>();
export default function Episodes() {
const { episodes } = useLoaderData<typeof loader>();
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 (
<div className="flex h-full w-full flex-col pt-5">
<SpaceFactsFilters
selectedValidDate={selectedValidDate}
selectedSpaceFilter={selectedSpaceFilter}
onValidDateChange={setSelectedValidDate}
onSpaceFilterChange={setSelectedSpaceFilter}
/>
<div className="mb-2 flex w-full items-center justify-start gap-2 px-5">
<SpaceEpisodesFilters
selectedValidDate={selectedValidDate}
selectedSpaceFilter={selectedSpaceFilter}
onValidDateChange={setSelectedValidDate}
onSpaceFilterChange={setSelectedSpaceFilter}
/>
</div>
<div className="flex h-[calc(100vh_-_56px)] w-full">
<ClientOnly
fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
>
{() => (
<SpaceFactsList
facts={filteredStatements}
<SpaceEpisodesList
episodes={filteredEpisodes}
hasMore={false} // TODO: Implement real pagination
loadMore={loadMore}
isLoading={false}

View File

@ -148,7 +148,7 @@ export default function Overview() {
variant="ghost"
className="text-muted-foreground mb-1 -ml-2 gap-1"
>
Summary
Context
<ChevronDown
size={14}
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 { SpaceOptions } from "~/components/spaces/space-options";
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) {
const userId = await requireUserId(request);
@ -23,6 +26,7 @@ export default function Space() {
const space = useTypedLoaderData<typeof loader>();
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() {
}
>
{() => (
<SpaceOptions
id={space.id as string}
name={space.name}
description={space.description}
/>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setShowAddMemory(true)}
>
Add episode
</Button>
<SpaceOptions
id={space.id as string}
name={space.name}
description={space.description}
/>
</div>
)}
</ClientOnly>
}
/>
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto">
<Outlet />
{showAddMemory && (
<AddMemoryDialog
open={showAddMemory}
onOpenChange={setShowAddMemory}
defaultSpaceId={space.id}
/>
)}
</div>
</>
);

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
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> {
const query = `
@ -72,6 +76,8 @@ export async function getEpisode(uuid: string): Promise<EpisodicNode | null> {
userId: episode.userId,
space: episode.space,
sessionId: episode.sessionId,
recallCount: episode.recallCount,
spaceIds: episode.spaceIds,
};
}
@ -140,7 +146,7 @@ export async function searchEpisodesByEmbedding(params: {
}) {
const limit = params.limit || 100;
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
WHERE episode.userId = $userId
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 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
WHERE episode.userId = $userId
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
`;
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, { userId, spaceId });
const updatedStatements = cleanupStatementsResult[0]?.get("updatedStatements") || 0;
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, {
userId,
spaceId,
});
const updatedStatements =
cleanupStatementsResult[0]?.get("updatedStatements") || 0;
// 3. Clean up episode references (remove spaceId from spaceIds arrays)
const cleanupEpisodesQuery = `
@ -168,8 +172,12 @@ export async function deleteSpace(
RETURN count(e) as updatedEpisodes
`;
const cleanupEpisodesResult = await runQuery(cleanupEpisodesQuery, { userId, spaceId });
const updatedEpisodes = cleanupEpisodesResult[0]?.get("updatedEpisodes") || 0;
const cleanupEpisodesResult = await runQuery(cleanupEpisodesQuery, {
userId,
spaceId,
});
const updatedEpisodes =
cleanupEpisodesResult[0]?.get("updatedEpisodes") || 0;
// 4. Delete the space node and all its relationships
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
*/
@ -508,3 +356,36 @@ export async function getSpaceEpisodeCount(
const result = await runQuery(query, { spaceId, userId });
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 { getEpisode } from "./graphModels/episode";
import { getSpacesForEpisodes } from "./graphModels/space";
export async function getIngestionLogs(
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
const log = await prisma.ingestionQueue.findUnique({
where: { id: id },
@ -66,6 +71,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
type: true,
output: true,
data: true,
workspaceId: true,
activity: {
select: {
text: true,
@ -94,7 +100,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
log.activity?.integrationAccount?.integrationDefinition;
const logData = log.data as any;
const formattedLog = {
const formattedLog: any = {
id: log.id,
source: integrationDef?.name || logData?.source || "Unknown",
ingestText:
@ -112,9 +118,76 @@ export const getIngestionQueueForFrontend = async (id: string) => {
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;
};
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) => {
return await prisma.ingestionQueue.delete({
where: {

View File

@ -33,14 +33,6 @@ export async function getUser(request: 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) {
const userId = await getUserId(request);
if (!userId) {
@ -71,6 +63,7 @@ export async function requireUser(request: Request) {
confirmedBasicDetails: user.confirmedBasicDetails,
onboardingComplete: user.onboardingComplete,
isImpersonating: !!impersonationId,
workspaceId: user.Workspace?.id,
};
}

View File

@ -3,20 +3,18 @@ import {
type SpaceNode,
type CreateSpaceParams,
type UpdateSpaceParams,
type SpaceAssignmentResult,
} from "@core/types";
import { type Space } from "@prisma/client";
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
import {
assignEpisodesToSpace,
assignStatementsToSpace,
createSpace,
deleteSpace,
getSpace,
getSpaceStatements,
initializeStatementSpaceIds,
removeStatementsFromSpace,
getSpaceEpisodeCount,
getSpaceEpisodes,
removeEpisodesFromSpace,
updateSpace,
} from "./graphModels/space";
import { prisma } from "~/trigger/utils/prisma";
@ -278,97 +276,11 @@ export class SpaceService {
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
*/
async getSpaceEpisodes(spaceId: string, userId: string) {
logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`);
const { getSpaceEpisodes } = await import("./graphModels/space");
return await getSpaceEpisodes(spaceId, userId);
}
@ -384,7 +296,7 @@ export class SpaceService {
`Assigning ${episodeIds.length} episodes to space ${spaceId} for user ${userId}`,
);
await assignEpisodesToSpace(episodeIds,spaceId, userId);
await assignEpisodesToSpace(episodeIds, spaceId, userId);
logger.info(
`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}`,
);
await this.removeEpisodesFromSpace(episodeIds, spaceId, userId);
await removeEpisodesFromSpace(episodeIds, spaceId, userId);
logger.info(
`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
*/
@ -482,41 +351,4 @@ export class SpaceService {
const space = await this.getSpace(spaceId, userId);
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-markdown": "10.1.0",
"react-resizable-panels": "^1.0.9",
"react-hotkeys-hook": "^4.5.0",
"react-virtualized": "^9.22.6",
"remix-auth": "^4.2.0",
"remix-auth-oauth2": "^3.4.1",
@ -135,6 +136,7 @@
"stripe": "19.0.0",
"simple-oauth2": "^5.1.0",
"tailwind-merge": "^2.6.0",
"tiptap-markdown": "0.9.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-textshadow": "^2.1.3",

View File

@ -39,6 +39,7 @@ export interface EpisodicNode {
sessionId?: string;
recallCount?: number;
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:
specifier: ^18.2.0
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:
specifier: 10.1.0
version: 10.1.0(@types/react@18.2.69)(react@18.3.1)
@ -655,6 +658,9 @@ importers:
tiny-invariant:
specifier: ^1.3.1
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:
specifier: 3.25.76
version: 3.25.76
@ -5741,9 +5747,15 @@ packages:
'@types/json5@0.0.29':
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':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@ -5753,6 +5765,9 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@1.0.5':
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@ -9066,6 +9081,9 @@ packages:
resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==}
engines: {node: '>=0.10.0'}
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@ -10584,6 +10602,12 @@ packages:
engines: {node: '>=18.0.0'}
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:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -11556,6 +11580,11 @@ packages:
tiptap-extension-global-drag-handle@0.1.18:
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:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
@ -18728,8 +18757,15 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/linkify-it@3.0.5': {}
'@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':
dependencies:
'@types/linkify-it': 5.0.0
@ -18743,6 +18779,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@1.0.5': {}
'@types/mdurl@2.0.0': {}
'@types/mdx@2.0.13': {}
@ -22608,6 +22646,8 @@ snapshots:
markdown-extensions@1.1.1: {}
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@ -24536,6 +24576,11 @@ snapshots:
- utf-8-validate
- 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@17.0.2: {}
@ -25790,6 +25835,14 @@ snapshots:
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:
dependencies:
os-tmpdir: 1.0.2